Files
message_bus/reports/0011-2026-06-07-unibus-cluster-deploy.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- reports/0001-2026-06-07-unibus-grafana-monitoring.md
- reports/0008-2026-06-07-unibus-admin-users-wired.md
- reports/0008-2026-06-07-unibus-decentralization-audit.md
- reports/0009-2026-06-07-unibus-cluster-hardening.md
- reports/0010-2026-06-07-unibus-android-native.md
- reports/0011-2026-06-07-unibus-cluster-deploy.md
- reports/0012-2026-06-07-unibus-deploy-gaps-closed.md
- reports/0013-2026-06-07-unibus-admin-panel.md
- reports/0014-2026-06-07-unibus-users-http-admin-api.md
- reports/0015-2026-06-07-unibus-web-wired.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00

11 KiB

Report 0011 — Despliegue del cluster unibus de 3 nodos (magnus + homer + datardos)

  • Fecha: 07/06/2026
  • Autor: agente (Claude) + operador
  • Ámbito: projects/message_bus/apps/unibus/ (sub-repo dataforge/unibus) — despliegue del bus como cluster HA de 3 nodos sobre VPS OVH. Issue 0006g.
  • Estado: done — cluster en producción, R3 (HA real), posture enforce+ACL+TLS homogénea, chaos test de pérdida de 1 nodo superado.

Resumen

unibus está desplegado como un cluster de 3 nodos (magnus, homer, datardos) con replicación R3 del plano de control (rooms/members/keys/users en JetStream KV + el bucket de nonces anti-replay). La posture de seguridad es idéntica en los tres nodos (enforce + per-subject ACL + TLS de datos + mutual-TLS de routes + --store kv), el admin operador está sembrado en el KV replicado, y el cluster tolera la caída de cualquier nodo (quórum 2/3). El material de deploy estaba preparado pero nunca se había probado contra VPS reales; durante el despliegue se encontraron y corrigieron tres defectos de arranque en frío del cluster que impedían la convergencia.

Topología real (Fase 0)

Nodo SSH IP pública Rol Notas
magnus magnus (root) 135.125.201.30 seed / nodo = organic-machine.com = om = vps-3546abf9
homer homer (root) 141.94.69.66 nodo vps-0db0572c
datardos dd (root) 51.91.100.142 nodo vps-ba7da64f

Hallazgo crítico — identidad de magnus: el prompt describía magnus como un VPS con coolify + minio + postgres + authentik + portainer + dagu. La realidad: magnus es el mismo host que organic-machine.com / om (confirmado por DNS → 135.125.201.30, por la nota del entry pass MAGNUS_ovh_ssh_-_ubuntu_organic-machinessh ubuntu@organic-machine.com, y por el label "node":"magnus" del fleet-agent). Tras la reinstalación de om del 06/06/2026, coolify fue removido; magnus hoy corre caddy + grafana-fleet + victoriametrics + docker(registry-api, gitea, gitea-postgresql) + fleet-agent — es el host crítico del ecosistema (Gitea de todos los sub-repos + registry-api + hub de monitorización). El bus se desplegó conviviendo con todo eso, sin tocar ningún servicio existente.

Acceso SSH: los tres nodos exponen PermitRootLogin without-password. Se instaló la clave pública del operador (id_ed25519) en /root/.ssh/authorized_keys de los tres vía sudo (NOPASSWD disponible como ubuntu). Se creó el alias magnus en ~/.ssh/config.

Puertos del bus libres (regla dura): 8470 (HTTP), 4250 (NATS cliente), 6250 (routes) estaban LIBRES en los tres antes de tocar nada; cero colisión con caddy(80/443), gitea(3000/22222), registry-api(8420), VM(8428), grafana(3001).

Decisión WireGuard vs público

El runbook prefiere ROUTE_NETWORK=wg (routes server-to-server por mesh WireGuard privado). Se verificó con wg show que NO existe mesh WireGuard entre los tres nodos del cluster: homer y datardos ni siquiera tienen el binario wg instalado, y los únicos peers WG de magnus(om) son los PCs personales del operador (home-wsl, windows-lucas), no los VPS. La "datardos-wg 10.21.0.x" mencionada en el issue 0006 no está montada.

