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/joinsolo 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.reachablees si el nodo alcanza el registro de imágenes del clúster; solo se informafalsetras dos chequeos seguidos fallidos.registry.configured=falsesignifica que no hay registro que evaluar.mesh.connectedson 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_pctes el porcentaje del total de núcleos (0 a 100) entre dos lecturas de/proc/stat;mem_usedesMemTotal - MemAvailable;disk_usedsale destatfsdel directorio de datos. Lo que no se pudo medir falta (p. ej.cpu_pcten la primera muestra de un arranque): «sin dato» no es cero.containersson 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 adocker stats --no-streampor 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) ystarted_at(arranque de la corrida actual, RFC 3339), de una llamadadocker inspectpor 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);
omittedcuenta 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).
slowson los pedidos de más de 1 s; la latencia media esdur_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_omittedcuenta 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
featuressigue 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;
spaesauto,yesono. 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-databasesytenant-isolation, con la salud vigente. Un agente anterior no la informa, así que nunca recibe una ordendb.*, 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 responde503con 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 (
featureses una lista abierta) y nunca manda órdenesdb.*: el agente nuevo sigue funcionando igual. Un plano de control sin bases (o un agente que no las soporta) responde204al informe de bases y404a 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_URLyPG*ya vienen resueltas enenv(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 redescwdb-<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,stoppedofailed. Se informa cada 30 s y al arrancar el agente (así un reinicio del nodo se refleja sin una orden). Unrunningno termina una restauración o una carga en curso: eso lo hace su resultado (restores,imports). Una base solo pasa afailedsi nunca llegó a correr; un arranque que falla la devuelve astopped.logsson 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_bytesyconnectionsalimentan el panel; con el almacenamiento elegido lleno el agente pone el rol en solo lectura y lo informa enmessage.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: unremovedde una base que no estabadeletingse 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(conRangepara retomar) baja un dump que subió el usuario; solo de bases del propio nodo, y solo archivos de tipoupload.PUTentrega el backup pedido pordb.export; solo a archivos de tipoexportde 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
- Cambios compatibles: se hacen en cualquier lado, sin subir el entero.
- Cambios incompatibles: subir
MaxAgentProtocoldel 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. - Cada repositorio prueba su pipeline contra la última versión publicada del otro (ver
tests/e2e/y el pipeline decloud-web-node).