Files
takana/docs/29-mudanza.md
T
SergioandClaude Opus 5 6f12c231fa mudanza etapa 2: el PLAN — exportable, con el comando literal de cada paso
`planear.py` convierte un censo decidido en un plan ejecutable. Cada paso lleva su COMANDO LITERAL y
su verificación, así que el fichero se ejecuta a mano, línea por línea, sin la herramienta y sin este
repo. Eso es lo que el usuario pidió como «pasos exportables».

Las tres reglas, cada una pagada en el SDD 28:

1. **Nada sin decidir se ejecuta**: aborta con código 2 listando qué falta. El silencio no es
   consentimiento — sin decisión, ni mudar ni matar es correcto. Probado en los dos sentidos.
2. **Lo que muere se dice por su nombre y con su tamaño ANTES de borrar**, y el paso no borra nada:
   es una lista para leer antes de apagar el origen.
3. **Cada copia se verifica EN DESTINO**: el `rsync` que llenó el disco devolvió 0 y dejó 1367
   artefactos vacíos; sólo se vio contando del otro lado.

Y el preflight dimensiona con el tamaño de COPIA (hardlinks expandidos, 60 G → 85 G medidos), no con
`du`; si un tamaño resulta ilegible lo dice en vez de contarlo como 0.

**El añadido que cambia el valor: la invocación real.** Decir «este servicio no está declarado»
nombra el problema; lo que hace falta para resolverlo es cómo corre AHORA. El censo lo lee de
`/proc` (`cmdline` + `cwd`, nunca `environ`: el entorno trae tokens) y el plan lo emite:

    #   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
    #   cwd    : /mnt/vvv/tawasuyu/shared/tejido
    #   puertos: 34221, 44961

Ese servicio de gioser es un binario de `target/debug/deps/` corriendo en producción, con dos
puertos, que ninguna declaración conoce. Apagada la máquina vieja, eso no se reconstruye de memoria.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:03:33 +00:00

8.4 KiB

SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta

Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28:

«quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay, qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo corriendo. Todo automático, sin IA.»

0. La corrección que ordena todo

El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso es configurar un servidor, no construir un producto. caddy no es un hueco de la imagen: es una instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.

La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios sirven, qué datos pesan— se censa en la máquina vieja, se confirma con el usuario, y se ejecuta.

Y hay una consecuencia de método que conviene decir fuerte: todo lo que hice a mano en el SDD 28 es la especificación de este programa. Censar, cruzar declaración contra realidad, separar vivo de fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo «¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. No hace falta IA: hace falta que esté escrito.

1. Las tres etapas

   CENSO                  PLAN                      APLICACIÓN
   censar.py    →    <censo>.toml (decisiones)  →   ejecutar, idempotente
   sólo lee          revisable · exportable         deja el server corriendo

El fichero del medio es el producto real: es «los pasos exportables». Se revisa, se versiona, se lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que se hizo.

2. Etapa 1 — el censo (implementado: scripts/mudanza/censar.py)

La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO, y reportar tres clases:

clase qué es por qué importa
vivo declarado y corriendo candidato a mudarse
declarado-muerto declarado, sin proceso candidato a morir
no-declarado corriendo, sin declaración el trabajo real: nadie sabe cómo levantarlo

No es teoría — cada regla salió de un error medido:

  • rc-status decía stopped de caddy, gitea, sshd, cronie y act-runner, y los cinco estaban vivos. Un censo que lea el init y no /proc produce un plan que no arranca.
  • De 29 dominios del Caddyfile, sólo 10 están vivos: 13 no tienen DNS, 2 dan 502, y 4 ya resuelven a otra máquina — su bloque de config es fósil aunque el sitio funcione. Por eso el censo compara la IP del DNS contra las IPs de la máquina: sin eso, ya-mudado se lee como vivo.
  • El caddy de la caja nueva murió en un reinicio y nadie lo notó, porque estaba arrancado a mano y no declarado. Ésa es la clase no-declarado, y es la que duele.
  • du -sh no dice cuánto ocupa COPIAR un árbol con hardlinks (medido: 60 G vs 85 G). El censo reporta los dos números.
  • Los nombres no coinciden entre init y proceso: act-runner/act_runner, crond/cronie, dbus-daemon/dbus, fail2ban-server/fail2ban. Sin normalizar, el mismo servicio sale a la vez como no-declarado y declarado-muerto: las dos clases que más importan, las dos mal.
  • Lo descartado se cuenta, no se esconde. Un proceso con ppid==1 que no es un servicio suele ser un huérfano, y eso es un hallazgo (los firefox fugados que dejaron la granja sin compilar hora y media fueron exactamente eso).

