Files
message_bus/reports/0012-2026-06-07-unibus-deploy-gaps-closed.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

181 lines
14 KiB
Markdown

# Report 0012 — Cierre de los gaps del despliegue del cluster unibus (report 0011)
- **Fecha:** 07/06/2026
- **Autor:** agente (Claude)
- **Ámbito:** `projects/message_bus/apps/unibus/` (sub-repo `dataforge/unibus`) — cierre de los gaps que el despliegue del cluster de 3 nodos (report 0011) dejó abiertos. Rama `quick/0011-deploy-gaps` (worktree `/tmp/unibus_gaps`, basada en master).
- **Estado:** done — GAP A y GAP B cerrados con evidencia ejecutable contra el cluster VIVO (magnus + homer + datardos, R3); GAP C (runbook) corregido. Pendiente menor: rollout del binario nuevo a magnus+homer (documentado, sin urgencia — la capability está probada y desplegada en datardos).
## Resumen
El report 0011 dejó tres gaps. Este trabajo los cierra: (A) `membershipd user add|list|revoke --store kv` añade usuarios al KV replicado con el cluster en marcha vía la conexión interna privilegiada — sin parar-sembrar-rearrancar; (B) `cmd/clientcheck` es la verificación end-to-end real del plano de datos (cliente autenticado nkey+TLS, room E2E, publish/subscribe, incluido failover con un nodo caído) que el chaos test del 0011 nunca ejecutó; (C) el runbook `deploy/cluster/README.md` queda corregido (orden de arranque, R1 inservible, nueva vía de alta). Todo verificado contra los 3 VPS reales con la posture enforce+ACL+TLS+R3 intacta. `app.md` sube 0.8.0 → 0.9.0.
## Cambios
| Archivo | Qué | Por qué |
|---|---|---|
| `pkg/client/identity.go` | `LoadIdentity` (load-only) extraída de `LoadOrCreateIdentity` | La CLI `--store kv` y el daemon cargan la identidad de servicio persistida; se preserva el guard "archivo corrupto = error, no se regenera". |
| `cmd/membershipd/main.go` | flag `--internal-id-file` | Persiste la identidad interna privilegiada (load-or-create, 0600) en vez de efímera, para que la misma nkey esté disponible fuera de proceso. Vacío = efímero (default dev/single-node sin cambios). |
| `cmd/membershipd/users_kv.go` | `connectKVStore` + `reportKVReplication` (nuevo) | Conexión privilegiada al NATS del cluster con la identidad interna; abre el store KV y escribe. Rechaza remoto sin `--ca`. Imprime `followers_current` tras escribir. |
| `cmd/membershipd/users_cli.go` | `--store kv` en `add`/`list`/`revoke` + idempotencia explícita | Alta/baja contra el KV vivo. Re-alta de la misma clave = `ErrUserExists` (sin sobrescribir ni elevar rol). |
| `cmd/membershipd/kv_useradd_test.go` (nuevo) | tests de integración GAP A | Golden bajo enforce, idempotencia, endpoint muerto, remoto-sin-CA. |
| `cmd/clientcheck/` (nuevo) | comando de verificación E2E reutilizable | GAP B: cliente real, room E2E, golden + loop (failover). |
| `deploy/cluster/README.md` | runbook corregido | GAP C: arranque por quórum, R1 SPOF→R3, vía de alta live, topología real. |
| `deploy/cluster/membershipd-cluster.service`, `deploy-cluster.sh` | `--internal-id-file ${INTERNAL_ID_FILE}` + `INTERNAL_ID_FILE` en cluster.env | Para que un deploy fresco habilite la vía de alta live en todos los nodos. |
| `app.md` | 0.8.0 → 0.9.0 + growth log | Nueva capability. |
### Mecanismo de la conexión privilegiada (GAP A) — diseño y porqué
Bajo `enforce` la ACL por-subject confina a cada usuario del bus a la JetStream API de SUS rooms; ninguna identidad de usuario normal puede tocar los buckets `KV_UNIBUS_*`. La única identidad a la que el autenticador concede permisos plenos de JetStream es la **identidad de servicio interna** de `membershipd` (la que ya rompe el ciclo bootstrap del nonce/KV). Esa identidad era **efímera por proceso**, así que ningún proceso externo podía presentarla.
Solución elegida (la más simple y segura de las opciones del prompt — "creds del propio servicio" + "ejecución por loopback en un nodo"): el daemon **persiste** su identidad de servicio en `--internal-id-file` (cada nodo genera/carga la suya, `0600`, junto a las claves TLS del nodo). La CLI `user add --store kv`, ejecutada **por loopback en el nodo** (el SAN del cert data-plane cubre `127.0.0.1`), carga ese mismo archivo y presenta la nkey que el autenticador del nodo reconoce con permisos plenos → abre `OpenJetStream` y escribe el bucket `KV_UNIBUS_users` replicado.
Seguridad (no baja la posture): leer el archivo de identidad exige **root en el nodo**, lo que ya implica control total de ese nodo (la clave TLS del servidor y el `cluster.pass` ya están ahí). Co-ubicarlo no añade exposición práctica. La posture publicada en `/healthz` (enforce+acl+tls+cluster+store=kv) es idéntica con o sin el flag. Cambio respecto a 0011: la identidad interna pasa de efímera a durable (rotable borrando el archivo + reinicio); documentado.
## Verificación
> Secretos redactados: clave privada del operador, su `endpoint` derivado, el hex del operador (`48bc…`) y los de usuarios de prueba se muestran truncados. Nota de build: el worktree vive en `/tmp`, donde el `replace fn-registry => ../../../../` de `go.mod` no resuelve; se usó un `go.work` fuera del árbol (`GOWORK=/tmp/unibus_gaps.work`, NO commiteado) solo para compilar. El checkout canónico bajo `~/fn_registry/.../unibus` no lo necesita.
### Mecánica — vet + build + test (CGO_ENABLED=0)
```
$ CGO_ENABLED=0 go vet ./... -> rc=0
$ CGO_ENABLED=0 go build ./... -> rc=0
$ CGO_ENABLED=0 go test ./pkg/membership/ ./cmd/...
ok github.com/enmanuel/unibus/pkg/membership 8.455s
? github.com/enmanuel/unibus/cmd/chat [no test files]
? github.com/enmanuel/unibus/cmd/clientcheck [no test files]
ok github.com/enmanuel/unibus/cmd/membershipd 0.413s
? github.com/enmanuel/unibus/cmd/worker [no test files]
$ CGO_ENABLED=0 go test ./... (suite completa)
ok github.com/enmanuel/unibus/cmd/membershipd 0.370s
ok github.com/enmanuel/unibus/pkg/blobstore 0.091s
ok github.com/enmanuel/unibus/pkg/busauth 0.007s
ok github.com/enmanuel/unibus/pkg/client 6.168s
ok github.com/enmanuel/unibus/pkg/embeddednats 5.894s
ok github.com/enmanuel/unibus/pkg/frame 0.002s
ok github.com/enmanuel/unibus/pkg/membership 8.383s
```
Tests de integración GAP A (nodo enforce embebido, identidad de archivo):
```
$ go test ./cmd/membershipd/ -run TestUserAddStoreKV -count=1
ok github.com/enmanuel/unibus/cmd/membershipd 0.050s
TestUserAddStoreKV_GoldenAndIdempotent (escribe bajo enforce + ErrUserExists sin sobrescribir)
TestUserAddStoreKV_RequiresInternalIdentity (sin id file / id ausente -> error)
TestUserAddStoreKV_UnreachableKV (endpoint muerto -> error envuelto)
TestUserAddStoreKV_RemoteWithoutCARefused (remoto sin --ca -> rechazado)
```
### GAP A — `user add --store kv` contra el cluster VIVO
Despliegue de verificación: binario nuevo + `--internal-id-file` a **datardos** (nodo no-crítico) con backups reversibles; arranca con identidad persistida, posture intacta, reincorporado a R3:
```
$ ssh dd ... (restart con binario nuevo)
internal service identity: persisted (/opt/unibus/secrets/internal.id) # 0600 root
healthz: {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
```
**Golden** (alta nueva, replicada R3):
```
$ ssh dd 'sudo /opt/unibus/membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
added user "gapcheck_user" (a58b…5622) role=member
KV_UNIBUS_users: leader=homer followers_current=2/2 msgs=2 <-- replicado a los 2 followers
```
**Lectura replicada** (`user list --store kv` en datardos lee el KV replicado):
```
HANDLE ROLE STATUS SIGN_PUB CREATED
gapcheck_user member active a58b…5622 2026-06-07T17:33:00Z
operator admin active 48bc…<op> 2026-06-07T16:48:12Z
```
**Edge 1 — idempotencia** (re-alta de la misma clave, y misma clave con otro rol):
```
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace (exit 1)
$ ssh dd 'sudo .../membershipd user add --store kv --handle impostor --role admin --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace (exit 1)
-> NO se sobrescribe ni se eleva a admin (sin escalado de privilegios vía re-alta).
```
**Edge 2 — alta con un nodo caído (quórum 2/3)**. Se para homer (líder del stream); el stream re-elige líder a datardos y el alta commitea:
```
>>> STOP homer (líder de KV_UNIBUS_users) — quedan datardos+magnus = quórum 2/3
homer is-active=inactive
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user2 --role member --sign-pub e560…4bf4'
added user "gapcheck_user2" (e560…4bf4) role=member
KV_UNIBUS_users: leader=datardos followers_current=1/2 msgs=3 <-- líder failover homer->datardos; commit con quórum
>>> START homer (rejoin) -> is-active=active ; healthz 3/3 ; user list muestra los 3 usuarios
```
**Error 1 — KV inalcanzable** (puerto muerto):
```
$ ssh dd 'sudo .../membershipd user add --store kv --nats-url nats://127.0.0.1:4999 --handle x --sign-pub a58b…5622'
membershipd user add: --store kv: connect cluster NATS "nats://127.0.0.1:4999": nats: no servers available for connection (exit 1)
```
**Limpieza** (revoke de los 2 usuarios de prueba — `revoke` es flip de status, no hay hard-delete en KV):
```
$ ssh dd 'sudo .../membershipd user revoke --store kv a58b…5622' -> revoked user a58b…5622
$ ssh dd 'sudo .../membershipd user revoke --store kv e560…4bf4' -> revoked user e560…4bf4
$ user list --store kv:
gapcheck_user member revoked a58b…5622
gapcheck_user2 member revoked e560…4bf4
operator admin active 48bc…<op>
```
### GAP B — verificación cliente END-TO-END real
**Golden E2E** (identidad operator de `pass`, CA por path, nkey+TLS+https a los 3 nodos, room E2E efímera, 5 msgs):
```
$ clientcheck --ca ca.crt --identity-file <operator.id 0600> \
--nats-seeds nats://magnus,homer,datardos:4250 --ctrl-seeds https://...:8470 --messages 5
connected: endpoint=<redacted> nats=nats://51.91.100.142:4250
created E2E room: id=01KTHHXA… subject=test.gapcheck.968e58c37a2d3d81 (encrypt=true sign=true persist=false)
published 5 messages; waiting for decrypted echoes...
GOLDEN OK: all 5 messages received and decrypted end-to-end
```
**Edge E2E — failover con cliente conectado**. Loop publish/subscribe 1/s mientras se para el nodo atado (datardos):
```
loop: publishing every 1s for 1m0s — stop a node now to test failover
t= 7s sent=7 recv=6 up=true node=nats://51.91.100.142:4250 publish=ok <-- atado a datardos
>>> 19:29:07 STOP datardos (nodo atado)
t= 8s sent=8 recv=7 up=true node=nats://135.125.201.30:4250 publish=ok <-- FAILOVER a magnus, sin perder mensaje
>>> 19:29:29 START datardos (rejoin)
loop done: sent=56 received=56 <-- CERO pérdida a través del corte
attached to nats://135.125.201.30:4250 for 49 ticks
attached to nats://51.91.100.142:4250 for 7 ticks
FAILOVER OBSERVED: client was attached to 2 distinct nodes across the run
LOOP OK: client kept receiving across the run (received=56)
```
Cluster sano 3/3 tras el corte (datardos reincorporado a R3).
### GAP C — runbook corregido
`deploy/cluster/README.md`:
- **Arranque** (corregido): "arrancar magnus solo y verificar healthz" deadlockea — un nodo solo de un cluster de 3 no tiene quórum del meta-group RAFT y JetStream nunca queda current, así que `--store kv` no crea los buckets y `/healthz` no devuelve ok hasta que se une un segundo nodo. Se documenta el arranque que forma quórum (los 3 cerca, el orden da igual) o escalonado apoyado en el retry loop de 120s; un nodo solo NUNCA se auto-sirve, así que no se debe condicionar el arranque del siguiente al healthz del anterior. Se nombran los 3 fixes de cold-start del 0011.
- **R1 inservible** (corregido): a R1 los 6 buckets viven en un solo nodo (SPOF de autenticación); el cold start solo converge con los 3 fixes; ir directo a R3 una vez formado el cluster. R1 es artefacto transitorio, no hito.
- **Vía de alta con cluster vivo**: nueva sección documentando `user add --store kv` (mecanismo, idempotencia, HA, sin hard-delete) que sustituye parar-sembrar-rearrancar.
- **Topología real**: IPs reales, `ROUTE_NETWORK=public` (no hay mesh WireGuard), magnus = host crítico convivido.
## Gaps / pendientes
- **Rollout del binario nuevo a magnus + homer**: la verificación live de GAP A desplegó el binario `0.9.0` + `--internal-id-file` solo en **datardos** (nodo no-crítico). magnus y homer siguen con el binario 0011. La capability de alta está probada y disponible desde datardos; la escritura replica R3 a los otros dos. Estado mixto seguro a nivel de protocolo (cambios aditivos/node-local, posture idéntica). Para uniformidad + redundancia de la capability, rodar el binario a homer y magnus (comandos exactos en el README, con backups y healthz entre nodos). Se dejó como decisión del operador por tocar el host crítico magnus y por haber dos agentes vivos en el sub-repo; no es urgente.
- **Sin hard-delete de usuarios en KV**: `revoke` deja la fila en estado `revoked` (denegada en ambos planos, auditable); el KV no borra filas, igual que el store SQLite. Los dos `gapcheck_user*` de prueba quedan revocados (inertes; sus claves privadas eran aleatorias y descartadas). No se inventó un delete a medias (regla del prompt).
- **Sin borrado de la room de prueba (GAP B)**: el control plane no expone borrado de rooms (no hay endpoint ni `DeleteRoom` en el store). Las rooms `test.gapcheck.<rand>` creadas (efímeras, E2E, solo el operator como miembro, subject aleatorio) quedan como filas en `KV_UNIBUS_rooms`/members (inocuas, sin tráfico). Documentado; no se inventó un delete.
- **kill-2/3 (pérdida de quórum)**: fuera de scope (esperado fail-closed); el runbook lo deja como paso manual.
- **Push a Gitea del 0011**: sigue pendiente del operador (gap heredado del 0011, ajeno a este trabajo). Esta rama `quick/0011-deploy-gaps` se pushea a `origin` para revisión; NO se mergea a master (lo revisa el operador).
## Commits (rama `quick/0011-deploy-gaps`)
```
e1a7402 chore: bump unibus to 0.9.0 (live user-add + clientcheck)
ce72131 docs(cluster): correct runbook + wire --internal-id-file into deploy
3aa5a2c feat(clientcheck): end-to-end client verification (E2E room + failover)
02c2004 feat(membershipd): user add/list/revoke --store kv against a live cluster
```