Saltar al contenido
cloud-webv0.1.0
Documentación

Protocolo agente–plano de control

Contrato entre el plano de control (cloud-web) y el agente de nodo (cloud-web-node). Los dos viven en repositorios y se publican por separado, así que este documento es lo que tienen en común.

Versión de protocolo vigente: 1.

Transporte y autenticación

  • HTTPS en el puerto del listener de agentes (por defecto :8443), TLS 1.3.
  • POST /agent/v1/join solo exige TLS de servidor: el agente fija la CA por huella (--ca-sha256).
  • El resto de las rutas exigen certificado de cliente emitido por la CA del clúster para un nodo que siga registrado. Eliminar un nodo revoca su acceso.
  • Las órdenes se entregan por una cola persistente con confirmación (ack); el stream SSE solo avisa de que hay algo nuevo.

Endpoints (protocolo 1)

Método y ruta Uso
POST /agent/v1/join Canjea un token de unión; devuelve certificado de cliente, CA y peers
POST /agent/v1/heartbeat Latido y capacidad actual del nodo
GET /agent/v1/peers Peers de la malla WireGuard
GET /agent/v1/commands Órdenes pendientes (deploy, stop y, con bases gestionadas, db.*)
POST /agent/v1/commands/{id}/ack Confirma una orden
POST /agent/v1/leave[?force=1] El nodo pide su propia baja (desinstalación); 409 si aún corre apps, salvo con force=1: el nodo, que está vivo y es quien se borra, puede forzarla y sus despliegues pasan a fallidos
POST /agent/v1/report Estado y logs de despliegues
GET /agent/v1/routes Tabla de rutas para el ingress
GET /agent/v1/registry Configuración del registro de imágenes interno
POST /agent/v1/databases/report Estado de las bases del nodo y resultado de sus operaciones (ver Bases gestionadas)
GET /agent/v1/databases/files/{id} El agente baja un dump subido por el usuario (admite Range)
PUT /agent/v1/databases/files/{id} El agente entrega un backup para que el dueño lo descargue
GET /agent/v1/stream SSE de avisos

Los esquemas JSON son los de apps/control-plane/internal/api/agent.go y apps/node-agent/internal/cpclient/cpclient.go; deben coincidir.

Latido y salud (campo opcional)

POST /agent/v1/heartbeat lleva la capacidad del nodo en el nivel superior (cpu_cores, mem_bytes, disk_bytes) y, desde la versión de agente 0.2, un campo opcional health:

{"cpu_cores": 8, "mem_bytes": 0, "disk_bytes": 0,
 "health": {"registry": {"configured": true, "reachable": false, "error": "i/o timeout"},
            "mesh": {"peers": 1, "connected": 0}}}
  • registry.reachable es si el nodo alcanza el registro de imágenes del clúster; solo se informa false tras dos chequeos seguidos fallidos. registry.configured=false significa que no hay registro que evaluar.
  • mesh.connected son los peers con un handshake de los últimos 3 minutos.
  • Es aditivo: no sube el entero de protocolo. Un servidor anterior ignora el campo y un agente anterior no lo envía; en ese caso la salud del nodo es desconocida y el nodo sigue siendo elegible.
  • El plano de control trata una salud de más de 60 s como desconocida y marca un nodo degradado solo si el registro está configurado y el nodo no lo alcanza (la malla se muestra pero no decide). Un nodo degradado no recibe instancias nuevas y se recupera solo cuando vuelve a alcanzar el registro.

Muestras de uso (metrics, campo opcional)

Desde la versión de agente 0.5 el latido puede llevar, una vez cada 30 s (no en cada latido), un campo opcional metrics con el uso real del nodo y de sus contenedores:

