tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ 2ecce583e59c6996355b7e96aa55c12d70496af1 docs/29-mudanza.md Markdown · 636 líneas · 35.7 KB Historia crudo

censar: sonda DNS-only (`--probe-dns`) — y los dominios fósiles vuelven a la lista de decisiones 73f75d45e · Sergio ·

1# SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta
2
3Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28:
4
5> «quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación
6> mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro
7> servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para
8> reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay,
9> qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo
10> corriendo. Todo automático, sin IA.»
11
12## 0. La corrección que ordena todo
13
14El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso
15es configurar un servidor, no construir un producto. **caddy no es un hueco de la imagen: es una
16instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.**
17
18La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios
19sirven, qué datos pesan— **se censa en la máquina vieja**, se confirma con el usuario, y se ejecuta.
20
21Y hay una consecuencia de método que conviene decir fuerte: **todo lo que hice a mano en el SDD 28 es
22la especificación de este programa.** Censar, cruzar declaración contra realidad, separar vivo de
23fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo
24«¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. **No hace falta IA: hace
25falta que esté escrito.**
26
27## 1. Las tres etapas
28
29```
30   CENSO                  PLAN                      APLICACIÓN
31   censar.py    →    <censo>.toml (decisiones)  →   ejecutar, idempotente
32   sólo lee          revisable · exportable         deja el server corriendo
33```
34
35El fichero del medio es el producto real: **es «los pasos exportables»**. Se revisa, se versiona, se
36lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que
37se hizo.
38
39## 2. Etapa 1 — el censo *(implementado: `scripts/mudanza/censar.py`)*
40
41**La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO**, y reportar tres clases:
42
43| clase | qué es | por qué importa |
44|---|---|---|
45| `vivo` | declarado **y** corriendo | candidato a mudarse |
46| `declarado-muerto` | declarado, sin proceso | candidato a morir |
47| **`no-declarado`** | **corriendo, sin declaración** | **el trabajo real: nadie sabe cómo levantarlo** |
48
49No es teoría — cada regla salió de un error medido:
50
51- **`rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los cinco estaban
52  vivos.** Un censo que lea el init y no `/proc` produce un plan que no arranca.
53- **De 29 dominios del Caddyfile, sólo 10 están vivos**: 13 no tienen DNS, 2 dan 502, y **4 ya
54  resuelven a otra máquina** — su bloque de config es fósil aunque el sitio funcione. Por eso el
55  censo compara la IP del DNS contra las IPs de la máquina: sin eso, `ya-mudado` se lee como `vivo`.
56- **El caddy de la caja nueva murió en un reinicio y nadie lo notó**, porque estaba arrancado a mano
57  y no declarado. Ésa es la clase `no-declarado`, y es la que duele.
58- **`du -sh` no dice cuánto ocupa COPIAR** un árbol con hardlinks (medido: 60 G vs 85 G). El censo
59  reporta los dos números.
60- **Los nombres no coinciden entre init y proceso**: `act-runner`/`act_runner`, `crond`/`cronie`,
61  `dbus-daemon`/`dbus`, `fail2ban-server`/`fail2ban`. Sin normalizar, el mismo servicio sale a la vez
62  como `no-declarado` y `declarado-muerto`: las dos clases que más importan, las dos mal.
63- **Lo descartado se cuenta, no se esconde.** Un proceso con `ppid==1` que no es un servicio suele
64  ser un huérfano, y eso es un hallazgo (los `firefox` fugados que dejaron la granja sin compilar
65  hora y media fueron exactamente eso).
66
67⚠ **Y el criterio que decide si el censo sirve**: un guardián con hallazgos falsos se ignora entero.
68La primera versión daba 28 `no-declarado` con ruido; tras normalizar nombres da 18, y son reales
69(`matilda`, `pacha`, `puerta-…`, `adb`, `ModemManager`). Afinarlo no es cosmética: es la diferencia
70entre que alguien lo lea o no.
71
72## 3. Etapa 2 — el plan *(implementado: `scripts/mudanza/planear.py`)*
73
74El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las decisiones puestas
75(`muda` / `muere`), más el orden y las dependencias. Requisitos:
76
771. **Nada sin decidir se ejecuta.** Una entrada vacía aborta: el silencio no es consentimiento.
782. **Lo que muere se dice por su nombre**, con su tamaño, antes de borrar nada.
793. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió.
804. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
81
82### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
83
84Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
85bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: **pelean** con el
86destino. `planear.py --revisar` muestra todo agrupado, con su recomendación **y su razón**:
87
88| familia | motivo | ejemplos en gioser |
89|---|---|---|
90| hardware local | una caja remota no tiene ese dispositivo | `bluetoothd` `ModemManager` `upowerd` `adb` |
91| escritorio o pantalla | un servidor no los tiene | `waypipe` |
92| red del destino | **pelearían**: el destino la configura con su propio init | `NetworkManager` `dhcpcd` |
93| lo provee el init del destino | no se muda | `udevd` `dbus-daemon` `elogind` `polkitd` `agetty` |
94
95**11 de 37 servicios de gioser caen ahí.** Y el motivo no es cortesía: *una recomendación sin razón
96no se puede discutir, así que o se acepta a ciegas o se ignora entera* — las dos son malas.
97
98#### La recomendación DERIVADA, que es la más fuerte
99
100Si el binario de un servicio vive en un árbol que **no se muda**, el servicio no va a poder arrancar
101allá. Eso no hace falta saberlo: se calcula, cruzando el `cmdline` que el censo leyó de `/proc`
102contra las decisiones de datos. Probado en los dos sentidos:
103
104```
105/mnt/vvv → muere :  puerta-f6e393ff  ⇒  NO mudar — «su binario vive en /mnt/vvv, que NO se muda»
106/mnt/vvv → muda  :  puerta-f6e393ff  ⇒  mudar    — «corre y sirve 34221, y NADIE lo declara»
107```
108
109**Por eso los DATOS se deciden primero**: de ellos se deriva la recomendación de los servicios, y al
110revés no se puede calcular. La revisión lo hace en ese orden y lo explica.
111
112#### Y lo que NO se recomienda solo
113
114Los **datos** salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina.
115`terapeuta.ec` eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía
116decidir eso por él.
117
118### Lo implementado
119
120`planear.py --censo <f> --target <host> [--decide] --out plan.toml`
121
122- **Aborta con código 2** si queda algo sin decidir, listando qué. Probado.
123- `--decide` recorre lo pendiente y propone (`vivo`→muda, `ya-mudado`/`backend-caido`→muere,
124  `no-declarado`→muda). **La sugerencia se imprime, nunca se aplica sola.**
125- **Cada paso lleva su comando literal y su verificación**, así que el plan se ejecuta a mano, línea
126  por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
127- El preflight dimensiona con el **tamaño de copia** (hardlinks expandidos), no con `du`; y si algún
128  tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo
129  cuando hace falta.
130- El paso «muere» **no borra nada**: lista con nombre y tamaño lo que se pierde al apagar el origen.
131
132### ⚠ El dato que vuelve accionable a un `no-declarado`: su invocación
133
134Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es
135**cómo se está ejecutando ahora**, y eso se lee de `/proc`. El censo captura `cmdline` y `cwd` de
136cada servicio vivo, y el plan los emite en el paso:
137
138```
139#   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
140#   cwd    : /mnt/vvv/tawasuyu/shared/tejido
141#   puertos: 34221, 44961
142```
143
144Ese servicio de gioser es **un binario de `target/debug/deps/`** corriendo en producción, con dos
145puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de
146memoria. Ahora está escrito.
147
148⚠ Se lee `cmdline` y `cwd`, **nunca `environ`**: el entorno suele traer tokens y contraseñas, y un
149censo que se guarda en un fichero y se comparte no puede llevarlos.
150
151## 4. Etapa 3 — declarar y aplicar *(`declarar.py` implementado)*
152
153### 4.0 `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca
154
155Es el paso que cierra la mudanza: **copiar un binario no lo levanta al arrancar**. Toma la
156invocación que el censo leyó de `/proc` y emite `seed.card.json`.
157
158**Por qué no alcanza con `arje-absorb`**: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y
159está bien hecho, pero lee **la declaración del init ajeno** — justo la que miente. En gioser
160`rc-status` daba `stopped` para cinco servicios vivos, y los `no-declarado` no aparecen en ninguna
161declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan:
162absorb para lo declarado, esto para lo que corre.
163
164Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos.** La tarjeta de caddy sale
165con su invocación real (`/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile`), que
166es exactamente lo que faltaba para que no muriera en el próximo reinicio.
167
168**Tres reglas:**
169
1701. **Un servicio sin `cmdline` legible NO se emite**, y se dice por su nombre. Una tarjeta que no
171   puede arrancar es peor que una ausente: la ausente falla ruidosamente al primer arranque, la rota
172   arranca el sistema y deja el servicio caído sin que nada avise.
1732. **El `cwd` se preserva envolviendo.** El payload `Native` de arje acepta `exec`/`argv`/`envp` y
174   **no `cwd`** (comprobado sobre la semilla real del producto). 9 de los 37 servicios de gioser
175   dependen de su directorio; sin envolver arrancarían en `/` y fallarían de un modo difícil de
176   atribuir. Se emite `sh -c 'cd … && exec …'`, el idioma que la propia tarjeta de `sshd` ya usa.
1773. **IDs deterministas** derivados del nombre ⇒ el mismo censo produce la misma semilla **byte a
178   byte** (verificado con `sha256`), así que dos corridas se comparan con `diff`. Un fichero
179   generado que cambia en cada corrida no se puede revisar.
180
181⚠ **`envp` va vacío a propósito**: el censo no lee `/proc/<pid>/environ` porque trae tokens. Un
182servicio que dependa de variables necesita que alguien las escriba, y se avisa.
183
184⚠ **Un bug que costó el 70 % del censo y vale como lección**: `cmdline` y `cwd` se leían en un solo
185comando, y como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el
186comando entero salía ≠0 y se descartaba **también el `cmdline`**, que sí se podía leer. 26 de 37
187servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se
188pudo». **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**
189
190### 4.0 bis · De dónde sale cada binario — «instalá el paquete» no es una instrucción
191
192El paso de servicio decía «instalá el binario/paquete en el destino», que es lo que uno ya sabía. La
193respuesta útil sale de cruzar **dos hechos**: quién posee el fichero en el ORIGEN (se lo pregunta el
194censo al gestor de paquetes de esa máquina, en un solo lote) y si el corpus de takana tiene una
195receta con ese nombre. Tres casos, tres trabajos distintos:
196
197| clase | qué significa | qué hay que hacer |
198|---|---|---|
199| `receta-takana` | takana ya sabe construirlo | instalarlo del repo firmado — **no** copiar el binario |
200| `paquete-ajeno` | lo trae el gestor del origen | escribir una receta, o `qorpa` (ADR 0015) |
201| `suelto` | **nadie lo provee**: puesto a mano | llevarlo con su entorno, o escribirle receta |
202
203**Medido sobre gioser (37 servicios vivos):** 4 `receta-takana` · 4 `paquete-ajeno` · **11 `suelto`**
204· 7 sin binario legible. Los sueltos viven en `/usr/local/bin` o en un home — `qdrant`, `matilda`,
205`pacha-secretos`, `act_runner`, `shuma-gateway`— y **son los que se pierden al apagar el origen**.
206
207⚠ **Y corrigió un error mío.** En el SDD 28 escribí que `gitea` no tenía receta y que «es la que más
208peso tiene». Las dos cosas eran falsas: `recipes/gitea.toml` existe y está sellada. Lo afirmé de
209memoria; el programa fue a mirar. Es exactamente para esto que existe.
210
211⚠ **Y un fallo del propio clasificador, que vale como regla**: calculaba la raíz del repo con un
212`dirname` de menos, así que no encontraba ninguna receta y **contestaba `suelto` a todo** — incluido
213`caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no
214encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta.
215
216### 4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto
217
218Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché.
219`planear.py` agrupa por **familia funcional**, muestra qué equivalentes tiene takana, y recomienda —
220pero **no «el mejor» en abstracto**: un gusto disfrazado de dato es peor que no recomendar.
221
222Los criterios son hechos comprobables, en este orden:
223
2241. **El que YA CORRE, si takana lo construye.** Y la razón es el costo real: *cambiar de servidor web
225   no es cambiar un binario — es reescribir la configuración entera*. Eso casi siempre pesa más que
226   cualquier ventaja teórica del otro.
2272. Si el que corre **no** está en el catálogo pero un equivalente sí, se recomienda ése — porque es
228   el que takana puede construir, firmar y reproducir — **diciendo lo que cuesta**.
2293. Si no hay ninguno, se dice eso. **No se sugiere «usá otro» cuando ese otro tampoco está.**
230
231Medido contra el catálogo real:
232
233```
234caddy  → alternativas: **caddy**✓  nginx✗  apache✗  lighttpd✗  traefik✓  haproxy✗
235         recomendado: caddy — es el que YA corre y takana lo construye (sellado).
236                      Cambiar obliga a REESCRIBIR la configuración entera.
237
238nginx  → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia
239                      sí están sellados: caddy, traefik. Cambiar implica reescribir la
240                      configuración: es una decisión, no un trámite.
241
242redis  → alternativas: redis✗  valkey✗  memcached✗
243         recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el
244                      catálogo. Hay que escribir la receta del que se elija.
245```
246
247⚠ **El dato que la recomendación destapa**: de servidores web el corpus sólo tiene **`caddy` y
248`traefik`**. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la
249recomendación misma.
250
251Si se elige un equivalente, queda en el censo (`alternativa = "…"`) y **el plan lo dice en su paso**,
252con la advertencia arriba de todo: la configuración del origen no sirve tal cual.
253
254### 4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala
255
256`declarar.py --perfil-out` emite además un `[perfil.<label>]` listo para `docs/state/targets.toml`.
257
258**Por qué un perfil y no una lista de `install`.** Mudar servicio por servicio a una caja viva es lo
259que uno haría en cualquier distro, y en takana va contra el diseño: el software de una máquina se
260**declara** y viene en la imagen, reproducible y firmado. Un servidor armado a fuerza de
261instalaciones sueltas no se puede volver a construir — que es exactamente el problema que esta
262mudanza existe para no repetir: **el `caddy` de gioser no tiene dueño ni receta, y se pierde con la
263máquina**.
264
265Y hay un impedimento medido, no estético: `takana install` **reproduce desde fuente**, y eso exige el
266LAB entero en el cliente ([SDD 28 §5.4](28-servidor-de-produccion.md)). Una caja de destino no lo
267tiene. El perfil esquiva el problema por el lado correcto: el software entra **al armar la imagen**,
268en el hub, que sí tiene lab.
269
270Generado desde el censo de gioser:
271
272```toml
273[perfil.gioser-mudado]
274descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
275hereda = ["base"]
276paquetes = [
277  "caddy",
278  "gitea",
279  "python3",  # python3, uvicorn
280]
281```
282
283Verificado que el instrumental lo acepta: `scripts/targets.py gioser-mudado` expande a **33 raíces**.
284
285⚠ **Y dice lo que NO puede cubrir**, uno por uno con su motivo: 34 servicios entre `suelto`,
286`paquete-ajeno` y `desconocido`. *Un perfil que se calla lo que le falta sale N/N describiendo un
287servidor incompleto* — la lección de `foot` en `escritorio-sway`, que llegó a 121/121 sellado y sin
288emulador de terminal.
289
290### 4.1.bis Los tres artefactos declarativos
291
292Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir **tres documentos que
293reconstruyen el servidor desde cero**:
294
295| artefacto | qué declara | lo consume |
296|---|---|---|
297| `[perfil.<x>]` → `targets.toml` | **qué software** lleva | `servidor-image.sh` al armar la imagen |
298| `seed.card.json` | **qué corre y cómo** | `arje-zero` al arrancar |
299| `plan.toml` | **qué datos y en qué orden** | `aplicar.py`, o un humano línea por línea |
300
301Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
302Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
303
304### 4.0 septies · El censo real de gioser, y dos huecos que destapó
305
306Corrido contra `204.168.193.248` — que es `gioser.net`, identificado **por IP y no por hostname**,
307porque `hostname` dice `momento`:
308
309```
310── servicios ──                       ── dominios (sonda DNS) ──
311  vivo               19                 apunta-aca      12
312⚠ no-declarado       18                 ya-mudado        4
313  declarado-muerto   85                 fosil-sin-dns   13
314```
315
316#### El HTTP toca al origen; el DNS no
317
318Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso
319el censo en local los salteaba, y quedaban 29 de 66 ítems **sin recomendación**. Pero lo que dispara
320la jaula es el HTTP: `getent` le pregunta al DNS, no al servidor. Con `--probe-dns` se sondea sólo el
321nombre, **sin una sola petición a gioser**, y eso ya contesta la pregunta que más pesa: cuáles siguen
322apuntando acá y cuáles son fósiles. 29 de 29 clasificados; sólo **12 hay que reapuntar**.
323
324Lo que el DNS solo no puede decir es si el backend contesta, así que esos quedan en `apunta-aca`
325— clase propia, **no `vivo`**. Decir «vivo» sin haber pedido una página sería la respuesta falsa con
326forma de respuesta que este censo existe para evitar.
327
328#### Los fósiles quedaban fuera de la lista, y por eso sus datos viajaban
329
330`entradas()` filtraba los `fosil-sin-dns` con el argumento de que un dominio sin DNS no necesita
331ningún paso. Es cierto para el DNS y **falso para lo que arrastra**: son 13 de 29, y `terapeuta.ec`
332tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran **invisibles**, así
333que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — exactamente el «mudar
334fósiles» que este plan existe para evitar, y contra la regla 2, que dice que lo que muere se nombra
335**antes** de borrar nada.
336
337### 4.0 sexies · El centro ENGANCHADO al plan
338
339El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Es cierto y
340es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora ese
341párrafo es **un paso con su comando literal**:
342
343```
344▸ traducir declaración de caddy (openrc → arje)        [verifica: humano]
345   scripts/mudanza/traducir.py --from openrc --to arje \
346         --in /etc/init.d/caddy --out caddy.card.json
347
348▸ traducir configuración de nginx → caddy              [verifica: humano]
349   scripts/mudanza/traducir.py --from nginx --to caddy \
350         --in /etc/nginx/nginx.conf --out Caddyfile
351```
352
353Dos traducciones distintas por servicio, y confundirlas es caro: la **declaración** (cómo se levanta)
354y la **configuración** (qué hace). Un servicio que se muda a sí mismo sólo necesita la primera —
355ofrecerle traducir su config sería inventarle trabajo.
356
357Los pares se le **preguntan al registro de plugins**, no se listan en `planear.py`: una lista propia
358se desincronizaría del centro y el plan ofrecería una traducción que no existe. Cuando no hay par, el
359paso lo dice y manda a `--list`.
360
361Y la verificación es **humana**, a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir,
362así que dar el paso por bueno por su código de salida sería exactamente al revés de lo que hay que
363mirar. Lo que este paso verifica es que alguien **leyó el acta**.
364
365#### ⚠ El plan que generábamos no era TOML válido
366
367Salió al meter el primer comando multilínea, pero estaba latente desde el principio. Dos modos de
368fallo, y el segundo es el peor:
369
370| | cadena básica `"""` | cadena literal `'''` |
371|---|---|---|
372| `awk "\$1==1"` (el `verifica` de todo servicio) | **ilegible**: `Unescaped '\'` | parsea idéntico |
373| `cmd --from x \` + salto | parsea… y **se come el salto y la sangría** | parsea idéntico |
374
375El primero rompe ruidosamente. El segundo **parsea y miente**: el plan se lee bien y el comando que
376sale no es el que se escribió. El plan es el producto —se revisa, se versiona, se lleva a otro
377proveedor y se vuelve a correr—, así que uno que no vuelve a parsear no sirve para ninguna de las dos
378cosas para las que existe.
379
380Arreglado usando la cadena literal, y con un guardián que **escribe y relee** antes de tocar el
381disco: si el TOML generado no parsea, no se escribe nada. Cuesta milisegundos y convierte un fallo
382diferido —que aparecía cuando alguien iba a *ejecutar* el plan— en uno inmediato.
383
384### 4.0 quinquies · El CENTRO de traducción — pivote y plugins
385
386Cambiar de nginx a caddy no es cambiar un binario: es **reescribir la configuración**. `traducir.py`
387hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
388
389#### ⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE
390
391Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son **20 traductores**,
392y cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un
393formato nuevo cuesta uno o dos plugins y **estrena todos los pares de golpe**.
394
395```
396  formatos/nginx.py   ─┐                      ┌─►  formatos/caddy.py
397  formatos/apache.py  ─┼─►  modelo pivote  ───┤
398  (el próximo)        ─┘    Config/Sitio/Ruta └─►  (el próximo)
399```
400
401**Probado, no argumentado**: el lector de apache se agregó **sin tocar el escritor de Caddy**, y con
402eso `apache → caddy` funciona sin que exista ningún traductor apache→caddy. Las dos salidas las
403valida el caddy del propio corpus:
404
405```
406apache → caddy :  Valid configuration
407nginx  → caddy :  Valid configuration
408```
409
410Un formato se agrega dejando un fichero en `formatos/` con `@lector("x")` y/o `@escritor("y")`. El
411centro los **descubre**: no hay una lista que editar, porque una lista mantenida a mano se
412desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
413habilita y cuántos plugins costaron.
414
415#### El pivote es uno POR FAMILIA
416
417Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el
418«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro
419**empareja sólo dentro de la familia**:
420
421| familia | modelo | lectores | escritores |
422|---|---|---|---|
423| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
424| `servicio` | `Servicio` / `Sistema` | systemd, openrc | arje |
425
426Un par cruzado se rechaza con un error, no con un intento:
427
428```
429$ traducir.py --from systemd --to caddy …
430✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
431  No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.
432```
433
434#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema
435
436El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
437real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio
438**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie
439vuelve a leer**. Sale como SIN-TRADUCIR, con su línea.
440
441Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce
442⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que
443lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del
444humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un
445servicio **menos confinado** que el original.
446
447Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
448semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
449tarjeta real de `sshd`**, más `_mudanza` de procedencia.
450
451#### `openrc → arje`: casi la mitad de los servicios NO son declaraciones
452
453Un unit de systemd se lee. Un servicio de OpenRC es un **script de shell** que puede hacer cualquier
454cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real de gioser —la máquina que esta
455mudanza borra— **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que
456valga: leer las variables y emitir una tarjeta produciría un servicio que arranca *otra cosa*.
457
458Por eso la regla va al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR y no se emite
459tarjeta**. Los números del corpus real cierran solos:
460
461```
462  ficheros en /etc/init.d      : 121
463  no son openrc-run            :   1
464  con start()/stop() propia    :  60   → no se emite tarjeta
465  declarativos sin command=    :   1   → no hay qué arrancar
466  ⇒ traducibles                :  59   ← y el lector emitió exactamente 59
467```
468
469Lo otro que OpenRC obliga a ir a buscar es **`/etc/conf.d/<servicio>`**: el servicio está partido en
470dos ficheros y **los argumentos de verdad viven en el segundo**. A diferencia del `EnvironmentFile=`
471de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático **no lo
472dice el script** sino el enlace en `/etc/runlevels/`.
473
474#### Tres defectos que sólo aparecieron corriendo contra las 121 de verdad
475
476- **`name=` no es un identificador, es un rótulo**: en gioser vale `Aura Backend`, con espacio — que
477  se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
478  además es lo que usan `rc-update` y `/etc/runlevels`.
479- **Ids repetidos**: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos, y son
480  scripts de OpenRC válidos. Dos servicios con el mismo nombre dan **el mismo id determinista** ⇒ una
481  semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
482- **Procedencia cableada**: la tarjeta decía `"desde": "systemd"` viniendo de OpenRC. El campo que
483  existe para saber de dónde salió algo era el que mentía.
484
485#### Lector y escritor no opinan del mismo campo
486
487`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía
488dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector
489registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de
490un `0` que obliga a buscar a mano lo que ya se sabía.
491
492#### El acta viaja DENTRO del modelo
493
494Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que
495un **lector** no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor
496la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.
497
498Y el escritor puede **agregarle**: Caddy no tiene equivalente directo de `location ~`, así que en vez
499de emitir un `handle` de prefijo —que *se parece* y no es lo mismo— lo manda al acta. **Parecerse es
500peor que faltar.**
501
502#### Doctrina heredada de `soltar`/`paskaq` (tawasuyu)
503
504`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, `.mdb`). El dominio es otro
505—datos, no configuración— pero sus principios son exactamente los que hacía falta, y se adoptan tal
506cual:
507
508- **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá cada directiva no
509  entendida sale con su línea y su texto. **Un traductor que descarta en silencio te deja un servidor
510  sin una redirección o sin una regla de auth, y el sitio parece funcionar.**
511- **No adivinar en silencio**: toda heurística queda como decisión explícita, no aplicada de tapadillo.
512- **Procedencia total**: cada bloque del resultado dice de qué línea del original salió.
513- **Determinista**: mismo fichero, misma salida.
514
515Y lo que NO es: un traductor completo de nginx. Traduce el núcleo común y **declara todo lo demás**.
516*Uno honesto que cubre el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el
517100 % es una trampa.*
518
519#### ⚠ Validar con el binario real encontró dos bugs, y uno cambiaba el sentido
520
521Leer la salida no alcanzaba. Pasarla por `caddy validate` —con el caddy del propio corpus— destapó:
522
5231. **`ambiguous site definition`**: en nginx dos `server` con el mismo `server_name` se distinguen por
524   su `listen`; en Caddy, por el **esquema de la dirección**. Sin traducir eso, los bloques colisionan
525   y el fichero ni carga.
5262. **Y el grave**: un `if (...) { return 403; }` salía como `respond 403` **incondicional** — el sitio
527   entero devolviendo 403. La directiva vivía dentro de un bloque que yo mismo había declarado
528   intraducible, y se absorbía igual al bloque de afuera.
529
530   Eso es peor que la pérdida silenciosa que la doctrina de `soltar` advierte: **no pierde, CAMBIA el
531   sentido**. Ahora todo lo que está dentro de un bloque opaco sale como `SIN-TRADUCIR` con su motivo:
532   *«sacarla de ahí le cambiaría el sentido — dejaría de ser condicional»*.
533
534Con los dos arreglados, el caddy del corpus contesta **`Valid configuration`**.
535
536#### Y la receta de caddy, terminada
537
538El `caddy` del corpus era un **import de nix en crudo**: su cabecera decía «PUNTO DE PARTIDA, no
539final» y su `commit` era el TAG flotante `v2.11.4` — contra el ADR 0006, porque un tag se mueve y la
540receta pasa a construir otra cosa sin que el hash lo note. Anclado a `e2eee6a7…` con `takana pin`; el
541hash se movió a propósito. 77 MB estáticos, **0 intérpretes requeridos**, y un `file-server` de
542prueba contesta 200.
543
544Importaba terminarla por una razón medida: **el `caddy` de gioser no tiene dueño** — binario puesto a
545mano, sin paquete y sin receta — y se pierde con la máquina. Con la receta, el servidor nuevo lo
546declara y lo reconstruye.
547
548### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)*
549
550`aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]`
551
552**La regla que lo define: un paso que no se ejecutó NO se marca como hecho.** Un plan tiene pasos de
553dos naturalezas y confundirlas es la forma más fácil de dar una mudanza por buena sin estarlo:
554
555- **ejecutable** — tiene comandos de verdad (copiar, comprobar espacio);
556- **manual** — su `cmd` son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada
557  que correr, y **un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok»**.
558  Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.
559
560Acá un paso manual queda `pendiente-humano`, la corrida **no** se considera completa (sale con ≠0), y
561se confirma con `--hecho N` — que además **se niega** si el paso sí tenía comandos: «corrélo, no lo
562marques».
563
564**Se le cree a la verificación, no al exit code.** El `rsync` que llenó el disco devolvió **0** y
565dejó 1367 artefactos vacíos. Un paso pasa a `ok` sólo si su verificación pasa; si el comando salió
566bien y la verificación falló, queda `sospechoso`, que se informa como peor que un fallo.
567
568**Y las verificaciones están TIPADAS** (`cmd` / `humano`): «la columna Available debe ser > X» no es
569un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.
570
571**Reanudable**: el estado vive en `<plan>.estado.json`, escrito con temporal + `fsync` + `rename` —
572la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.
573
574#### ⚠ Y una verificación que era decorativa
575
576La primera versión del paso de datos verificaba así:
577
578```
579ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'
580```
581
582Imprimía el número de vacíos **y devolvía 0 igual**. En la primera corrida real dio «✓ verificado: 2»
583— donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.
584
585Ahora **compara**: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a
586propósito y con el control que tiene que pasar:
587
588```
589copia NO hecha  → ficheros: origen=5 destino=0  ⇒ FALLA
590copia hecha     → ficheros: origen=5 destino=5  ⇒ PASA
591```
592
593Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
594
5951. **Idempotente y reanudable.** Cada copia grande se cortó al menos una vez.
5962. **Verifica en DESTINO, no en origen.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
597   dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
5983. **Los servicios `no-declarado` se declaran**, no se replican a mano. Es la salida natural hacia
599   `arje-absorb`, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero **leyendo la
600   declaración, que es justo la que miente**: hay que alimentarlo con el censo.
6014. **Deja el servidor corriendo**, y lo prueba desde afuera: cada dominio `vivo` del plan tiene que
602   contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.
603
604## 5. La imagen, independiente del proveedor
605
606Lo que hoy la ata a Hetzner es poco y está medido:
607
608- **Arranque: ✅ RESUELTO (2026-09-11).** La imagen ahora arranca por **BIOS y por UEFI**, y es la
609  misma imagen. Layout: `p1` BIOS-boot · `p2` ESP FAT32 · `p3` `/` · `p4` estado · `p5` store.
610
611  **Un solo `grub.cfg`**: el `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan los dos a
612  `(hd0,gpt3)/boot/grub`, así que hay una sola línea de comando y un solo sitio donde editarla. Dos
613  imágenes hermanas obligarían a elegir por proveedor y a que divergieran — y ya divergían: la
614  hermana EFI documentaba etiquetas `takana-*` que su propio código no escribe (corregido).
615
616  **No se usa EFI-stub directo acá** (sí lo hace `install-image-efi.sh`, ADR 0010): el stub arrancado
617  por la ruta fallback recibe LoadOptions VACÍO, así que necesita la cmdline HORNEADA en el kernel, y
618  la de `linux-generic` sólo trae la consola — sin `root=`. Hornearla ataría la línea de comando al
619  ArtifactHash del kernel. Con GRUB EFI la cmdline vive en el `grub.cfg`, que es donde se edita.
620
621  **Verificado con la MISMA imagen en los dos firmwares**, hasta SSH:
622
623  | | UEFI (OVMF) | BIOS (SeaBIOS) |
624  |---|---|---|
625  | `/sys/firmware/efi` | **presente** | ausente |
626  | PID 1 | `arje-zero` | `arje-zero` |
627  | raíz · store | `sda3` · `sda5` | `sda3` · `sda5` |
628- **Red**: `netup` hace DHCPv4 y ya cubre el caso difícil (IP `/32` con la puerta fuera del prefijo,
629  que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores.
630- **Instalación**: `rescue → dd → reboot` no es de Hetzner: sirve en cualquier entorno de rescate con
631  SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor.
632- **Disco**: ya se resuelve solo — el store es la última partición y crece al disco entero en el
633  primer arranque.
634
635⇒ **la imagen está más cerca de ser portable que la herramienta de mudarse.** Por eso el orden es
636censo → plan → aplicación, y la portabilidad de la imagen va en paralelo.

29-mudanza.md

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:

clasequé espor qué importa
vivodeclarado y corriendocandidato a mudarse
declarado-muertodeclarado, sin procesocandidato a morir
no-declaradocorriendo, sin declaraciónel trabajo real: nadie sabe cómo levantarlo

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

⚠ 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:

familiamotivoejemplos en gioser
hardware localuna caja remota no tiene ese dispositivobluetoothd ModemManager upowerd adb
escritorio o pantallaun servidor no los tienewaypipe
red del destinopelearían: el destino la configura con su propio initNetworkManager dhcpcd
lo provee el init del destinono se mudaudevd 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

⚠ 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:

clasequé significaqué hay que hacer
receta-takanatakana ya sabe construirloinstalarlo del repo firmado — no copiar el binario
paquete-ajenolo trae el gestor del origenescribir una receta, o qorpa (ADR 0015)
sueltonadie lo provee: puesto a manollevarlo 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:

artefactoqué declaralo consume
[perfil.<x>] → targets.tomlqué software llevaservidor-image.sh al armar la imagen
seed.card.jsonqué corre y cómoarje-zero al arrancar
plan.tomlqué datos y en qué ordenaplicar.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.0 septies · El censo real de gioser, y dos huecos que destapó

Corrido contra 204.168.193.248 — que es gioser.net, identificado por IP y no por hostname, porque hostname dice momento:

── servicios ──                       ── dominios (sonda DNS) ──
  vivo               19                 apunta-aca      12
⚠ no-declarado       18                 ya-mudado        4
  declarado-muerto   85                 fosil-sin-dns   13

El HTTP toca al origen; el DNS no

Sondear los dominios desde la propia máquina disparó su fail2ban y la dejó incomunicada — por eso el censo en local los salteaba, y quedaban 29 de 66 ítems sin recomendación. Pero lo que dispara la jaula es el HTTP: getent le pregunta al DNS, no al servidor. Con --probe-dns se sondea sólo el nombre, sin una sola petición a gioser, y eso ya contesta la pregunta que más pesa: cuáles siguen apuntando acá y cuáles son fósiles. 29 de 29 clasificados; sólo 12 hay que reapuntar.

Lo que el DNS solo no puede decir es si el backend contesta, así que esos quedan en apunta-aca — clase propia, no vivo. Decir «vivo» sin haber pedido una página sería la respuesta falsa con forma de respuesta que este censo existe para evitar.

Los fósiles quedaban fuera de la lista, y por eso sus datos viajaban

entradas() filtraba los fosil-sin-dns con el argumento de que un dominio sin DNS no necesita ningún paso. Es cierto para el DNS y falso para lo que arrastra: son 13 de 29, y terapeuta.ec tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran invisibles, así que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — exactamente el «mudar fósiles» que este plan existe para evitar, y contra la regla 2, que dice que lo que muere se nombra antes de borrar nada.

4.0 sexies · El centro ENGANCHADO al plan

El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Es cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora ese párrafo es un paso con su comando literal:

▸ traducir declaración de caddy (openrc → arje)        [verifica: humano]
   scripts/mudanza/traducir.py --from openrc --to arje \
         --in /etc/init.d/caddy --out caddy.card.json

▸ traducir configuración de nginx → caddy              [verifica: humano]
   scripts/mudanza/traducir.py --from nginx --to caddy \
         --in /etc/nginx/nginx.conf --out Caddyfile

Dos traducciones distintas por servicio, y confundirlas es caro: la declaración (cómo se levanta) y la configuración (qué hace). Un servicio que se muda a sí mismo sólo necesita la primera — ofrecerle traducir su config sería inventarle trabajo.

Los pares se le preguntan al registro de plugins, no se listan en planear.py: una lista propia se desincronizaría del centro y el plan ofrecería una traducción que no existe. Cuando no hay par, el paso lo dice y manda a --list.

Y la verificación es humana, a propósito: traducir.py sale ≠0 cuando algo quedó sin traducir, así que dar el paso por bueno por su código de salida sería exactamente al revés de lo que hay que mirar. Lo que este paso verifica es que alguien leyó el acta.

⚠ El plan que generábamos no era TOML válido

Salió al meter el primer comando multilínea, pero estaba latente desde el principio. Dos modos de fallo, y el segundo es el peor:

cadena básica """cadena literal '''
awk "\$1==1" (el verifica de todo servicio)ilegible: Unescaped '\'parsea idéntico
cmd --from x \ + saltoparsea… y se come el salto y la sangríaparsea idéntico

El primero rompe ruidosamente. El segundo parsea y miente: el plan se lee bien y el comando que sale no es el que se escribió. El plan es el producto —se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr—, así que uno que no vuelve a parsear no sirve para ninguna de las dos cosas para las que existe.

Arreglado usando la cadena literal, y con un guardián que escribe y relee antes de tocar el disco: si el TOML generado no parsea, no se escribe nada. Cuesta milisegundos y convierte un fallo diferido —que aparecía cuando alguien iba a ejecutar el plan— en uno inmediato.

4.0 quinquies · El CENTRO de traducción — pivote y plugins

Cambiar de nginx a caddy no es cambiar un binario: es reescribir la configuración. traducir.py hace la parte mecánica y —sobre todo— dice con número de línea lo que NO pudo traducir.

⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE

Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y cada formato nuevo agrega 2N. Con un modelo intermedio son N lectores + M escritores: un formato nuevo cuesta uno o dos plugins y estrena todos los pares de golpe.

  formatos/nginx.py   ─┐                      ┌─►  formatos/caddy.py
  formatos/apache.py  ─┼─►  modelo pivote  ───┤
  (el próximo)        ─┘    Config/Sitio/Ruta └─►  (el próximo)

Probado, no argumentado: el lector de apache se agregó sin tocar el escritor de Caddy, y con eso apache → caddy funciona sin que exista ningún traductor apache→caddy. Las dos salidas las valida el caddy del propio corpus:

apache → caddy :  Valid configuration
nginx  → caddy :  Valid configuration

Un formato se agrega dejando un fichero en formatos/ con @lector("x") y/o @escritor("y"). El centro los descubre: no hay una lista que editar, porque una lista mantenida a mano se desincroniza y el formato nuevo «no existe» sin que nada falle. traducir.py --list dice qué pares habilita y cuántos plugins costaron.

El pivote es uno POR FAMILIA

Un unit de systemd no es un sitio web. Meterlo a la fuerza en Sitio/Ruta sería justo el «parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro empareja sólo dentro de la familia:

familiamodelolectoresescritores
webSitio / Ruta / Accionnginx, apachecaddy
servicioServicio / Sistemasystemd, openrcarje

Un par cruzado se rechaza con un error, no con un intento:

$ traducir.py --from systemd --to caddy …
✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
  No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.

systemd → arje: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema

El payload Native de arje acepta exec, argv y envp — y nada más (comprobado en la semilla real del producto). No hay campo de usuario. Un User=git traducido en silencio correría el servicio como root: no es perder una opción, es escalar privilegios en un fichero generado que nadie vuelve a leer. Sale como SIN-TRADUCIR, con su línea.

Los otros tres del mismo tipo: EnvironmentFile= (el fichero no está en la máquina donde se traduce ⇒ el envp sale incompleto y el servicio falla tarde), Type=forking (arje supervisa al proceso que lanza ⇒ bucle de reinicio; la bandera de primer plano depende del demonio y decirla es del humano), y todo el endurecimiento (ProtectSystem, NoNewPrivileges…), que callado entrega un servicio menos confinado que el original.

Lo que sí tiene destino exacto: Type=oneshot → "supervision": "OneShot", que ya existe en la semilla del producto. Y el control es que la tarjeta generada tiene el mismo juego de claves que la tarjeta real de sshd, más _mudanza de procedencia.

openrc → arje: casi la mitad de los servicios NO son declaraciones

Un unit de systemd se lee. Un servicio de OpenRC es un script de shell que puede hacer cualquier cosa antes de arrancar nada. Medido sobre el /etc/init.d real de gioser —la máquina que esta mudanza borra— 60 de 121 definen su propia start()/stop(). En ésos no hay command= que valga: leer las variables y emitir una tarjeta produciría un servicio que arranca otra cosa.

Por eso la regla va al revés que en systemd: start() propia ⇒ SIN-TRADUCIR y no se emite tarjeta. Los números del corpus real cierran solos:

  ficheros en /etc/init.d      : 121
  no son openrc-run            :   1
  con start()/stop() propia    :  60   → no se emite tarjeta
  declarativos sin command=    :   1   → no hay qué arrancar
  ⇒ traducibles                :  59   ← y el lector emitió exactamente 59

Lo otro que OpenRC obliga a ir a buscar es /etc/conf.d/<servicio>: el servicio está partido en dos ficheros y los argumentos de verdad viven en el segundo. A diferencia del EnvironmentFile= de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático no lo dice el script sino el enlace en /etc/runlevels/.

Tres defectos que sólo aparecieron corriendo contra las 121 de verdad

Lector y escritor no opinan del mismo campo

User= lo captura el lector y lo juzga el escritor. Cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector registra además en qué línea vio cada campo, para que el escritor señale la línea real en vez de un 0 que obliga a buscar a mano lo que ya se sabía.

El acta viaja DENTRO del modelo

Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que un lector no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.

Y el escritor puede agregarle: Caddy no tiene equivalente directo de location ~, así que en vez de emitir un handle de prefijo —que se parece y no es lo mismo— lo manda al acta. Parecerse es peor que faltar.

Doctrina heredada de soltar/paskaq (tawasuyu)

soltar desencadena datos presos en formatos cautivos (Btrieve, Paradox, .mdb). El dominio es otro —datos, no configuración— pero sus principios son exactamente los que hacía falta, y se adoptan tal cual:

Y lo que NO es: un traductor completo de nginx. Traduce el núcleo común y declara todo lo demás. Uno honesto que cubre el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.

⚠ Validar con el binario real encontró dos bugs, y uno cambiaba el sentido

Leer la salida no alcanzaba. Pasarla por caddy validate —con el caddy del propio corpus— destapó:

  1. ambiguous site definition: en nginx dos server con el mismo server_name se distinguen por su listen; en Caddy, por el esquema de la dirección. Sin traducir eso, los bloques colisionan y el fichero ni carga.

  2. Y el grave: un if (...) { return 403; } salía como respond 403 incondicional — el sitio entero devolviendo 403. La directiva vivía dentro de un bloque que yo mismo había declarado intraducible, y se absorbía igual al bloque de afuera.

    Eso es peor que la pérdida silenciosa que la doctrina de soltar advierte: no pierde, CAMBIA el sentido. Ahora todo lo que está dentro de un bloque opaco sale como SIN-TRADUCIR con su motivo: «sacarla de ahí le cambiaría el sentido — dejaría de ser condicional».

Con los dos arreglados, el caddy del corpus contesta Valid configuration.

Y la receta de caddy, terminada

El caddy del corpus era un import de nix en crudo: su cabecera decía «PUNTO DE PARTIDA, no final» y su commit era el TAG flotante v2.11.4 — contra el ADR 0006, porque un tag se mueve y la receta pasa a construir otra cosa sin que el hash lo note. Anclado a e2eee6a7… con takana pin; el hash se movió a propósito. 77 MB estáticos, 0 intérpretes requeridos, y un file-server de prueba contesta 200.

Importaba terminarla por una razón medida: el caddy de gioser no tiene dueño — binario puesto a mano, sin paquete y sin receta — y se pierde con la máquina. Con la receta, el servidor nuevo lo declara y lo reconstruye.

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:

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:

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