Y el criterio que decide si el censo sirve: un guardián con hallazgos falsos se ignora entero. La primera versión daba 28 no-declarado con ruido; tras normalizar nombres da 18, y son reales (matilda, pacha, puerta-…, adb, ModemManager). Afinarlo no es cosmética: es la diferencia entre que alguien lo lea o no.

3. Etapa 2 — el plan (implementado: scripts/mudanza/planear.py)

El censo emite cada entrada con decision = "". El plan es ese fichero con las decisiones puestas (muda / muere), más el orden y las dependencias. Requisitos:

  1. Nada sin decidir se ejecuta. Una entrada vacía aborta: el silencio no es consentimiento.
  2. Lo que muere se dice por su nombre, con su tamaño, antes de borrar nada.
  3. El plan es exportable y re-ejecutable en otro proveedor. Es el artefacto que el usuario pidió.
  4. Un TUI para llenarlo, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.

Lo implementado

planear.py --censo <f> --target <host> [--decide] --out plan.toml

  • Aborta con código 2 si queda algo sin decidir, listando qué. Probado.
  • --decide recorre lo pendiente y propone (vivo→muda, ya-mudado/backend-caido→muere, no-declarado→muda). La sugerencia se imprime, nunca se aplica sola.
  • Cada paso lleva su comando literal y su verificación, así que el plan se ejecuta a mano, línea por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
  • El preflight dimensiona con el tamaño de copia (hardlinks expandidos), no con du; y si algún tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo cuando hace falta.
  • El paso «muere» no borra nada: lista con nombre y tamaño lo que se pierde al apagar el origen.

⚠ El dato que vuelve accionable a un no-declarado: su invocación

Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es cómo se está ejecutando ahora, y eso se lee de /proc. El censo captura cmdline y cwd de cada servicio vivo, y el plan los emite en el paso:

#   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
#   cwd    : /mnt/vvv/tawasuyu/shared/tejido
#   puertos: 34221, 44961

Ese servicio de gioser es un binario de target/debug/deps/ corriendo en producción, con dos puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de memoria. Ahora está escrito.

⚠ Se lee cmdline y cwd, nunca environ: el entorno suele traer tokens y contraseñas, y un censo que se guarda en un fichero y se comparte no puede llevarlos.

4. Etapa 3 — la aplicación (pendiente)

Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:

  1. Idempotente y reanudable. Cada copia grande se cortó al menos una vez.
  2. Verifica en DESTINO, no en origen. «Lo copié» no prueba nada: el rsync que llenó el disco dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
  3. Los servicios no-declarado se declaran, no se replican a mano. Es la salida natural hacia arje-absorb, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero leyendo la declaración, que es justo la que miente: hay que alimentarlo con el censo.
  4. Deja el servidor corriendo, y lo prueba desde afuera: cada dominio vivo del plan tiene que contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.

5. La imagen, independiente del proveedor

Lo que hoy la ata a Hetzner es poco y está medido:

  • Arranque: la imagen es GPT+BIOS. Hetzner Cloud arranca BIOS; otros proveedores piden UEFI. Los dos caminos existen (install-image.sh / install-image-efi.sh); falta una imagen que haga las dos (ESP + BIOS boot en el mismo disco) para no elegir por proveedor.
  • Red: netup hace DHCPv4 y ya cubre el caso difícil (IP /32 con la puerta fuera del prefijo, que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores.
  • Instalación: rescue → dd → reboot no es de Hetzner: sirve en cualquier entorno de rescate con SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor.
  • Disco: ya se resuelve solo — el store es la última partición y crece al disco entero en el primer arranque.

la imagen está más cerca de ser portable que la herramienta de mudarse. Por eso el orden es censo → plan → aplicación, y la portabilidad de la imagen va en paralelo.