{"cpu_cores": 8, "mem_bytes": 0, "disk_bytes": 0,
 "metrics": {"node": {"cpu_pct": 12.5, "mem_used": 8589934592, "disk_used": 128849018880},
             "containers": [{"container": "cw-web-3", "cpu_cores": 0.25, "mem_bytes": 104857600, "mem_limit": 536870912}],
             "omitted": 0}}
  • node.cpu_pct es el porcentaje del total de núcleos (0 a 100) entre dos lecturas de /proc/stat; mem_used es MemTotal - MemAvailable; disk_used sale de statfs del directorio de datos. Lo que no se pudo medir falta (p. ej. cpu_pct en la primera muestra de un arranque): «sin dato» no es cero.
  • containers son los contenedores de apps (cw-*) con su CPU en núcleos usados (Docker lo da relativo a un núcleo y puede pasar de 100 %), su memoria y su límite. Una sola llamada a docker stats --no-stream por muestra, con tiempo límite de 10 s y en una goroutine aparte: ni su duración (~2,5 s con unos pocos contenedores) ni un fallo retrasan o impiden el latido. Sin contenedores en ejecución no se llama a Docker.
  • Cada contenedor puede traer restarts (reinicios) y started_at (arranque de la corrida actual, RFC 3339), de una llamada docker inspect por muestra (como mucho dos llamadas a Docker en total, con el mismo tiempo límite); si falla, el contenedor sale sin esos campos.
  • Como máximo 100 contenedores por muestra (los de más memoria); omitted cuenta los que quedaron afuera. Con 100 contenedores el campo pesa ~10 KB.
  • Es aditivo: no sube el entero de protocolo. Un servidor anterior ignora el campo; un agente anterior no lo envía y el panel de monitoreo muestra «sin datos» para ese nodo.

Tráfico HTTP por host (metrics.traffic, campo opcional)

Un nodo con ingress (capacidad traffic-metrics) suma a la muestra de uso el tráfico de las últimas 30 s por host:

{"metrics": {"traffic": [{"host": "web.zondacloud.com", "requests": 120, "r2xx": 110, "r3xx": 4, "r4xx": 5, "r5xx": 1, "dur_sum_ms": 5400.5, "slow": 2}],
             "traffic_omitted": 0}}
  • Son deltas del intervalo (no acumulados). slow son los pedidos de más de 1 s; la latencia media es dur_sum_ms / requests.
  • Caddy escribe su registro de accesos en un socket local del agente; el agente decodifica solo host, código y duración y cuenta únicamente los hosts de la tabla de rutas (un Host cualquiera no cuenta). Rutas, parámetros, direcciones de cliente y cabeceras nunca se leen ni se mandan.
  • Hasta 200 hosts por muestra (los de más pedidos); traffic_omitted cuenta los que quedaron afuera. Aditivo: un servidor anterior lo ignora.
  • Si el agente se reinicia, los pedidos de ese hueco no se cuentan: es una estimación, no un registro contable.

Capacidades del agente (health.features)

Desde la versión de agente 0.3 el mismo campo health puede llevar features, la lista de capacidades opcionales de ese agente:

{"health": {"registry": {...}, "mesh": {...}, "features": ["static-sites"]}}
  • static-sites: sabe construir y servir un sitio estático a partir de los ajustes de build de la orden de despliegue.
  • traffic-metrics: un nodo de ingress que informa el tráfico HTTP por host (metrics.traffic).
  • managed-databases: corre bases Postgres gestionadas y conecta las instancias de una app a la red de sus bases. Solo se informa con el aislamiento de usuarios (network-isolation) comprobado en el propio nodo, y mientras lo esté.
  • Es aditivo, igual que health: no sube el entero de protocolo. Un agente anterior no lo envía y un plano de control anterior lo ignora.
  • Como la salud caduca a los 60 s, una capacidad sin salud vigente no se supone.
  • El plano de control solo exige una capacidad cuando la app lo necesita (ver abajo); para todo lo demás un agente sin features sigue siendo un nodo elegible.

Docker Desktop

Un nodo cuyo motor de Docker es Docker Desktop informa registry.configured=true, reachable=false con el motivo Docker Desktop is not supported on a node…: el chequeo del registro lo hace el agente desde su red, pero quien sube las imágenes es el daemon, que en Docker Desktop corre en otra VM y no ve la malla. El nodo queda degradado y no recibe despliegues.

Orden de despliegue y sitios estáticos (build)

La orden deploy puede llevar un bloque opcional build con los ajustes de build que el usuario fijó para un sitio estático:

{"deployment_id": "dep_…", "source": {…}, "env": {…}, "port": 0,
 "build": {"install_cmd": "npm ci", "build_cmd": "npm run build:prod", "output_dir": "dist", "spa": "yes"}}
  • Todos los campos son opcionales; spa es auto, yes o no. Sin el bloque, el agente detecta todo.
  • Solo se envía en despliegues desde el código (no en un rollback ni en un reprogramado, que reutilizan una imagen ya construida).
  • El plano de control valida los valores y el agente vuelve a validarlos (largo, sin saltos de línea, directorio relativo y dentro del proyecto): no confía en el servidor.
  • Un agente anterior ignora el bloque. Para que no construya por buildpacks algo que no puede construirse, el planificador solo envía una app con ajustes explícitos a nodos que informan static-sites; si no hay ninguno, el despliegue falla diciendo qué nodos hay que actualizar. Una app sin ajustes se envía a cualquier nodo y el agente decide la estrategia (Dockerfile, sitio estático o buildpacks).
  • Solo las variables de la app con prefijo público (VITE_, REACT_APP_, NEXT_PUBLIC_, PUBLIC_, GATSBY_) entran al build de un sitio estático; el resto no.

