# 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-machine` → `ssh 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 ``/`` 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.