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 quindecies · ⚠ 26 repositorios existen SÓLO en la máquina que se va a borrar
305
306Es el hallazgo más grave de este frente, y va por delante de todo lo demás en el plan. Buscando cómo
307escribir la receta de `tawasuyu` apareció que **su único remoto es el gitea de gioser**
308(`ssh://gitea@git.tawasuyu.net:2345/…`, y `git.tawasuyu.net` resuelve a `204.168.193.248`, que es
309gioser). Al mirar el resto: **28 repositorios con remoto en esta máquina, 26 sin ninguna copia
310fuera.** Sólo `takana` y `llimphi-standalone` tienen espejo externo.
311
312Apagar el origen no borra unos servicios: borra **el código con el que se volverían a construir**. Y
313los clones de trabajo están en el mismo disco, que también muere. Entre los 26 está `tawasuyu`, que
314produce **10 de los 13 binarios que nadie más provee** — la dependencia circular completa.
315
316No se ve desde ninguna otra parte del censo: un repo no es un proceso, ni un puerto, ni un dominio.
317Se descubre cuando ya no hay de dónde sacarlo.
318
319El censo lo mira ahora (`[[repo]]`), y lo que reporta **no es «tiene remoto» sino si alguno de sus
320remotos NO es esta máquina** — un remoto que apunta afuera es la prueba de que el código sobrevive.
321En el plan es el **paso 1**, por delante del rescate de binarios: aquello pierde un servicio, esto
322pierde la posibilidad de reconstruirlo. El propio takana ya resuelve esto con `pushurl` doble
323(`scripts/espejo-setup.sh`), así que el arreglo está escrito.
324
325### 4.0 quaterdecies · Los 13 binarios sueltos son casi un solo árbol
326
327De los 13 servicios cuyo binario **nadie provee** —los que se pierden al apagar el origen— **diez
328salen del mismo repositorio**, `tawasuyu` (hoy en `bad13117c`):
329
330| binario | paquete del workspace |
331|---|---|
332| `matilda` | `matilda` |
333| `pacha` | `pacha-cli` |
334| `pacha-secretos` | `pacha-secretos` |
335| `sandokan-watch` | `sandokan-seguridad-core` |
336| `shuma-daemon` · `shuma-gateway` | homónimos |
337| `tejido` | `tejido` |
338| `tupu` | `tupu-cli` |
339| `willay-crosscheck` | `willay-cruce` |
340| `willay-daemon` | `willay-daemon` |
341
342(Los otros tres son ajenos: `act_runner`, `adb` y el `puerta-…` de `target/debug`.)
343
344O sea que **una receta cubre diez servicios**, y el trabajo de escribirla se paga diez veces.
345
346#### El tamaño real es una quinta parte del que aparenta
347
348El `Cargo.lock` del workspace tiene **2823 crates**, pero eso es el workspace entero —con su stack
349gráfico, audio y Android—. El subárbol que esos diez binarios necesitan de verdad son **524**. Y de
350todos ellos, sólo **cinco traen C**:
351
352```
353 aws-lc-sys ring ← criptografía: compilan C/asm, necesitan cmake
354 dirs-sys inotify-sys netlink-sys ← bindings puros, sin librería externa
355```
356
357`aws-lc-sys` y `ring` son los conocidos de esta cadena; los otros tres no cuestan nada.
358
359#### ⚠ Lo que hay que decidir antes de escribirla: los dos pines no coinciden
360
361`tawasuyu/rust-toolchain.toml` fija **1.96.0**, y lo argumenta en el propio fichero: *una distro que
362promete builds deterministas y atestación firmada no puede tener el compilador flotando*. El lab de
363takana trae **1.97.0**, y es **rodante** por diseño — `lab-toolchain.lock` detecta la deriva, no la
364evita.
365
366Así que una receta takana de tawasuyu compilaría con un rustc distinto del que el propio tawasuyu
367exige. No es un detalle de estilo: **el binario resultante no sería el mismo que está corriendo**, y
368rompería el contrato de atestación de tawasuyu. Tres salidas, y es una decisión, no un trámite:
369
3701. **Pinear 1.96.0 para esta receta** — honra el contrato de tawasuyu; hay que meter ese rustc en el
371 lab, como ya se hace con las 84 recetas que pinean `zig_version=0.13.0`.
3722. **Mover el pin de tawasuyu a 1.97.0** — decisión del otro frente, no de éste.
3733. **Llevar los binarios tal cual** — es lo que el plan hace hoy, y significa que no reproducen.
374
375⚠ Y no se construye en el origen: 4 núcleos y ~2 G disponibles. Eso va al worker
376(`dev.gioser.net`, 6 núcleos / 16 G), que además es gratis.
377
378### 4.0 terdecies · El cruce que nadie hacía: un dominio que se muda sin su backend
379
380La config del servidor **declara de qué servicios depende** — cada `reverse_proxy` y cada
381`php_fastcgi` apuntan a algo. Cruzar eso contra la decisión tomada sobre cada servicio caza un fallo
382que ninguna otra parte puede ver:
383
384> el dominio se **muda** y el servicio que lo sirve está marcado **muere**.
385
386Las dos decisiones son razonables por separado —el dominio se usa, el servicio parecía
387prescindible— y juntas dejan el sitio nuevo devolviendo 502. No lo ve el DNS, no lo ven los procesos,
388y no lo ve quien decide **de a una entrada por vez**, que es como se decide.
389
390También caza el revés, y en gioser eso es lo que apareció: dos sitios proxean a un puerto que
391**ningún servicio censado sirve**, o sea que ya devuelven 502 *hoy, en el origen*.
392
393```
394 mail.sigma.gioser.net ↳ localhost:9000 nadie escucha ahí
395 api.gioser.net ↳ localhost:8000 nadie escucha ahí
396```
397
398Confirmado aparte con `ss -lntp`: efectivamente no hay nada en 8000 ni en 9000.
399
400#### Y un falso positivo que hubo que matar primero
401
402La primera corrida acusaba a `sergio.gioser.net` de mudarse dejando atrás a `shuma`. Era falso, y la
403causa estaba **en el censo**: los puertos se adjudicaban por prefijo de nombre
404(`proc.startswith(name[:15])`), así que un `shuma` **declarado-muerto** se quedaba con el 7378 — que
405lo escucha `shuma-gateway`, comprobado con `ss -lntp`. Un declarado-muerto **por definición no puede
406estar escuchando**.
407
408Ahora los puertos se atan por **PID**, que es lo único sin ambigüedad. El puerto es la señal más
409fuerte de que algo sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de
410él — y aquí se derivó un guardián acusando al inocente, que es la forma más rápida de que un guardián
411se deje de leer.
412
413### 4.0 duodecies · La cuenta de lo que falta, bien hecha
414
415`origen_binario` buscaba la receta por el **nombre del servicio**, y ése casi nunca es el nombre del
416paquete: `sshd` lo trae `openssh`, `crond` lo trae `cronie`. El censo ya le preguntó al gestor de
417paquetes quién posee cada binario, así que ese nombre también se prueba. Con eso el perfil pasó de
418**4 recetas a 7**, y la clasificación de los 27 servicios quedó así:
419
420```
421 receta-takana 9 paquete-ajeno 13
422 suelto 9 interprete 4 borrado 4
423```
424
425#### La trampa: el intérprete NO es el programa
426
427`openclaw` lo posee el paquete `nodejs`, y `fail2ban-server`, `glances` y `uvicorn` los posee
428`python`. Contarlos como cubiertos porque existe `recipes/nodejs.toml` haría salir el perfil **N/N
429describiendo un servidor al que le faltan cuatro programas**. Tienen clase propia, `interprete`, y
430cuentan **las dos cosas a la vez**, porque las dos son ciertas:
431
432- su **runtime entra al perfil** — `openclaw` necesita `nodejs` en la imagen pase lo que pase;
433- el **servicio sigue listado como no cubierto**, con su motivo.
434
435Meterlo sólo en las raíces haría un perfil que miente; dejarlo sólo en los faltantes armaría una
436imagen sin runtime, y el programa, cuando llegue, no arrancaría.
437
438(Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix
439empaqueta `python`, el catálogo tiene `python3.toml`. Sin ese alias, tres servicios decían «hace
440falta una receta takana» teniendo el runtime sellado — dos trabajos muy distintos.)
441
442#### Sellada y sin sellar no son lo mismo
443
444El perfil las listaba igual. Una sellada se **instala** del repo firmado; una sin sellar hay que
445**construirla**, y eso puede ser una tarde. Salió con un caso real: `recipes/qdrant.toml` entró al
446catálogo desde otro frente **mientras se escribía esto** y no tiene artefacto. Ahora el perfil lo
447marca en la línea y lo resume al pie.
448
449### 4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA
450
451El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir:
452`/proc/<pid>/exe` termina en `(deleted)` para **cuatro servicios de gioser**. El fichero ya no está
453en disco; sólo vive el inodo que sostiene su proceso.
454
455Y en tres de los cuatro hay **ahora otro fichero en la misma ruta, de distinto tamaño**:
456
457| servicio | corriendo | en su ruta hoy |
458|---|---|---|
459| `tejido` | 12 750 368 B | 13 815 864 B |
460| `shuma-gateway` | 8 431 896 B | 10 363 504 B |
461| `pacha-secretos` | 8 634 240 B | 8 647 456 B |
462| `puerta-f6e393ff…` | 197 262 440 B | *no hay nada* |
463
464Copiar la ruta **no falla**: muda otra cosa, y el servicio nuevo no es el que estaba andando. Es el
465modo de fallo más caro que hay en este documento — todo verde, todo distinto.
466
467Se recuperan leyendo `/proc/<pid>/exe`, **y sólo mientras el proceso viva**. En una mudanza que
468termina *borrando el origen*, un reinicio de gioser antes de este paso pierde esos binarios para
469siempre. Por eso el rescate va **antes del preflight**: es el único paso del plan que puede volverse
470imposible mientras se piensa el resto.
471
472Su verificación **compara tamaños** contra el que corre, y no por celo: un `cat` de un `/proc` que ya
473no existe crea un fichero **vacío** y devuelve 0. Probado en los dos sentidos — el paso real pasa, y
474con un fichero vacío a propósito el guardián dice `FALTA tejido` y sale 1.
475
476Los cuatro ya están rescatados en `work/mudanza/rescate/`, con su `SHA256SUMS`.
477
478### 4.0 decies · Leer la config del servidor: un fósil se detecta SIN tocar la red
479
480La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el
481**fichero de configuración**, y leerlo no toca al origen — ni una petición, ni riesgo de fail2ban.
482
483Para leerlo se agregó un **lector de Caddy** al centro (`formatos/caddy.py`, que ya tenía el
484escritor), y el censo lo usa en vez de tener su propio parser a medias: los lectores de nginx y
485apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note.
486
487El resultado inmediato, sobre el Caddyfile real de gioser — **cinco sitios apuntan a un `root` que no
488existe**, y son justo los que devolvían los 502 que en su día hicieron que el censo *se baneara a sí
489mismo* al sondearlos por HTTP:
490
491```
492 aura.gioser.net → /var/www/aura_frontend NO EXISTE
493 sigma.gioser.net → /var/www/sigma/frontend NO EXISTE
494 summa.gioser.net → /var/www/summa/frontend NO EXISTE
495 kosmofono.gioser.net → /var/www/kosmofono/frontend NO EXISTE
496 dev.summa.gioser.net → /var/www/summa-dev/frontend NO EXISTE
497```
498
499Es evidencia **más fuerte que el DNS**: no hay nada que servir, devuelve 502 resuelva donde resuelva.
500
501#### Y sirve para lo contrario, que es donde el aviso hacía daño
502
503Un directorio que la config **sí** referencia no es un huérfano, diga lo que diga su nombre. Sin
504esto, el plan marcaba `/var/www/git-tawasuyu` como «nadie lo recuerda» estando servido — y ese aviso,
505aplicado, **tira `git.tawasuyu.net`**.
506
507La primera versión del lector seguía fallando en eso, y el motivo es instructivo: el `root` de ese
508sitio vive **dentro de un `handle`**, y yo saltaba los bloques anidados enteros por no saber
509modelarlos. Que el pivote no sepa **modelar** algo no es razón para no **verlo**: ahora el bloque
510sigue marcado SIN-TRADUCIR, pero sus `root` y `reverse_proxy` se leen. Con eso aparecieron dos raíces
511ausentes más, que también estaban anidadas.
512
513#### Falsos sitios: una lista negra no alcanzaba
514
515Detectar la cabecera de un sitio descartando directivas conocidas (`handle {`, `route {`, `@bot {`)
516dejaba pasar `log { output file … {` y el snippet `(acceso) {` — **dos dominios inventados** que el
517censo habría puesto a decidir. La regla que aguanta es positiva: *todos* los tokens de la cabecera
518tienen que **parecer una dirección**.
519
520### 4.0 nonies · La cadena entera, y el preflight que medía contra el sistema de ficheros equivocado
521
522`censar → planear → aplicar` corrió de punta a punta contra gioser: **59 pasos**, 20 ejecutables y 39
523manuales, correctamente separados — el aplicador marca los manuales `pendiente-humano` y la corrida
524no se considera completa.
525
526Y ahí el ensayo en seco destapó lo que ninguna prueba de juguete iba a mostrar: **33,6 G de datos
527contra una raíz de 3,5 G libres.**
528
529```
530 /dev/root 3,5 G libres /
531 /dev/sdb 19,0 G libres /store
532 /dev/sda4 66,2 G libres /work
533```
534
535Dos cosas estaban mal a la vez:
536
537- **El plan copiaba ruta → misma ruta**, sin forma de decir dónde cae cada árbol en el destino. Ahora
538 `[[datos]]` tiene `destino` (vacío = la misma ruta), y el fallo que evita es caro: aparecía a mitad
539 de un `rsync` de 22 G.
540- **El preflight sumaba todo y lo comparaba contra `/`.** Eso acierta *por casualidad* mientras todo
541 caiga en `/`, y da un veredicto **completamente falso** en cuanto una ruta va a otro montaje — en
542 las dos direcciones. Ahora mide **por sistema de ficheros**, agrupando los destinos, y el veredicto
543 lo saca el propio comando en vez de un humano leyendo una columna:
544
545```
546 ✗ / necesita 34386 MiB · libres 3596 ⇐ /var/www /var/lib /srv /opt /home
547 NO ENTRA en /: faltan 30790 MiB (exit 1)
548
549 ✓ /work necesita 34386 MiB · libres 66192 ⇐ /work/var/www …/work/home (exit 0)
550```
551
552Los dos controles corridos contra la caja de verdad: el negativo falla y el positivo pasa.
553
554### 4.0 octies · `/proc` es un formato de origen más — y había DOS emisores de tarjetas
555
556`declarar.py` tenía su propio molde de card de arje, y `formatos/arje.py` el suyo. Dos emisores del
557mismo formato es exactamente el N×M que el pivote existe para evitar, y no quedó en teoría: **cada
558copia tenía un campo mal de la raíz, y ninguno de los dos el mismo.**
559
560| campo de la raíz | `declarar.py` | `formatos/arje.py` | la semilla REAL del producto |
561|---|---|---|---|
562| `provides` | `["Spawn","Journal"]` ✓ | `[]` ✗ | `["Spawn","Journal"]` |
563| `supervision` | `Restart{…}` ✗ | `"OneShot"` ✓ | `"OneShot"` |
564
565Las dos consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni
566dónde escribir; y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
567
568Ahora hay un lector `proc` —el censo es un formato de origen, igual que systemd u OpenRC— y el
569escritor `arje` es el único que emite tarjetas. `declarar.py` queda con lo suyo: el **perfil**.
570
571```
572familia «servicio»: systemd ─┐
573 openrc ─┼─► Servicio/Sistema ──► arje
574 proc ─┘
575```
576
577Los tres se complementan y ninguno solo alcanza: systemd y OpenRC leen **lo declarado**, que es lo
578que miente —`rc-status` daba `stopped` para cinco servicios vivos—, y `proc` lee **lo que corre**,
579que es lo único donde aparecen los 15 `no-declarado`.
580
581#### Una semilla vacía parecería un éxito
582
583Si ningún servicio tiene `decision = "muda"`, el lector `proc` **no devuelve cero tarjetas en
584silencio**: lo dice y sale ≠0. Es el mismo modo de fallo que un artefacto vacío en el store — un
585ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
586
587#### El acta se estaba ahogando en su propio ruido
588
589La primera versión anotaba el `envp` vacío **una vez por servicio**: 26 líneas idénticas que tapaban
590los dos hallazgos que sí eran de un servicio concreto —`shuma-daemon` corre como `sergio`, y los que
591no tienen línea de comando—. La limitación es del *lector* y vale igual para todos, así que va **una
592entrada nombrando a los 26**. Un acta donde casi todo es la misma línea se deja de leer, y entonces
593no queda ningún acta. Lo mismo con la supervisión. Y al revés: las entradas que sí son por servicio
594ahora lo **nombran** (`gitea: cwd=/var/lib/gitea`), porque cuatro `WorkingDirectory=/home/sergio`
595sin nombre obligan a buscar cuál es cuál.
596
597### 4.0 septies · El censo real de gioser, y dos huecos que destapó
598
599Corrido contra `204.168.193.248` — que es `gioser.net`, identificado **por IP y no por hostname**,
600porque `hostname` dice `momento`:
601
602```
603── servicios ── ── dominios (sonda DNS) ──
604 vivo 19 apunta-aca 12
605⚠ no-declarado 18 ya-mudado 4
606 declarado-muerto 85 fosil-sin-dns 13
607```
608
609#### Tres nombres equivocados, y los tres llegaban al plan
610
611El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
612clasificación —no cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<nombre>` y el
613que va de `label` en la tarjeta de arje.
614
615- **`comm` está capado a 15 caracteres.** `willay-crosscheck` llegaba como `willay-crossche`, y con
616 ese nombre no casaba contra su declaración ⇒ figuraba como **`no-declarado`** teniendo su tarjeta
617 en la semilla de arje. La línea de comando trae el nombre entero.
618- **`supervise-daemon` no es un servicio, es un envoltorio.** Los cinco de gioser se fundían en UNA
619 entrada llamada `supervise-daemo`; y del otro lado, `dbus`, `metalog`, `dhcpcd`, `squid` y
620 `shuma-daemon` salían como **`declarado-muerto` estando vivos** — el mismo agujero que el censo
621 existe para tapar, entrando por la otra puerta. El nombre real es su `argv[1]`; el **comando** real
622 es el último token antes del `--` suelto.
623- **`head -15` con `ppid==1`** era un resto de tubería reparentado, contado como servicio
624 `no-declarado`. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
625
626#### «No se pudo leer su binario» eran dos cosas distintas
627
6287 de 38 servicios salían `desconocido`. La causa no era una: muchos demonios **reescriben su
629`argv[0]`** (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
630comando no dice cuál es el binario — pero `/proc/<pid>/exe` sí, y necesita ser dueño o root. Con el
631censo corriendo como root, los 7 se resuelven:
632
633```
634 uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
635 root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
636```
637
638Así que el censo ahora **dice cuál de las dos cosas pasó**: «repetilo con sudo» y «averigualo a mano»
639son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
640
641Y desenvolver al supervisor destapó un dato que se estaba perdiendo: `shuma-daemon` corre con
642`--user sergio`. La tarjeta de arje **no tiene campo de usuario**, así que sin eso a la vista un
643servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión.
644
645#### El HTTP toca al origen; el DNS no
646
647Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso
648el censo en local los salteaba, y quedaban 29 de 66 ítems **sin recomendación**. Pero lo que dispara
649la jaula es el HTTP: `getent` le pregunta al DNS, no al servidor. Con `--probe-dns` se sondea sólo el
650nombre, **sin una sola petición a gioser**, y eso ya contesta la pregunta que más pesa: cuáles siguen
651apuntando acá y cuáles son fósiles. 29 de 29 clasificados; sólo **12 hay que reapuntar**.
652
653Lo que el DNS solo no puede decir es si el backend contesta, así que esos quedan en `apunta-aca`
654— clase propia, **no `vivo`**. Decir «vivo» sin haber pedido una página sería la respuesta falsa con
655forma de respuesta que este censo existe para evitar.
656
657#### Los fósiles quedaban fuera de la lista, y por eso sus datos viajaban
658
659`entradas()` filtraba los `fosil-sin-dns` con el argumento de que un dominio sin DNS no necesita
660ningún paso. Es cierto para el DNS y **falso para lo que arrastra**: son 13 de 29, y `terapeuta.ec`
661tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran **invisibles**, así
662que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — exactamente el «mudar
663fósiles» que este plan existe para evitar, y contra la regla 2, que dice que lo que muere se nombra
664**antes** de borrar nada.
665
666### 4.0 sexies · El centro ENGANCHADO al plan
667
668El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Es cierto y
669es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora ese
670párrafo es **un paso con su comando literal**:
671
672```
673▸ traducir declaración de caddy (openrc → arje) [verifica: humano]
674 scripts/mudanza/traducir.py --from openrc --to arje \
675 --in /etc/init.d/caddy --out caddy.card.json
676
677▸ traducir configuración de nginx → caddy [verifica: humano]
678 scripts/mudanza/traducir.py --from nginx --to caddy \
679 --in /etc/nginx/nginx.conf --out Caddyfile
680```
681
682Dos traducciones distintas por servicio, y confundirlas es caro: la **declaración** (cómo se levanta)
683y la **configuración** (qué hace). Un servicio que se muda a sí mismo sólo necesita la primera —
684ofrecerle traducir su config sería inventarle trabajo.
685
686Los pares se le **preguntan al registro de plugins**, no se listan en `planear.py`: una lista propia
687se desincronizaría del centro y el plan ofrecería una traducción que no existe. Cuando no hay par, el
688paso lo dice y manda a `--list`.
689
690Y la verificación es **humana**, a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir,
691así que dar el paso por bueno por su código de salida sería exactamente al revés de lo que hay que
692mirar. Lo que este paso verifica es que alguien **leyó el acta**.
693
694#### ⚠ El plan que generábamos no era TOML válido
695
696Salió al meter el primer comando multilínea, pero estaba latente desde el principio. Dos modos de
697fallo, y el segundo es el peor:
698
699| | cadena básica `"""` | cadena literal `'''` |
700|---|---|---|
701| `awk "\$1==1"` (el `verifica` de todo servicio) | **ilegible**: `Unescaped '\'` | parsea idéntico |
702| `cmd --from x \` + salto | parsea… y **se come el salto y la sangría** | parsea idéntico |
703
704El primero rompe ruidosamente. El segundo **parsea y miente**: el plan se lee bien y el comando que
705sale no es el que se escribió. El plan es el producto —se revisa, se versiona, se lleva a otro
706proveedor y se vuelve a correr—, así que uno que no vuelve a parsear no sirve para ninguna de las dos
707cosas para las que existe.
708
709Arreglado usando la cadena literal, y con un guardián que **escribe y relee** antes de tocar el
710disco: si el TOML generado no parsea, no se escribe nada. Cuesta milisegundos y convierte un fallo
711diferido —que aparecía cuando alguien iba a *ejecutar* el plan— en uno inmediato.
712
713### 4.0 quinquies · El CENTRO de traducción — pivote y plugins
714
715Cambiar de nginx a caddy no es cambiar un binario: es **reescribir la configuración**. `traducir.py`
716hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
717
718#### ⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE
719
720Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son **20 traductores**,
721y cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un
722formato nuevo cuesta uno o dos plugins y **estrena todos los pares de golpe**.
723
724```
725 formatos/nginx.py ─┐ ┌─► formatos/caddy.py
726 formatos/apache.py ─┼─► modelo pivote ───┤
727 (el próximo) ─┘ Config/Sitio/Ruta └─► (el próximo)
728```
729
730**Probado, no argumentado**: el lector de apache se agregó **sin tocar el escritor de Caddy**, y con
731eso `apache → caddy` funciona sin que exista ningún traductor apache→caddy. Las dos salidas las
732valida el caddy del propio corpus:
733
734```
735apache → caddy : Valid configuration
736nginx → caddy : Valid configuration
737```
738
739Un formato se agrega dejando un fichero en `formatos/` con `@lector("x")` y/o `@escritor("y")`. El
740centro los **descubre**: no hay una lista que editar, porque una lista mantenida a mano se
741desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
742habilita y cuántos plugins costaron.
743
744#### El pivote es uno POR FAMILIA
745
746Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el
747«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro
748**empareja sólo dentro de la familia**:
749
750| familia | modelo | lectores | escritores |
751|---|---|---|---|
752| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
753| `servicio` | `Servicio` / `Sistema` | systemd, openrc | arje |
754
755Un par cruzado se rechaza con un error, no con un intento:
756
757```
758$ traducir.py --from systemd --to caddy …
759✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
760 No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.
761```
762
763#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema
764
765El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
766real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio
767**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie
768vuelve a leer**. Sale como SIN-TRADUCIR, con su línea.
769
770Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce
771⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que
772lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del
773humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un
774servicio **menos confinado** que el original.
775
776Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
777semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
778tarjeta real de `sshd`**, más `_mudanza` de procedencia.
779
780#### `openrc → arje`: casi la mitad de los servicios NO son declaraciones
781
782Un unit de systemd se lee. Un servicio de OpenRC es un **script de shell** que puede hacer cualquier
783cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real de gioser —la máquina que esta
784mudanza borra— **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que
785valga: leer las variables y emitir una tarjeta produciría un servicio que arranca *otra cosa*.
786
787Por eso la regla va al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR y no se emite
788tarjeta**. Los números del corpus real cierran solos:
789
790```
791 ficheros en /etc/init.d : 121
792 no son openrc-run : 1
793 con start()/stop() propia : 60 → no se emite tarjeta
794 declarativos sin command= : 1 → no hay qué arrancar
795 ⇒ traducibles : 59 ← y el lector emitió exactamente 59
796```
797
798Lo otro que OpenRC obliga a ir a buscar es **`/etc/conf.d/<servicio>`**: el servicio está partido en
799dos ficheros y **los argumentos de verdad viven en el segundo**. A diferencia del `EnvironmentFile=`
800de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático **no lo
801dice el script** sino el enlace en `/etc/runlevels/`.
802
803#### Tres defectos que sólo aparecieron corriendo contra las 121 de verdad
804
805- **`name=` no es un identificador, es un rótulo**: en gioser vale `Aura Backend`, con espacio — que
806 se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
807 además es lo que usan `rc-update` y `/etc/runlevels`.
808- **Ids repetidos**: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos, y son
809 scripts de OpenRC válidos. Dos servicios con el mismo nombre dan **el mismo id determinista** ⇒ una
810 semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
811- **Procedencia cableada**: la tarjeta decía `"desde": "systemd"` viniendo de OpenRC. El campo que
812 existe para saber de dónde salió algo era el que mentía.
813
814#### Lector y escritor no opinan del mismo campo
815
816`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía
817dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector
818registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de
819un `0` que obliga a buscar a mano lo que ya se sabía.
820
821#### El acta viaja DENTRO del modelo
822
823Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que
824un **lector** no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor
825la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.
826
827Y el escritor puede **agregarle**: Caddy no tiene equivalente directo de `location ~`, así que en vez
828de emitir un `handle` de prefijo —que *se parece* y no es lo mismo— lo manda al acta. **Parecerse es
829peor que faltar.**
830
831#### Doctrina heredada de `soltar`/`paskaq` (tawasuyu)
832
833`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, `.mdb`). El dominio es otro
834—datos, no configuración— pero sus principios son exactamente los que hacía falta, y se adoptan tal
835cual:
836
837- **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá cada directiva no
838 entendida sale con su línea y su texto. **Un traductor que descarta en silencio te deja un servidor
839 sin una redirección o sin una regla de auth, y el sitio parece funcionar.**
840- **No adivinar en silencio**: toda heurística queda como decisión explícita, no aplicada de tapadillo.
841- **Procedencia total**: cada bloque del resultado dice de qué línea del original salió.
842- **Determinista**: mismo fichero, misma salida.
843
844Y lo que NO es: un traductor completo de nginx. Traduce el núcleo común y **declara todo lo demás**.
845*Uno honesto que cubre el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el
846100 % es una trampa.*
847
848#### ⚠ Validar con el binario real encontró dos bugs, y uno cambiaba el sentido
849
850Leer la salida no alcanzaba. Pasarla por `caddy validate` —con el caddy del propio corpus— destapó:
851
8521. **`ambiguous site definition`**: en nginx dos `server` con el mismo `server_name` se distinguen por
853 su `listen`; en Caddy, por el **esquema de la dirección**. Sin traducir eso, los bloques colisionan
854 y el fichero ni carga.
8552. **Y el grave**: un `if (...) { return 403; }` salía como `respond 403` **incondicional** — el sitio
856 entero devolviendo 403. La directiva vivía dentro de un bloque que yo mismo había declarado
857 intraducible, y se absorbía igual al bloque de afuera.
858
859 Eso es peor que la pérdida silenciosa que la doctrina de `soltar` advierte: **no pierde, CAMBIA el
860 sentido**. Ahora todo lo que está dentro de un bloque opaco sale como `SIN-TRADUCIR` con su motivo:
861 *«sacarla de ahí le cambiaría el sentido — dejaría de ser condicional»*.
862
863Con los dos arreglados, el caddy del corpus contesta **`Valid configuration`**.
864
865#### Y la receta de caddy, terminada
866
867El `caddy` del corpus era un **import de nix en crudo**: su cabecera decía «PUNTO DE PARTIDA, no
868final» y su `commit` era el TAG flotante `v2.11.4` — contra el ADR 0006, porque un tag se mueve y la
869receta pasa a construir otra cosa sin que el hash lo note. Anclado a `e2eee6a7…` con `takana pin`; el
870hash se movió a propósito. 77 MB estáticos, **0 intérpretes requeridos**, y un `file-server` de
871prueba contesta 200.
872
873Importaba terminarla por una razón medida: **el `caddy` de gioser no tiene dueño** — binario puesto a
874mano, sin paquete y sin receta — y se pierde con la máquina. Con la receta, el servidor nuevo lo
875declara y lo reconstruye.
876
877### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)*
878
879`aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]`
880
881**La regla que lo define: un paso que no se ejecutó NO se marca como hecho.** Un plan tiene pasos de
882dos naturalezas y confundirlas es la forma más fácil de dar una mudanza por buena sin estarlo:
883
884- **ejecutable** — tiene comandos de verdad (copiar, comprobar espacio);
885- **manual** — su `cmd` son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada
886 que correr, y **un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok»**.
887 Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.
888
889Acá un paso manual queda `pendiente-humano`, la corrida **no** se considera completa (sale con ≠0), y
890se confirma con `--hecho N` — que además **se niega** si el paso sí tenía comandos: «corrélo, no lo
891marques».
892
893**Se le cree a la verificación, no al exit code.** El `rsync` que llenó el disco devolvió **0** y
894dejó 1367 artefactos vacíos. Un paso pasa a `ok` sólo si su verificación pasa; si el comando salió
895bien y la verificación falló, queda `sospechoso`, que se informa como peor que un fallo.
896
897**Y las verificaciones están TIPADAS** (`cmd` / `humano`): «la columna Available debe ser > X» no es
898un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.
899
900**Reanudable**: el estado vive en `<plan>.estado.json`, escrito con temporal + `fsync` + `rename` —
901la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.
902
903#### ⚠ Y una verificación que era decorativa
904
905La primera versión del paso de datos verificaba así:
906
907```
908ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'
909```
910
911Imprimía el número de vacíos **y devolvía 0 igual**. En la primera corrida real dio «✓ verificado: 2»
912— donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.
913
914Ahora **compara**: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a
915propósito y con el control que tiene que pasar:
916
917```
918copia NO hecha → ficheros: origen=5 destino=0 ⇒ FALLA
919copia hecha → ficheros: origen=5 destino=5 ⇒ PASA
920```
921
922Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
923
9241. **Idempotente y reanudable.** Cada copia grande se cortó al menos una vez.
9252. **Verifica en DESTINO, no en origen.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
926 dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
9273. **Los servicios `no-declarado` se declaran**, no se replican a mano. Es la salida natural hacia
928 `arje-absorb`, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero **leyendo la
929 declaración, que es justo la que miente**: hay que alimentarlo con el censo.
9304. **Deja el servidor corriendo**, y lo prueba desde afuera: cada dominio `vivo` del plan tiene que
931 contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.
932
933## 5. La imagen, independiente del proveedor
934
935Lo que hoy la ata a Hetzner es poco y está medido:
936
937- **Arranque: ✅ RESUELTO (2026-09-11).** La imagen ahora arranca por **BIOS y por UEFI**, y es la
938 misma imagen. Layout: `p1` BIOS-boot · `p2` ESP FAT32 · `p3` `/` · `p4` estado · `p5` store.
939
940 **Un solo `grub.cfg`**: el `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan los dos a
941 `(hd0,gpt3)/boot/grub`, así que hay una sola línea de comando y un solo sitio donde editarla. Dos
942 imágenes hermanas obligarían a elegir por proveedor y a que divergieran — y ya divergían: la
943 hermana EFI documentaba etiquetas `takana-*` que su propio código no escribe (corregido).
944
945 **No se usa EFI-stub directo acá** (sí lo hace `install-image-efi.sh`, ADR 0010): el stub arrancado
946 por la ruta fallback recibe LoadOptions VACÍO, así que necesita la cmdline HORNEADA en el kernel, y
947 la de `linux-generic` sólo trae la consola — sin `root=`. Hornearla ataría la línea de comando al
948 ArtifactHash del kernel. Con GRUB EFI la cmdline vive en el `grub.cfg`, que es donde se edita.
949
950 **Verificado con la MISMA imagen en los dos firmwares**, hasta SSH:
951
952 | | UEFI (OVMF) | BIOS (SeaBIOS) |
953 |---|---|---|
954 | `/sys/firmware/efi` | **presente** | ausente |
955 | PID 1 | `arje-zero` | `arje-zero` |
956 | raíz · store | `sda3` · `sda5` | `sda3` · `sda5` |
957- **Red**: `netup` hace DHCPv4 y ya cubre el caso difícil (IP `/32` con la puerta fuera del prefijo,
958 que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores.
959- **Instalación**: `rescue → dd → reboot` no es de Hetzner: sirve en cualquier entorno de rescate con
960 SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor.
961- **Disco**: ya se resuelve solo — el store es la última partición y crece al disco entero en el
962 primer arranque.
963
964⇒ **la imagen está más cerca de ser portable que la herramienta de mudarse.** Por eso el orden es
965censo → 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:
| 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:
- Nada sin decidir se ejecuta. Una entrada vacía aborta: el silencio no es consentimiento.
- Lo que muere se dice por su nombre, con su tamaño, antes de borrar nada.
- El plan es exportable y re-ejecutable en otro proveedor. Es el artefacto que el usuario pidió.
- Un TUI para llenarlo, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: pelean con el
destino. planear.py --revisar muestra todo agrupado, con su recomendación y su razón:
| familia | motivo | ejemplos en gioser |
| hardware local | una caja remota no tiene ese dispositivo | bluetoothd ModemManager upowerd adb |
| escritorio o pantalla | un servidor no los tiene | waypipe |
| red del destino | pelearían: el destino la configura con su propio init | NetworkManager dhcpcd |
| lo provee el init del destino | no se muda | udevd dbus-daemon elogind polkitd agetty |
11 de 37 servicios de gioser caen ahí. Y el motivo no es cortesía: una recomendación sin razón
no se puede discutir, así que o se acepta a ciegas o se ignora entera — las dos son malas.
La recomendación DERIVADA, que es la más fuerte
Si el binario de un servicio vive en un árbol que no se muda, el servicio no va a poder arrancar
allá. Eso no hace falta saberlo: se calcula, cruzando el cmdline que el censo leyó de /proc
contra las decisiones de datos. Probado en los dos sentidos:
/mnt/vvv → muere : puerta-f6e393ff ⇒ NO mudar — «su binario vive en /mnt/vvv, que NO se muda»
/mnt/vvv → muda : puerta-f6e393ff ⇒ mudar — «corre y sirve 34221, y NADIE lo declara»
Por eso los DATOS se deciden primero: de ellos se deriva la recomendación de los servicios, y al
revés no se puede calcular. La revisión lo hace en ese orden y lo explica.
Y lo que NO se recomienda solo
Los datos salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina.
terapeuta.ec eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía
decidir eso por él.
Lo implementado
planear.py --censo <f> --target <host> [--decide] --out plan.toml
- Aborta con código 2 si queda algo sin decidir, listando qué. Probado.
--decide recorre lo pendiente y propone (vivo→muda, ya-mudado/backend-caido→muere,
no-declarado→muda). La sugerencia se imprime, nunca se aplica sola.
- Cada paso lleva su comando literal y su verificación, así que el plan se ejecuta a mano, línea
por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
- El preflight dimensiona con el tamaño de copia (hardlinks expandidos), no con
du; y si algún
tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo
cuando hace falta.
- El paso «muere» no borra nada: lista con nombre y tamaño lo que se pierde al apagar el origen.
⚠ El dato que vuelve accionable a un no-declarado: su invocación
Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es
cómo se está ejecutando ahora, y eso se lee de /proc. El censo captura cmdline y cwd de
cada servicio vivo, y el plan los emite en el paso:
# cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
# cwd : /mnt/vvv/tawasuyu/shared/tejido
# puertos: 34221, 44961
Ese servicio de gioser es un binario de target/debug/deps/ corriendo en producción, con dos
puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de
memoria. Ahora está escrito.
⚠ Se lee cmdline y cwd, nunca environ: el entorno suele traer tokens y contraseñas, y un
censo que se guarda en un fichero y se comparte no puede llevarlos.
4. Etapa 3 — declarar y aplicar (declarar.py implementado)
4.0 declarar.py — de «corre y nadie sabe cómo» a una Semilla que arranca
Es el paso que cierra la mudanza: copiar un binario no lo levanta al arrancar. Toma la
invocación que el censo leyó de /proc y emite seed.card.json.
Por qué no alcanza con arje-absorb: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y
está bien hecho, pero lee la declaración del init ajeno — justo la que miente. En gioser
rc-status daba stopped para cinco servicios vivos, y los no-declarado no aparecen en ninguna
declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan:
absorb para lo declarado, esto para lo que corre.
Medido sobre gioser: 37 de 37 servicios vivos declarados, cero fallos. La tarjeta de caddy sale
con su invocación real (/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile), que
es exactamente lo que faltaba para que no muriera en el próximo reinicio.
Tres reglas:
- 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.
- 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.
- IDs deterministas derivados del nombre ⇒ el mismo censo produce la misma semilla byte a
byte (verificado con
sha256), así que dos corridas se comparan con diff. Un fichero
generado que cambia en cada corrida no se puede revisar.
⚠ envp va vacío a propósito: el censo no lee /proc/<pid>/environ porque trae tokens. Un
servicio que dependa de variables necesita que alguien las escriba, y se avisa.
⚠ Un bug que costó el 70 % del censo y vale como lección: cmdline y cwd se leían en un solo
comando, y como readlink /proc/<pid>/cwd exige permiso de ptrace, en cualquier proceso de root el
comando entero salía ≠0 y se descartaba también el cmdline, que sí se podía leer. 26 de 37
servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se
pudo». Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.
4.0 bis · De dónde sale cada binario — «instalá el paquete» no es una instrucción
El paso de servicio decía «instalá el binario/paquete en el destino», que es lo que uno ya sabía. La
respuesta útil sale de cruzar dos hechos: quién posee el fichero en el ORIGEN (se lo pregunta el
censo al gestor de paquetes de esa máquina, en un solo lote) y si el corpus de takana tiene una
receta con ese nombre. Tres casos, tres trabajos distintos:
| clase | qué significa | qué hay que hacer |
receta-takana | takana ya sabe construirlo | instalarlo del repo firmado — no copiar el binario |
paquete-ajeno | lo trae el gestor del origen | escribir una receta, o qorpa (ADR 0015) |
suelto | nadie lo provee: puesto a mano | llevarlo con su entorno, o escribirle receta |
Medido sobre gioser (37 servicios vivos): 4 receta-takana · 4 paquete-ajeno · 11 suelto
· 7 sin binario legible. Los sueltos viven en /usr/local/bin o en un home — qdrant, matilda,
pacha-secretos, act_runner, shuma-gateway— y son los que se pierden al apagar el origen.
⚠ Y corrigió un error mío. En el SDD 28 escribí que gitea no tenía receta y que «es la que más
peso tiene». Las dos cosas eran falsas: recipes/gitea.toml existe y está sellada. Lo afirmé de
memoria; el programa fue a mirar. Es exactamente para esto que existe.
⚠ Y un fallo del propio clasificador, que vale como regla: calculaba la raíz del repo con un
dirname de menos, así que no encontraba ninguna receta y contestaba suelto a todo — incluido
caddy. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no
encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta.
4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto
Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché.
planear.py agrupa por familia funcional, muestra qué equivalentes tiene takana, y recomienda —
pero no «el mejor» en abstracto: un gusto disfrazado de dato es peor que no recomendar.
Los criterios son hechos comprobables, en este orden:
- 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.
- 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.
- Si no hay ninguno, se dice eso. No se sugiere «usá otro» cuando ese otro tampoco está.
Medido contra el catálogo real:
caddy → alternativas: **caddy**✓ nginx✗ apache✗ lighttpd✗ traefik✓ haproxy✗
recomendado: caddy — es el que YA corre y takana lo construye (sellado).
Cambiar obliga a REESCRIBIR la configuración entera.
nginx → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia
sí están sellados: caddy, traefik. Cambiar implica reescribir la
configuración: es una decisión, no un trámite.
redis → alternativas: redis✗ valkey✗ memcached✗
recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el
catálogo. Hay que escribir la receta del que se elija.
⚠ El dato que la recomendación destapa: de servidores web el corpus sólo tiene caddy y
traefik. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la
recomendación misma.
Si se elige un equivalente, queda en el censo (alternativa = "…") y el plan lo dice en su paso,
con la advertencia arriba de todo: la configuración del origen no sirve tal cual.
4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala
declarar.py --perfil-out emite además un [perfil.<label>] listo para docs/state/targets.toml.
Por qué un perfil y no una lista de install. Mudar servicio por servicio a una caja viva es lo
que uno haría en cualquier distro, y en takana va contra el diseño: el software de una máquina se
declara y viene en la imagen, reproducible y firmado. Un servidor armado a fuerza de
instalaciones sueltas no se puede volver a construir — que es exactamente el problema que esta
mudanza existe para no repetir: el caddy de gioser no tiene dueño ni receta, y se pierde con la
máquina.
Y hay un impedimento medido, no estético: takana install reproduce desde fuente, y eso exige el
LAB entero en el cliente (SDD 28 §5.4). Una caja de destino no lo
tiene. El perfil esquiva el problema por el lado correcto: el software entra al armar la imagen,
en el hub, que sí tiene lab.
Generado desde el censo de gioser:
[perfil.gioser-mudado]
descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
hereda = ["base"]
paquetes = [
"caddy",
"gitea",
"python3", # python3, uvicorn
]
Verificado que el instrumental lo acepta: scripts/targets.py gioser-mudado expande a 33 raíces.
⚠ Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre suelto,
paquete-ajeno y desconocido. Un perfil que se calla lo que le falta sale N/N describiendo un
servidor incompleto — la lección de foot en escritorio-sway, que llegó a 121/121 sellado y sin
emulador de terminal.
4.1.bis Los tres artefactos declarativos
Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir tres documentos que
reconstruyen el servidor desde cero:
| artefacto | qué declara | lo consume |
[perfil.<x>] → targets.toml | qué software lleva | servidor-image.sh al armar la imagen |
seed.card.json | qué corre y cómo | arje-zero al arrancar |
plan.toml | qué datos y en qué orden | aplicar.py, o un humano línea por línea |
Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
4.0 quindecies · ⚠ 26 repositorios existen SÓLO en la máquina que se va a borrar
Es el hallazgo más grave de este frente, y va por delante de todo lo demás en el plan. Buscando cómo
escribir la receta de tawasuyu apareció que su único remoto es el gitea de gioser
(ssh://gitea@git.tawasuyu.net:2345/…, y git.tawasuyu.net resuelve a 204.168.193.248, que es
gioser). Al mirar el resto: 28 repositorios con remoto en esta máquina, 26 sin ninguna copia
fuera. Sólo takana y llimphi-standalone tienen espejo externo.
Apagar el origen no borra unos servicios: borra el código con el que se volverían a construir. Y
los clones de trabajo están en el mismo disco, que también muere. Entre los 26 está tawasuyu, que
produce 10 de los 13 binarios que nadie más provee — la dependencia circular completa.
No se ve desde ninguna otra parte del censo: un repo no es un proceso, ni un puerto, ni un dominio.
Se descubre cuando ya no hay de dónde sacarlo.
El censo lo mira ahora ([[repo]]), y lo que reporta no es «tiene remoto» sino si alguno de sus
remotos NO es esta máquina — un remoto que apunta afuera es la prueba de que el código sobrevive.
En el plan es el paso 1, por delante del rescate de binarios: aquello pierde un servicio, esto
pierde la posibilidad de reconstruirlo. El propio takana ya resuelve esto con pushurl doble
(scripts/espejo-setup.sh), así que el arreglo está escrito.
4.0 quaterdecies · Los 13 binarios sueltos son casi un solo árbol
De los 13 servicios cuyo binario nadie provee —los que se pierden al apagar el origen— diez
salen del mismo repositorio, tawasuyu (hoy en bad13117c):
| binario | paquete del workspace |
matilda | matilda |
pacha | pacha-cli |
pacha-secretos | pacha-secretos |
sandokan-watch | sandokan-seguridad-core |
shuma-daemon · shuma-gateway | homónimos |
tejido | tejido |
tupu | tupu-cli |
willay-crosscheck | willay-cruce |
willay-daemon | willay-daemon |
(Los otros tres son ajenos: act_runner, adb y el puerta-… de target/debug.)
O sea que una receta cubre diez servicios, y el trabajo de escribirla se paga diez veces.
El tamaño real es una quinta parte del que aparenta
El Cargo.lock del workspace tiene 2823 crates, pero eso es el workspace entero —con su stack
gráfico, audio y Android—. El subárbol que esos diez binarios necesitan de verdad son 524. Y de
todos ellos, sólo cinco traen C:
aws-lc-sys ring ← criptografía: compilan C/asm, necesitan cmake
dirs-sys inotify-sys netlink-sys ← bindings puros, sin librería externa
aws-lc-sys y ring son los conocidos de esta cadena; los otros tres no cuestan nada.
⚠ Lo que hay que decidir antes de escribirla: los dos pines no coinciden
tawasuyu/rust-toolchain.toml fija 1.96.0, y lo argumenta en el propio fichero: una distro que
promete builds deterministas y atestación firmada no puede tener el compilador flotando. El lab de
takana trae 1.97.0, y es rodante por diseño — lab-toolchain.lock detecta la deriva, no la
evita.
Así que una receta takana de tawasuyu compilaría con un rustc distinto del que el propio tawasuyu
exige. No es un detalle de estilo: el binario resultante no sería el mismo que está corriendo, y
rompería el contrato de atestación de tawasuyu. Tres salidas, y es una decisión, no un trámite:
- Pinear 1.96.0 para esta receta — honra el contrato de tawasuyu; hay que meter ese rustc en el
lab, como ya se hace con las 84 recetas que pinean
zig_version=0.13.0.
- Mover el pin de tawasuyu a 1.97.0 — decisión del otro frente, no de éste.
- Llevar los binarios tal cual — es lo que el plan hace hoy, y significa que no reproducen.
⚠ Y no se construye en el origen: 4 núcleos y ~2 G disponibles. Eso va al worker
(dev.gioser.net, 6 núcleos / 16 G), que además es gratis.
4.0 terdecies · El cruce que nadie hacía: un dominio que se muda sin su backend
La config del servidor declara de qué servicios depende — cada reverse_proxy y cada
php_fastcgi apuntan a algo. Cruzar eso contra la decisión tomada sobre cada servicio caza un fallo
que ninguna otra parte puede ver:
el dominio se muda y el servicio que lo sirve está marcado muere.
Las dos decisiones son razonables por separado —el dominio se usa, el servicio parecía
prescindible— y juntas dejan el sitio nuevo devolviendo 502. No lo ve el DNS, no lo ven los procesos,
y no lo ve quien decide de a una entrada por vez, que es como se decide.
También caza el revés, y en gioser eso es lo que apareció: dos sitios proxean a un puerto que
ningún servicio censado sirve, o sea que ya devuelven 502 hoy, en el origen.
mail.sigma.gioser.net ↳ localhost:9000 nadie escucha ahí
api.gioser.net ↳ localhost:8000 nadie escucha ahí
Confirmado aparte con ss -lntp: efectivamente no hay nada en 8000 ni en 9000.
Y un falso positivo que hubo que matar primero
La primera corrida acusaba a sergio.gioser.net de mudarse dejando atrás a shuma. Era falso, y la
causa estaba en el censo: los puertos se adjudicaban por prefijo de nombre
(proc.startswith(name[:15])), así que un shuma declarado-muerto se quedaba con el 7378 — que
lo escucha shuma-gateway, comprobado con ss -lntp. Un declarado-muerto por definición no puede
estar escuchando.
Ahora los puertos se atan por PID, que es lo único sin ambigüedad. El puerto es la señal más
fuerte de que algo sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de
él — y aquí se derivó un guardián acusando al inocente, que es la forma más rápida de que un guardián
se deje de leer.
4.0 duodecies · La cuenta de lo que falta, bien hecha
origen_binario buscaba la receta por el nombre del servicio, y ése casi nunca es el nombre del
paquete: sshd lo trae openssh, crond lo trae cronie. El censo ya le preguntó al gestor de
paquetes quién posee cada binario, así que ese nombre también se prueba. Con eso el perfil pasó de
4 recetas a 7, y la clasificación de los 27 servicios quedó así:
receta-takana 9 paquete-ajeno 13
suelto 9 interprete 4 borrado 4
La trampa: el intérprete NO es el programa
openclaw lo posee el paquete nodejs, y fail2ban-server, glances y uvicorn los posee
python. Contarlos como cubiertos porque existe recipes/nodejs.toml haría salir el perfil N/N
describiendo un servidor al que le faltan cuatro programas. Tienen clase propia, interprete, y
cuentan las dos cosas a la vez, porque las dos son ciertas:
- su runtime entra al perfil —
openclaw necesita nodejs en la imagen pase lo que pase;
- el servicio sigue listado como no cubierto, con su motivo.
Meterlo sólo en las raíces haría un perfil que miente; dejarlo sólo en los faltantes armaría una
imagen sin runtime, y el programa, cuando llegue, no arrancaría.
(Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix
empaqueta python, el catálogo tiene python3.toml. Sin ese alias, tres servicios decían «hace
falta una receta takana» teniendo el runtime sellado — dos trabajos muy distintos.)
Sellada y sin sellar no son lo mismo
El perfil las listaba igual. Una sellada se instala del repo firmado; una sin sellar hay que
construirla, y eso puede ser una tarde. Salió con un caso real: recipes/qdrant.toml entró al
catálogo desde otro frente mientras se escribía esto y no tiene artefacto. Ahora el perfil lo
marca en la línea y lo resume al pie.
4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA
El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir:
/proc/<pid>/exe termina en (deleted) para cuatro servicios de gioser. El fichero ya no está
en disco; sólo vive el inodo que sostiene su proceso.
Y en tres de los cuatro hay ahora otro fichero en la misma ruta, de distinto tamaño:
| servicio | corriendo | en su ruta hoy |
tejido | 12 750 368 B | 13 815 864 B |
shuma-gateway | 8 431 896 B | 10 363 504 B |
pacha-secretos | 8 634 240 B | 8 647 456 B |
puerta-f6e393ff… | 197 262 440 B | no hay nada |
Copiar la ruta no falla: muda otra cosa, y el servicio nuevo no es el que estaba andando. Es el
modo de fallo más caro que hay en este documento — todo verde, todo distinto.
Se recuperan leyendo /proc/<pid>/exe, y sólo mientras el proceso viva. En una mudanza que
termina borrando el origen, un reinicio de gioser antes de este paso pierde esos binarios para
siempre. Por eso el rescate va antes del preflight: es el único paso del plan que puede volverse
imposible mientras se piensa el resto.
Su verificación compara tamaños contra el que corre, y no por celo: un cat de un /proc que ya
no existe crea un fichero vacío y devuelve 0. Probado en los dos sentidos — el paso real pasa, y
con un fichero vacío a propósito el guardián dice FALTA tejido y sale 1.
Los cuatro ya están rescatados en work/mudanza/rescate/, con su SHA256SUMS.
4.0 decies · Leer la config del servidor: un fósil se detecta SIN tocar la red
La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el
fichero de configuración, y leerlo no toca al origen — ni una petición, ni riesgo de fail2ban.
Para leerlo se agregó un lector de Caddy al centro (formatos/caddy.py, que ya tenía el
escritor), y el censo lo usa en vez de tener su propio parser a medias: los lectores de nginx y
apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note.
El resultado inmediato, sobre el Caddyfile real de gioser — cinco sitios apuntan a un root que no
existe, y son justo los que devolvían los 502 que en su día hicieron que el censo se baneara a sí
mismo al sondearlos por HTTP:
aura.gioser.net → /var/www/aura_frontend NO EXISTE
sigma.gioser.net → /var/www/sigma/frontend NO EXISTE
summa.gioser.net → /var/www/summa/frontend NO EXISTE
kosmofono.gioser.net → /var/www/kosmofono/frontend NO EXISTE
dev.summa.gioser.net → /var/www/summa-dev/frontend NO EXISTE
Es evidencia más fuerte que el DNS: no hay nada que servir, devuelve 502 resuelva donde resuelva.
Y sirve para lo contrario, que es donde el aviso hacía daño
Un directorio que la config sí referencia no es un huérfano, diga lo que diga su nombre. Sin
esto, el plan marcaba /var/www/git-tawasuyu como «nadie lo recuerda» estando servido — y ese aviso,
aplicado, tira git.tawasuyu.net.
La primera versión del lector seguía fallando en eso, y el motivo es instructivo: el root de ese
sitio vive dentro de un handle, y yo saltaba los bloques anidados enteros por no saber
modelarlos. Que el pivote no sepa modelar algo no es razón para no verlo: ahora el bloque
sigue marcado SIN-TRADUCIR, pero sus root y reverse_proxy se leen. Con eso aparecieron dos raíces
ausentes más, que también estaban anidadas.
Falsos sitios: una lista negra no alcanzaba
Detectar la cabecera de un sitio descartando directivas conocidas (handle {, route {, @bot {)
dejaba pasar log { output file … { y el snippet (acceso) { — dos dominios inventados que el
censo habría puesto a decidir. La regla que aguanta es positiva: todos los tokens de la cabecera
tienen que parecer una dirección.
4.0 nonies · La cadena entera, y el preflight que medía contra el sistema de ficheros equivocado
censar → planear → aplicar corrió de punta a punta contra gioser: 59 pasos, 20 ejecutables y 39
manuales, correctamente separados — el aplicador marca los manuales pendiente-humano y la corrida
no se considera completa.
Y ahí el ensayo en seco destapó lo que ninguna prueba de juguete iba a mostrar: 33,6 G de datos
contra una raíz de 3,5 G libres.
/dev/root 3,5 G libres /
/dev/sdb 19,0 G libres /store
/dev/sda4 66,2 G libres /work
Dos cosas estaban mal a la vez:
- El plan copiaba ruta → misma ruta, sin forma de decir dónde cae cada árbol en el destino. Ahora
[[datos]] tiene destino (vacío = la misma ruta), y el fallo que evita es caro: aparecía a mitad
de un rsync de 22 G.
- El preflight sumaba todo y lo comparaba contra
/. Eso acierta por casualidad mientras todo
caiga en /, y da un veredicto completamente falso en cuanto una ruta va a otro montaje — en
las dos direcciones. Ahora mide por sistema de ficheros, agrupando los destinos, y el veredicto
lo saca el propio comando en vez de un humano leyendo una columna:
✗ / necesita 34386 MiB · libres 3596 ⇐ /var/www /var/lib /srv /opt /home
NO ENTRA en /: faltan 30790 MiB (exit 1)
✓ /work necesita 34386 MiB · libres 66192 ⇐ /work/var/www …/work/home (exit 0)
Los dos controles corridos contra la caja de verdad: el negativo falla y el positivo pasa.
4.0 octies · /proc es un formato de origen más — y había DOS emisores de tarjetas
declarar.py tenía su propio molde de card de arje, y formatos/arje.py el suyo. Dos emisores del
mismo formato es exactamente el N×M que el pivote existe para evitar, y no quedó en teoría: cada
copia tenía un campo mal de la raíz, y ninguno de los dos el mismo.
| campo de la raíz | declarar.py | formatos/arje.py | la semilla REAL del producto |
provides | ["Spawn","Journal"] ✓ | [] ✗ | ["Spawn","Journal"] |
supervision | Restart{…} ✗ | "OneShot" ✓ | "OneShot" |
Las dos consecuencias son concretas: sin Spawn/Journal las hijas no tienen quién las lance ni
dónde escribir; y una card Virtual con Restart le pide a arje que respawnee algo que nunca corrió.
Ahora hay un lector proc —el censo es un formato de origen, igual que systemd u OpenRC— y el
escritor arje es el único que emite tarjetas. declarar.py queda con lo suyo: el perfil.
familia «servicio»: systemd ─┐
openrc ─┼─► Servicio/Sistema ──► arje
proc ─┘
Los tres se complementan y ninguno solo alcanza: systemd y OpenRC leen lo declarado, que es lo
que miente —rc-status daba stopped para cinco servicios vivos—, y proc lee lo que corre,
que es lo único donde aparecen los 15 no-declarado.
Una semilla vacía parecería un éxito
Si ningún servicio tiene decision = "muda", el lector proc no devuelve cero tarjetas en
silencio: lo dice y sale ≠0. Es el mismo modo de fallo que un artefacto vacío en el store — un
ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
El acta se estaba ahogando en su propio ruido
La primera versión anotaba el envp vacío una vez por servicio: 26 líneas idénticas que tapaban
los dos hallazgos que sí eran de un servicio concreto —shuma-daemon corre como sergio, y los que
no tienen línea de comando—. La limitación es del lector y vale igual para todos, así que va una
entrada nombrando a los 26. Un acta donde casi todo es la misma línea se deja de leer, y entonces
no queda ningún acta. Lo mismo con la supervisión. Y al revés: las entradas que sí son por servicio
ahora lo nombran (gitea: cwd=/var/lib/gitea), porque cuatro WorkingDirectory=/home/sergio
sin nombre obligan a buscar cuál es cuál.
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
Tres nombres equivocados, y los tres llegaban al plan
El censo nombraba los servicios por comm, que viene del kernel. De ahí salieron tres errores de
clasificación —no cosméticos: ese nombre es el que el plan mete en --in /etc/init.d/<nombre> y el
que va de label en la tarjeta de arje.
comm está capado a 15 caracteres. willay-crosscheck llegaba como willay-crossche, y con
ese nombre no casaba contra su declaración ⇒ figuraba como no-declarado teniendo su tarjeta
en la semilla de arje. La línea de comando trae el nombre entero.
supervise-daemon no es un servicio, es un envoltorio. Los cinco de gioser se fundían en UNA
entrada llamada supervise-daemo; y del otro lado, dbus, metalog, dhcpcd, squid y
shuma-daemon salían como declarado-muerto estando vivos — el mismo agujero que el censo
existe para tapar, entrando por la otra puerta. El nombre real es su argv[1]; el comando real
es el último token antes del -- suelto.
head -15 con ppid==1 era un resto de tubería reparentado, contado como servicio
no-declarado. Va a descartados, que se imprimen: un huérfano es un hallazgo, no basura.
«No se pudo leer su binario» eran dos cosas distintas
7 de 38 servicios salían desconocido. La causa no era una: muchos demonios reescriben su
argv[0] (sshd: /usr/bin/sshd [listener], php-fpm: master process (…)), así que la línea de
comando no dice cuál es el binario — pero /proc/<pid>/exe sí, y necesita ser dueño o root. Con el
censo corriendo como root, los 7 se resuelven:
uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
Así que el censo ahora dice cuál de las dos cosas pasó: «repetilo con sudo» y «averigualo a mano»
son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
Y desenvolver al supervisor destapó un dato que se estaba perdiendo: shuma-daemon corre con
--user sergio. La tarjeta de arje no tiene campo de usuario, así que sin eso a la vista un
servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión.
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 \ + salto | parsea… y se come el salto y la sangría | parsea 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:
| familia | modelo | lectores | escritores |
web | Sitio / Ruta / Accion | nginx, apache | caddy |
servicio | Servicio / Sistema | systemd, openrc | arje |
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
name= no es un identificador, es un rótulo: en gioser vale Aura Backend, con espacio — que
se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
además es lo que usan rc-update y /etc/runlevels.
- Ids repetidos:
/etc/init.d guarda copias *.bak-FECHA junto a los servicios vivos, y son
scripts de OpenRC válidos. Dos servicios con el mismo nombre dan el mismo id determinista ⇒ una
semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
- Procedencia cableada: la tarjeta decía
"desde": "systemd" viniendo de OpenRC. El campo que
existe para saber de dónde salió algo era el que mentía.
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:
- Elisión honesta. «Lo que no se pudo leer se reporta con offset y motivo.» Acá cada directiva no
entendida sale con su línea y su texto. Un traductor que descarta en silencio te deja un servidor
sin una redirección o sin una regla de auth, y el sitio parece funcionar.
- No adivinar en silencio: toda heurística queda como decisión explícita, no aplicada de tapadillo.
- Procedencia total: cada bloque del resultado dice de qué línea del original salió.
- Determinista: mismo fichero, misma salida.
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ó:
-
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.
-
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:
- ejecutable — tiene comandos de verdad (copiar, comprobar espacio);
- manual — su
cmd son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada
que correr, y un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok».
Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.
Acá un paso manual queda pendiente-humano, la corrida no se considera completa (sale con ≠0), y
se confirma con --hecho N — que además se niega si el paso sí tenía comandos: «corrélo, no lo
marques».
Se le cree a la verificación, no al exit code. El rsync que llenó el disco devolvió 0 y
dejó 1367 artefactos vacíos. Un paso pasa a ok sólo si su verificación pasa; si el comando salió
bien y la verificación falló, queda sospechoso, que se informa como peor que un fallo.
Y las verificaciones están TIPADAS (cmd / humano): «la columna Available debe ser > X» no es
un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.
Reanudable: el estado vive en <plan>.estado.json, escrito con temporal + fsync + rename —
la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.
⚠ Y una verificación que era decorativa
La primera versión del paso de datos verificaba así:
ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'
Imprimía el número de vacíos y devolvía 0 igual. En la primera corrida real dio «✓ verificado: 2»
— donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.
Ahora compara: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a
propósito y con el control que tiene que pasar:
copia NO hecha → ficheros: origen=5 destino=0 ⇒ FALLA
copia hecha → ficheros: origen=5 destino=5 ⇒ PASA
Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
- Idempotente y reanudable. Cada copia grande se cortó al menos una vez.
- 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.
- 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.
- 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.