Bases gestionadas (db.*)

Capacidad managed-databases (ver Capacidades del agente). Todo es aditivo: no sube el entero de protocolo.

Quién recibe qué

  • Una base se aloja en un nodo que informa managed-databases y tenant-isolation, con la salud vigente. Un agente anterior no la informa, así que nunca recibe una orden db.*, ni una app vinculada a una base (el planificador la excluye con el motivo "its agent predates managed databases: update the node's agent"). Si ningún nodo sirve, la creación responde 503 con el requisito que falta.
  • Una base queda anclada a su nodo para siempre (su volumen vive en el disco de ese nodo). Las apps vinculadas se programan en ese nodo.
  • Un plano de control anterior ignora la capacidad (features es una lista abierta) y nunca manda órdenes db.*: el agente nuevo sigue funcionando igual. Un plano de control sin bases (o un agente que no las soporta) responde 204 al informe de bases y 404 a los archivos.

Órdenes (cola persistente con ack)

Todas son idempotentes por id de base y de operación: un reintento o un reinicio del agente las repite sin duplicar nada. El agente las ejecuta de a una y en el orden en que llegaron, y un despliegue que lleva el bloque databases espera a que terminen las db.* que llegaron antes que él (la red de su base y su contraseña vigente ya tienen que estar).

Tipo Payload (campos) Efecto
db.provision database_id, name, version (14–17), storage_gb, memory_mb, network, subnet, alias, database, user, password Asegura red interna (--internal), volúmenes, contenedor endurecido, reglas de acceso, rol sin privilegios y base. También arranca una base detenida y aplica una contraseña rotada
db.stop database_id Detiene el contenedor sin tocar el volumen
db.remove database_id, version, final_backup_id? Con final_backup_id toma ese backup primero y, si falla, no borra nada; después borra contenedor, red y volumen de datos (el de backups queda)
db.backup database_id, backup_id, kind, version, database, user, network, keep pg_dump --format=custom a /backups/<backup_id>-<kind>.dump, con SHA-256 y retención
db.restore database_id, operation_id, backup_file, from_database_id, version, database, user, network, pre_backup_id? Restaura todo-o-nada (base temporal e intercambio atómico). Exige el backup de seguridad previo cuando hay pre_backup_id
db.import database_id, import_id, source (upload|url), url?, upload_id?, mode (empty|replace), version, database, user, network, pre_backup_id? Carga un dump (SQL plano, gzip, custom, tar)
db.export database_id, backup_id, backup_file, file_id Entrega el backup con PUT /agent/v1/databases/files/{file_id}
db.purge database_id Borra el volumen de backups de una base eliminada hace más de 7 días

El agente vuelve a validar todo lo que va a parar a un comando o a SQL (ids, nombres de base y rol, subred dentro de 172.31.0.0/16, alias *.db.internal, nombres de archivo de backup): no confía en el servidor. La contraseña del rol viaja en db.provision por mTLS, no en la línea de comandos de ningún proceso, y nunca sale del nodo en un mensaje de error.

Bloque databases de la orden deploy

{"deployment_id": "dep_…", "env": {"DATABASE_URL": "postgres://app_…:…@bo.db.internal:5432/db_…?sslmode=disable", "PGHOST": "bo.db.internal"},
 "databases": [{"database_id": "db_0123abcd", "network": "cwdb-db_0123abcd", "alias": "bo.db.internal"}]}
  • Las variables DATABASE_URL y PG* ya vienen resueltas en env (ganan sobre las de la app con el mismo nombre). El bloque dice a qué redes de bases hay que conectar la instancia antes de arrancarla; el agente solo acepta redes cwdb-<database_id>.
  • Opcional: un agente anterior lo ignora, por eso el planificador solo envía estas apps a nodos con managed-databases.

Informe de bases: POST /agent/v1/databases/report

Todos los campos son opcionales. Es idempotente y se rechaza entero con 403 si nombra una base de otro nodo.

