d43ffae3ae
- 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>
174 lines
11 KiB
Markdown
174 lines
11 KiB
Markdown
# 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.
|