Files
takana/docs/29-mudanza.md
T
SergioandClaude Opus 5 68fdd9eb72 mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano
`declarar.py --perfil-out` emite un `[perfil.<label>]` listo para `targets.toml`, generado desde el
censo: cada raíz está ahí porque un servicio que CORRÍA la necesita y takana tiene receta.

Por qué un perfil y no una lista de `install`: mudar servicio por servicio a una caja viva va contra
el diseño —el software de una máquina se declara y viene en la imagen, reproducible y firmado— y un
servidor armado a fuerza de instalaciones sueltas no se puede volver a construir. Que es justo el
problema que esta mudanza existe para no repetir: el `caddy` de gioser no tiene dueño ni receta y se
pierde con la máquina.

Y hay un impedimento medido, no estético: `takana install` REPRODUCE desde fuente y eso exige el lab
entero en el cliente (SDD 28 §5.4), que una caja de destino no tiene. El perfil lo resuelve por el
lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.

Del censo de gioser salen `caddy`, `gitea`, `python3` (agrupado: lo piden python3 y uvicorn).
Verificado que el instrumental lo acepta: `targets.py gioser-mudado` expande a 33 raíces.

⚠ Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre `suelto`,
`paquete-ajeno` y `desconocido`. Un perfil que se calla lo que le falta sale N/N describiendo un
servidor incompleto — la lección de `foot` en escritorio-sway.

Con esto la mudanza produce TRES documentos declarativos que reconstruyen el servidor desde cero:
el perfil (qué software), la semilla (qué corre y cómo) y el plan (qué datos, en qué orden). Ninguno
es un log de lo hecho: los tres son entradas que se vuelven a ejecutar.

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

22 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.

⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo

Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: pelean con el destino. planear.py --revisar muestra todo agrupado, con su recomendación y su razón:

familia motivo ejemplos en gioser
hardware local una caja remota no tiene ese dispositivo bluetoothd ModemManager upowerd adb
escritorio o pantalla un servidor no los tiene waypipe
red del destino pelearían: el destino la configura con su propio init NetworkManager dhcpcd
lo provee el init del destino no se muda udevd dbus-daemon elogind polkitd agetty

11 de 37 servicios de gioser caen ahí. Y el motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a ciegas o se ignora entera — las dos son malas.

La recomendación DERIVADA, que es la más fuerte

Si el binario de un servicio vive en un árbol que no se muda, el servicio no va a poder arrancar allá. Eso no hace falta saberlo: se calcula, cruzando el cmdline que el censo leyó de /proc contra las decisiones de datos. Probado en los dos sentidos:

/mnt/vvv → muere :  puerta-f6e393ff  ⇒  NO mudar — «su binario vive en /mnt/vvv, que NO se muda»
/mnt/vvv → muda  :  puerta-f6e393ff  ⇒  mudar    — «corre y sirve 34221, y NADIE lo declara»

Por eso los DATOS se deciden primero: de ellos se deriva la recomendación de los servicios, y al revés no se puede calcular. La revisión lo hace en ese orden y lo explica.

Y lo que NO se recomienda solo

Los datos salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina. terapeuta.ec eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso por él.

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 — declarar y aplicar (declarar.py implementado)

4.0 declarar.py — de «corre y nadie sabe cómo» a una Semilla que arranca

Es el paso que cierra la mudanza: copiar un binario no lo levanta al arrancar. Toma la invocación que el censo leyó de /proc y emite seed.card.json.

Por qué no alcanza con arje-absorb: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y está bien hecho, pero lee la declaración del init ajeno — justo la que miente. En gioser rc-status daba stopped para cinco servicios vivos, y los no-declarado no aparecen en ninguna declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan: absorb para lo declarado, esto para lo que corre.

Medido sobre gioser: 37 de 37 servicios vivos declarados, cero fallos. La tarjeta de caddy sale con su invocación real (/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile), que es exactamente lo que faltaba para que no muriera en el próximo reinicio.

Tres reglas:

  1. Un servicio sin cmdline legible NO se emite, y se dice por su nombre. Una tarjeta que no puede arrancar es peor que una ausente: la ausente falla ruidosamente al primer arranque, la rota arranca el sistema y deja el servicio caído sin que nada avise.
  2. El cwd se preserva envolviendo. El payload Native de arje acepta exec/argv/envp y no cwd (comprobado sobre la semilla real del producto). 9 de los 37 servicios de gioser dependen de su directorio; sin envolver arrancarían en / y fallarían de un modo difícil de atribuir. Se emite sh -c 'cd … && exec …', el idioma que la propia tarjeta de sshd ya usa.
  3. IDs deterministas derivados del nombre ⇒ el mismo censo produce la misma semilla byte a byte (verificado con sha256), así que dos corridas se comparan con diff. Un fichero generado que cambia en cada corrida no se puede revisar.

