# 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. ### 3 bis · El tercer camino: **mudar · respaldar · abandonar** *(pedido del usuario, 2026-09-16)* > «la herramienta debería mostrarme una lista de cada cosa con una corta descripción y la opción de > yo elegir mudar, mover a un directorio para respaldar ese directorio, o abandonarlo» Las tres partes de esa frase eran tres huecos distintos, y los tres se cerraron. #### 1. «cada cosa» — el censo medía RAÍCES, no cosas `/home` era **una** entrada de 25 G y **una** decisión. Adentro hay cosas de dueños distintos: repos con remoto, SDKs que se re-bajan, 4,3 G de caché, y trabajo que no está en ningún otro lado. Con una sola decisión sólo se puede acertar a lo bruto — mudar 25 G para salvar 200 M, o abandonar 25 G y perder ese 200 M. **La granularidad de la lista es la granularidad de lo que se puede decidir.** `censar.py` ahora emite un renglón por cosa (169 en gioser contra 5), un nivel hacia adentro de cada raíz y **dos** en `/home`, porque su primer nivel son usuarios y la decisión no es por usuario. Queda `--solo-totales` para la vista vieja, que es la que dimensiona la mudanza y la que mira el preflight: las dos vistas viajan en el censo, `datos` y `datos_raiz`. ⚠ Y una lección repetida: el primer intento mandaba las ~110 sondas en **una** llamada, se pasaba del timeout, y como el parser no encontraba ninguna línea devolvía **lista vacía** — «no hay datos» en vez de «no pude medirlos». Va en tandas de 12 y **una tanda que falla se nombra**. #### 2. «una corta descripción» — hechos, nunca un juicio De cada cosa se reporta lo que se puede comprobar, lo más decisivo primero: ``` /home/sergio/gioser-web 428M LO SIRVE gioser.net, www.gioser.net · repo git → gitea@git.gioser.net:… /home/sergio/android-sdk 1019M lo usa adb · lo más nuevo: 2026-06-12 /home/sergio/accordion 37M repo git → github.com/GotJimmy/accordion · CON CAMBIOS SIN COMMITEAR /home/sergio/.cache 4.3G lo más nuevo: 2026-09-16 /home/sergio/humanoid 200M sin señales: ni git, ni servido, ni usado por nada vivo ``` Nada de «parece un proyecto viejo». Es repo o no lo es; lo sirve la config del servidor web o no; lo usa un proceso vivo o no; adentro hay `node_modules`/`target`/`venv` o no. **«sin señales» también es un hecho** — y es el que dice dónde hay que mirar a mano. #### 3. «yo elegir» entre tres — y qué recomienda la máquina `DECISIONES` pasa de `("muda","muere")` a `("muda","respalda","muere")`. El camino que faltaba es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero perder*. Con dos opciones, todo lo dudoso se marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. La regla de fondo **no cambió**: qué datos VALEN no se deduce de la máquina, y la mayoría sale sin recomendación a propósito. Pero hay cuatro cosas que la máquina sí sabe, y callarlas obliga a mirar 169 entradas a ojo: | hecho | recomienda | por qué | |---|---|---| | lo sirve el servidor web · lo usa un proceso vivo | `muda` | si no viaja, ese sitio o ese servicio deja de existir | | es caché o estado transitorio (`.cache`, `.gradle`, `node_modules`…) | `muere` | el destino lo vuelve a crear solo | | repo git **limpio** con remoto | `muere` | se vuelve a clonar | | repo **sin** remoto, o **con cambios sin commitear** | `respalda` | esa parte existe únicamente en este disco | ⚠ **Y el cruce que evita el peor error**: un remoto que apunta a ESTA MISMA máquina no es un respaldo, es el mismo disco. Sin eso, «tiene remoto ⇒ se vuelve a clonar» mandaría a abandonar un repo cuyo único ejemplar se borra con el servidor. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es `ssh://git@127.0.0.1:2345/…`, el gitea de la propia caja. #### 4. Y las decisiones ahora se GUARDAN `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal y las 169 elecciones se perdían. El censo **es** la interfaz (§3), así que ahora se vuelve a escribir con las decisiones puestas —temporal + `fsync` + `rename`, copia `.bak`, y **releído antes de pisar nada**— y se aborta si el fichero regenerado perdió alguna. Decidir 169 cosas siempre se hace a medias; que sobreviva a medias es parte del diseño, no una comodidad. Para que eso fuera posible hubo que hacer el emisor del censo **idempotente**: escribía `decision = ""` fijo al final de cada entrada, así que re-emitir un censo decidido producía la clave dos veces y el TOML dejaba de parsear. O sea que la herramienta no podía volver a escribir su propio fichero. #### 5. El paso `respaldo`, y los dos bugs que encontró el guardián de dos sentidos Lo marcado `respalda` va a un **corral** —por defecto el Storage Box, leído de `respaldo-storagebox.sh` para que no haya una segunda dirección escrita a mano— con el nombre aplanado (`/home/sergio/humanoid` → `home-sergio-humanoid`), y **no se instala en el destino**: la caja nueva no se entera de que existe. Sin corral configurado el plan **aborta**: un `rsync` a ninguna parte devuelve 0 y se lee igual que un respaldo. Probar el paso emitido con rotura a propósito **y** con el control que tiene que pasar destapó dos cosas que la lectura no veía: 1. **`--mkpath` no es opcional**: sin él rsync muere con `mkdir … failed` en cuanto el corral tiene un nivel que no existe — y el primer respaldo siempre lo tiene. 2. **Un FICHERO no lleva barra final.** Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál es cuál; el paso de `datos` tenía el mismo defecto latente. La verificación no usa `du` ni `find`, sino **rsync en seco contra el destino real**: el corral puede ser un Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías, y cuyo `du` además **comprime** (1,3 G subidos se ven como 576 M — un número que parece copia truncada y no lo es). Lo que se cuenta son los ficheros que a rsync todavía le faltarían. #### 6. De paso: el aplicador en seco decía «✅ todos los pasos ejecutados y verificados» Con `--dry-run` no se ejecuta nada, y el resumen de siempre contaba cero ejecutados igual que cero fallidos. Ahora dice **«EN SECO: N pasos mostrados, NINGUNO ejecutado y NINGUNO verificado»**. Es la misma regla que el resto del aplicador: *un paso que no se ejecutó no se marca como hecho*. ### ⚠ 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.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala `declarar.py --perfil-out` emite además un `[perfil.