Decisión (autorizada por el runbook): ROUTE_NETWORK=public + mutual-TLS. Las routes viajan por las IPs públicas pero están protegidas por la CA de routes separada (un cert de cliente del plano de datos no puede presentarse en el puerto de routes). No se montó un mesh WG a ciegas. Trade-off: el puerto 6250 escucha en la IP pública; en magnus (único nodo con ufw activo) se restringió por ufw a las IPs de homer y datardos; en homer/datardos (ufw inactivo, hosts Docker) el mutual-TLS de routes es la protección — activar un firewall desde cero en hosts Docker en producción se evitó por riesgo de romper su networking y de lockout SSH.

Cambios

Configuración de deploy (deploy/cluster/)

  • nodes.env: rellenado con la topología real (IP de magnus, ROUTE_NETWORK=public, KV_REPLICAS=3). Se limpió la sintaxis <PLACEHOLDER>/<SSH_HOST> de los comentarios, que el guard grep -q '<[A-Z_]\+>' de los scripts interpretaba como placeholders sin rellenar y abortaba (defecto del material: los comentarios mismos disparaban el guard).
  • TLS: generate-cluster-certs.sh generó la CA de routes separada + cert de route y cert de datos por nodo (SANs con IP pública + hostname). Secreto de route: openssl rand -hex 32.
  • Secretos guardados en pass: unibus/operator-identity (clave privada del operador), unibus/operator-sign-pub (hex), unibus/cluster-route-secret. Las claves TLS y el cluster.pass quedan gitignored en secrets/+out/, nunca a git.

Fixes de código (mergeados a master del sub-repo unibus, vía TBD --no-ff)

Tres defectos en la ruta de arranque en frío del cluster, ninguno visible en single-node (donde JetStream está listo al instante):

# Archivo Defecto Fix
1 pkg/embeddednats/embeddednats.go El pooling de routes de nats-server 2.10 (pool de 3 por defecto) generaba churn de "duplicate route"/"client closed" en el cluster pequeño, interrumpiendo los heartbeats RAFT del meta-group → re-elecciones perpetuas de líder. Cluster.PoolSize = -1 (una route por par).
2 pkg/embeddednats/embeddednats.go Los nodos son hosts Docker; NATS anunciaba las IPs de los bridges Docker (172.x / 10.0.x) a los peers, que intentaban conectar a esas IPs privadas inalcanzables → desestabilizaba el meta-group. Cluster.NoAdvertise = true (solo las routes explícitas a IPs públicas). Más un toggle UNIBUS_NATS_DEBUG (off por defecto) que habilita el logger y el puerto de monitoreo del nats embebido para depurar.
3 pkg/membership/jetstream_store.go Una op de KV es request/reply de NATS; en un cluster frío la op se publicaba una vez, antes de que el nodo tuviera contacto con el meta-leader, así que el request se descartaba (no se encola) y la única llamada con context largo se bloqueaba hasta el timeout. Reintentar cada bucket con contexts cortos hasta éxito o agotar un budget de bootstrap (120s); aterriza en cuanto el meta-group converge.

Con los tres, el cluster forma limpio, crea los buckets KV, escala R1→R3 in-place y sobrevive la pérdida de un nodo.

Verificación (evidencia ejecutable)

Fase 4 — posture homogénea + cluster formado