envp va vacío a propósito: el censo no lee /proc/<pid>/environ porque trae tokens. Un servicio que dependa de variables necesita que alguien las escriba, y se avisa.

Un bug que costó el 70 % del censo y vale como lección: cmdline y cwd se leían en un solo comando, y como readlink /proc/<pid>/cwd exige permiso de ptrace, en cualquier proceso de root el comando entero salía ≠0 y se descartaba también el cmdline, que sí se podía leer. 26 de 37 servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se pudo». Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.

4.0 bis · De dónde sale cada binario — «instalá el paquete» no es una instrucción

El paso de servicio decía «instalá el binario/paquete en el destino», que es lo que uno ya sabía. La respuesta útil sale de cruzar dos hechos: quién posee el fichero en el ORIGEN (se lo pregunta el censo al gestor de paquetes de esa máquina, en un solo lote) y si el corpus de takana tiene una receta con ese nombre. Tres casos, tres trabajos distintos:

clase qué significa qué hay que hacer
receta-takana takana ya sabe construirlo instalarlo del repo firmado — no copiar el binario
paquete-ajeno lo trae el gestor del origen escribir una receta, o qorpa (ADR 0015)
suelto nadie lo provee: puesto a mano llevarlo con su entorno, o escribirle receta

Medido sobre gioser (37 servicios vivos): 4 receta-takana · 4 paquete-ajeno · 11 suelto · 7 sin binario legible. Los sueltos viven en /usr/local/bin o en un home — qdrant, matilda, pacha-secretos, act_runner, shuma-gateway— y son los que se pierden al apagar el origen.

Y corrigió un error mío. En el SDD 28 escribí que gitea no tenía receta y que «es la que más peso tiene». Las dos cosas eran falsas: recipes/gitea.toml existe y está sellada. Lo afirmé de memoria; el programa fue a mirar. Es exactamente para esto que existe.

Y un fallo del propio clasificador, que vale como regla: calculaba la raíz del repo con un dirname de menos, así que no encontraba ninguna receta y contestaba suelto a todo — incluido caddy. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta.

4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto

Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché. planear.py agrupa por familia funcional, muestra qué equivalentes tiene takana, y recomienda — pero no «el mejor» en abstracto: un gusto disfrazado de dato es peor que no recomendar.

Los criterios son hechos comprobables, en este orden:

  1. El que YA CORRE, si takana lo construye. Y la razón es el costo real: cambiar de servidor web no es cambiar un binario — es reescribir la configuración entera. Eso casi siempre pesa más que cualquier ventaja teórica del otro.
  2. Si el que corre no está en el catálogo pero un equivalente sí, se recomienda ése — porque es el que takana puede construir, firmar y reproducir — diciendo lo que cuesta.
  3. Si no hay ninguno, se dice eso. No se sugiere «usá otro» cuando ese otro tampoco está.

Medido contra el catálogo real:

caddy  → alternativas: **caddy**✓  nginx✗  apache✗  lighttpd✗  traefik✓  haproxy✗
         recomendado: caddy — es el que YA corre y takana lo construye (sellado).
                      Cambiar obliga a REESCRIBIR la configuración entera.

nginx  → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia
                      sí están sellados: caddy, traefik. Cambiar implica reescribir la
                      configuración: es una decisión, no un trámite.

redis  → alternativas: redis✗  valkey✗  memcached✗
         recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el
                      catálogo. Hay que escribir la receta del que se elija.

El dato que la recomendación destapa: de servidores web el corpus sólo tiene caddy y traefik. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la recomendación misma.

Si se elige un equivalente, queda en el censo (alternativa = "…") y el plan lo dice en su paso, con la advertencia arriba de todo: la configuración del origen no sirve tal cual.

4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala

declarar.py --perfil-out emite además un [perfil.<label>] listo para docs/state/targets.toml.

