# 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 → .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 --target [--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//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//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.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)* `aplicar.py --plan plan.toml [--dry-run] [--only ] [--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 `.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

; find

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