# 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 `<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.