Por qué un perfil y no una lista de install. Mudar servicio por servicio a una caja viva es lo que uno haría en cualquier distro, y en takana va contra el diseño: el software de una máquina se declara y viene en la imagen, reproducible y firmado. Un servidor armado a fuerza de instalaciones sueltas no se puede volver a construir — que es exactamente el problema que esta mudanza existe para no repetir: el caddy de gioser no tiene dueño ni receta, y se pierde con la máquina.

Y hay un impedimento medido, no estético: takana install reproduce desde fuente, y eso exige el LAB entero en el cliente (SDD 28 §5.4). Una caja de destino no lo tiene. El perfil esquiva el problema por el lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.

Generado desde el censo de gioser:

[perfil.gioser-mudado]
descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
hereda = ["base"]
paquetes = [
  "caddy",
  "gitea",
  "python3",  # python3, uvicorn
]

Verificado que el instrumental lo acepta: scripts/targets.py gioser-mudado expande a 33 raíces.

Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre suelto, paquete-ajeno y desconocido. Un perfil que se calla lo que le falta sale N/N describiendo un servidor incompleto — la lección de foot en escritorio-sway, que llegó a 121/121 sellado y sin emulador de terminal.

4.1.bis Los tres artefactos declarativos

Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir tres documentos que reconstruyen el servidor desde cero:

artefacto qué declara lo consume
[perfil.<x>]targets.toml qué software lleva servidor-image.sh al armar la imagen
seed.card.json qué corre y cómo arje-zero al arrancar
plan.toml qué datos y en qué orden aplicar.py, o un humano línea por línea

Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar. Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.

4.1 El aplicador (implementado: scripts/mudanza/aplicar.py)

aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]

La regla que lo define: un paso que no se ejecutó NO se marca como hecho. Un plan tiene pasos de dos naturalezas y confundirlas es la forma más fácil de dar una mudanza por buena sin estarlo:

  • ejecutable — tiene comandos de verdad (copiar, comprobar espacio);
  • manual — su cmd son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada que correr, y un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok». Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.

Acá un paso manual queda pendiente-humano, la corrida no se considera completa (sale con ≠0), y se confirma con --hecho N — que además se niega si el paso sí tenía comandos: «corrélo, no lo marques».

Se le cree a la verificación, no al exit code. El rsync que llenó el disco devolvió 0 y dejó 1367 artefactos vacíos. Un paso pasa a ok sólo si su verificación pasa; si el comando salió bien y la verificación falló, queda sospechoso, que se informa como peor que un fallo.

Y las verificaciones están TIPADAS (cmd / humano): «la columna Available debe ser > X» no es un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.

Reanudable: el estado vive en <plan>.estado.json, escrito con temporal + fsync + rename — la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.

⚠ Y una verificación que era decorativa

La primera versión del paso de datos verificaba así:

ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'

Imprimía el número de vacíos y devolvía 0 igual. En la primera corrida real dio «✓ verificado: 2» — donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.

Ahora compara: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a propósito y con el control que tiene que pasar:

copia NO hecha  → ficheros: origen=5 destino=0  ⇒ FALLA
copia hecha     → ficheros: origen=5 destino=5  ⇒ PASA

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: RESUELTO (2026-09-11). La imagen ahora arranca por BIOS y por UEFI, y es la misma imagen. Layout: p1 BIOS-boot · p2 ESP FAT32 · p3 / · p4 estado · p5 store.

    Un solo grub.cfg: el core.img de i386-pc y el BOOTX64.EFI de x86_64-efi apuntan los dos a (hd0,gpt3)/boot/grub, así que hay una sola línea de comando y un solo sitio donde editarla. Dos imágenes hermanas obligarían a elegir por proveedor y a que divergieran — y ya divergían: la hermana EFI documentaba etiquetas takana-* que su propio código no escribe (corregido).

    No se usa EFI-stub directo acá (sí lo hace install-image-efi.sh, ADR 0010): el stub arrancado por la ruta fallback recibe LoadOptions VACÍO, así que necesita la cmdline HORNEADA en el kernel, y la de linux-generic sólo trae la consola — sin root=. Hornearla ataría la línea de comando al ArtifactHash del kernel. Con GRUB EFI la cmdline vive en el grub.cfg, que es donde se edita.

    Verificado con la MISMA imagen en los dos firmwares, hasta SSH:

    UEFI (OVMF) BIOS (SeaBIOS)
    /sys/firmware/efi presente ausente
    PID 1 arje-zero arje-zero
    raíz · store sda3 · sda5 sda3 · sda5
  • 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.