{"databases": [{"database_id": "db_…", "state": "running", "used_bytes": 104857600, "connections": 3, "message": ""}],
 "backups":   [{"database_id": "db_…", "backup_id": "bk_…", "status": "ok", "file": "bk_…-manual.dump", "size_bytes": 4096, "sha256": "…",
                "duration_ms": 900, "error": "", "off_node": false, "pruned": ["bk_…"]}],
 "restores":  [{"database_id": "db_…", "operation_id": "bk_…", "status": "ok", "error": ""}],
 "imports":   [{"database_id": "db_…", "import_id": "im_…", "status": "failed", "bytes_read": 4096, "duration_ms": 1500,
                "source_version": "15.4", "error": "ERROR: …", "error_line": 42}],
 "removed": ["db_…"], "purged": ["db_…"],
 "logs":      [{"database_id": "db_…", "lines": [{"at": "2026-10-19T10:00:00.5Z", "line": "LOG:  …"}]}]}
  • state: running, stopped o failed. Se informa cada 30 s y al arrancar el agente (así un reinicio del nodo se refleja sin una orden). Un running no termina una restauración o una carga en curso: eso lo hace su resultado (restores, imports). Una base solo pasa a failed si nunca llegó a correr; un arranque que falla la devuelve a stopped.
  • logs son las líneas nuevas del log de Postgres de cada base desde el último envío (la primera vez, las últimas 200; hasta 500 por informe y base, de hasta 2000 bytes cada una). El plano de control conserva hasta 5000 líneas por base y 14 días.
  • used_bytes y connections alimentan el panel; con el almacenamiento elegido lleno el agente pone el rol en solo lectura y lo informa en message.
  • removed: la baja terminó (la base queda 7 días para restaurar su backup final); purged: se borraron también sus backups. El plano de control decide qué se elimina: un removed de una base que no estaba deleting se ignora.
  • Los resultados fallidos llevan el motivo; una restauración o carga fallida deja los datos previos y la base vuelve a running.

Archivos: GET/PUT /agent/v1/databases/files/{id}

  • GET (con Range para retomar) baja un dump que subió el usuario; solo de bases del propio nodo, y solo archivos de tipo upload.
  • PUT entrega el backup pedido por db.export; solo a archivos de tipo export de bases del propio nodo, y una sola vez.
  • Los archivos temporales viven hasta 24 h en el plano de control y se borran al terminar la carga (bien o mal).

Versionado

Cada petición del agente lleva dos cabeceras:

Cabecera Valor
X-Cloudweb-Agent-Version versión del binario (p. ej. 0.1.0)
X-Cloudweb-Protocol entero de protocolo que habla el agente
  • El entero de protocolo solo sube ante cambios incompatibles (quitar o cambiar el significado de un campo, de una ruta o de un flujo). Agregar campos opcionales o rutas nuevas no lo sube.
  • Un agente que no envía la cabecera es anterior al versionado y se trata como protocolo 1.
  • El plano de control declara el rango que entiende (MinAgentProtocol–MaxAgentProtocol).

Incompatibilidad

Si el protocolo del agente queda fuera del rango, el plano de control responde antes de ejecutar cualquier acción (no cambia estado, no consume el token de unión):

HTTP/1.1 426 Upgrade Required
{
  "error": "protocol 2 is not supported: this control plane supports protocols 1 to 1; upgrade the control-plane",
  "agent_version": "0.3.0",
  "agent_protocol": 2,
  "supported_min": 1,
  "supported_max": 1,
  "upgrade": "control-plane"
}

upgrade indica qué lado debe actualizarse (agent si el agente es más viejo que el mínimo, control-plane si es más nuevo que el máximo). Una cabecera mal formada da 400.

El agente, al recibir 426, lo registra como error con el rango soportado y baja el ritmo de reintento a uno cada 5 minutos; vuelve al ritmo normal en cuanto el plano de control lo acepta.

Cómo evolucionar el protocolo

  1. Cambios compatibles: se hacen en cualquier lado, sin subir el entero.
  2. Cambios incompatibles: subir MaxAgentProtocol del servidor para que acepte ambos protocolos durante una ventana de transición, publicar el servidor, y recién después el agente que habla el nuevo. Retirar el protocolo viejo (MinAgentProtocol) en una versión posterior, avisándolo en las notas.
  3. Cada repositorio prueba su pipeline contra la última versión publicada del otro (ver tests/e2e/ y el pipeline de cloud-web-node).