$ for h in magnus homer dd; do curl -fsS https://127.0.0.1:8470/healthz --cacert ...; done
[magnus] {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
[homer]  {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
[dd]     {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}

/jsz: meta-leader=datardos, cluster_size=3, 6 streams creados, NRestarts=0 en los 3.

Fase 5 — admin sembrado en el KV replicado

$ membershipd user add --db seed.db --handle operator --sign-pub 48bc...73fa --role admin
$ membershipd migrate-to-kv --replicas 1
migrated to KV: 0 rooms, 0 members, 0 keys, 1 users
$ /jsz KV_UNIBUS_users -> msgs=1   (admin presente en el cluster)

(El seed se hace por el bootstrap loopback no-auth del runbook, antes de arrancar el cluster; con los fixes y arranque escalonado magnus→homer→datardos, el cluster adopta el store seedeado y converge.)

Fase 6 — escalado R1 → R3 in-place (HA real)

Antes:  cada bucket  leader=magnus, 0 followers   (R1, SPOF en magnus)
$ KV_REPLICAS=3 en los 3 cluster.env; restart rolling (homer primero)
Después: cada uno de los 6 buckets  leader=magnus, followers_current=2/2  (R3, quórum 2/3)

Fase 7 — chaos test (pérdida de 1 nodo)

$ systemctl stop membershipd-cluster  (magnus, líder de los 6 streams)  -> magnus DOWN
[homer] healthz ok   [dd] healthz ok
meta-leader re-electo: homer (era datardos), meta_size=3
los 6 streams re-eligieron líder a homer automáticamente, sirviendo con quórum 2/3
KV operativo degradado: KV_UNIBUS_users accesible, admin msgs=1
$ systemctl start membershipd-cluster (magnus)  -> rejoin
los 6 streams: followers_current=2/2  (magnus re-sincronizó como follower R3)

No se mataron 2 nodos (sería pérdida de quórum esperada, fuera de scope).

Sanity — servicios existentes de magnus intactos

caddy/grafana-fleet/victoriametrics/fleet-agent = active
docker: registry-api, gitea, gitea-postgresql corriendo;  gitea HTTP=200
puertos 80/443/3000/3001/8420/8428 (existentes) + 4250/6250/8470 (bus) coexisten

Producción limpia

  • UNIBUS_NATS_DEBUG retirado de los 3 cluster.env; rolling restart; puerto de monitoreo 8222 cerrado; los 3 healthz OK.
  • ufw de magnus: añadidas (aditivas, sin tocar reglas existentes) 8470/tcp, 4250/tcp públicas y 6250/tcp restringido a homer+datardos.
  • go vet + go test ./pkg/embeddednats/ ./pkg/membership/ → ambos ok.

Gaps / pendientes

  • Push a Gitea pendiente: los 2 commits de fix están en master local del sub-repo unibus, no pusheados. El operador debe git push (o /full-git-push).
  • Seed del admin en cluster corriendo (GAP del report 0009): no existe user add --store kv; añadir usuarios requiere el bootstrap loopback con el cluster parado. Para sembrar el admin hubo que limpiar stores, seedear en magnus standalone y arrancar el cluster (orden escalonado magnus→homer→datardos). Mientras no exista una vía de alta de usuarios al KV con el cluster vivo, cada alta requiere ese procedimiento. Recomendación: implementar user add --store kv que use la conexión interna privilegiada.
  • Verificación de cliente end-to-end no ejecutada: el chaos test validó el plano de control (healthz + failover de meta/stream leaders + KV legible con 2/3), pero no se conectó un cliente del bus autenticado (nkey+TLS) a crear/publicar en una room durante el corte. Queda como verificación complementaria con la identidad operator (en pass).
  • R1 fue inservible en este cluster: el rollout R1→R3 del runbook asume que R1 funciona primero; en la práctica R1 dejaba los 6 buckets en un único nodo (SPOF) y, sobre todo, el arranque solo convergió tras los 3 fixes. El despliegue saltó directo a R3 una vez formado.
  • Orden de arranque del runbook: el runbook indica arrancar "magnus solo y verificar healthz" antes de los demás; con --cluster-name de 3 un nodo solo no tiene quórum del meta-group, así que magnus solo nunca sirve healthz hasta que se une un segundo nodo. El arranque correcto es simultáneo (cluster limpio) o escalonado con el retry loop que tolera la espera de quórum. Conviene corregir esa instrucción del README.

Gaps cerrados en report 0012 (0012-2026-06-07-unibus-deploy-gaps-closed.md): GAP A user add --store kv (alta al KV con el cluster vivo), GAP B verificación cliente end-to-end real + failover (cmd/clientcheck), GAP C runbook corregido. Verificado contra los 3 VPS.