gioser

sergio/takana

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

TOML 1430Shell 145Markdown 87Rust 852973 Sutichaykuna

⎇ 02c529b8a50c0199bfea6c7b7d3f0d3179774463 docs/29-mudanza.md Markdown · 1168 siq'ikuna · 65.9 KB Ñawpa willay ch'uya

la batuta: los tres repos al lado y los sitios publicándose solos 4b52acae0 · Sergio ·

1# SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta
2
3Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28:
4
5> «quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación
6> mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro
7> servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para
8> reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay,
9> qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo
10> corriendo. Todo automático, sin IA.»
11
12## 0. La corrección que ordena todo
13
14El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso
15es configurar un servidor, no construir un producto. **caddy no es un hueco de la imagen: es una
16instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.**
17
18La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios
19sirven, qué datos pesan— **se censa en la máquina vieja**, se confirma con el usuario, y se ejecuta.
20
21Y hay una consecuencia de método que conviene decir fuerte: **todo lo que hice a mano en el SDD 28 es
22la especificación de este programa.** Censar, cruzar declaración contra realidad, separar vivo de
23fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo
24«¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. **No hace falta IA: hace
25falta que esté escrito.**
26
27## 1. Las tres etapas
28
29```
30   CENSO                  PLAN                      APLICACIÓN
31   censar.py    →    <censo>.toml (decisiones)  →   ejecutar, idempotente
32   sólo lee          revisable · exportable         deja el server corriendo
33```
34
35El fichero del medio es el producto real: **es «los pasos exportables»**. Se revisa, se versiona, se
36lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que
37se hizo.
38
39## 2. Etapa 1 — el censo *(implementado: `scripts/mudanza/censar.py`)*
40
41**La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO**, y reportar tres clases:
42
43| clase | qué es | por qué importa |
44|---|---|---|
45| `vivo` | declarado **y** corriendo | candidato a mudarse |
46| `declarado-muerto` | declarado, sin proceso | candidato a morir |
47| **`no-declarado`** | **corriendo, sin declaración** | **el trabajo real: nadie sabe cómo levantarlo** |
48
49No es teoría — cada regla salió de un error medido:
50
51- **`rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los cinco estaban
52  vivos.** Un censo que lea el init y no `/proc` produce un plan que no arranca.
53- **De 29 dominios del Caddyfile, sólo 10 están vivos**: 13 no tienen DNS, 2 dan 502, y **4 ya
54  resuelven a otra máquina** — su bloque de config es fósil aunque el sitio funcione. Por eso el
55  censo compara la IP del DNS contra las IPs de la máquina: sin eso, `ya-mudado` se lee como `vivo`.
56- **El caddy de la caja nueva murió en un reinicio y nadie lo notó**, porque estaba arrancado a mano
57  y no declarado. Ésa es la clase `no-declarado`, y es la que duele.
58- **`du -sh` no dice cuánto ocupa COPIAR** un árbol con hardlinks (medido: 60 G vs 85 G). El censo
59  reporta los dos números.
60- **Los nombres no coinciden entre init y proceso**: `act-runner`/`act_runner`, `crond`/`cronie`,
61  `dbus-daemon`/`dbus`, `fail2ban-server`/`fail2ban`. Sin normalizar, el mismo servicio sale a la vez
62  como `no-declarado` y `declarado-muerto`: las dos clases que más importan, las dos mal.
63- **Lo descartado se cuenta, no se esconde.** Un proceso con `ppid==1` que no es un servicio suele
64  ser un huérfano, y eso es un hallazgo (los `firefox` fugados que dejaron la granja sin compilar
65  hora y media fueron exactamente eso).
66
67⚠ **Y el criterio que decide si el censo sirve**: un guardián con hallazgos falsos se ignora entero.
68La primera versión daba 28 `no-declarado` con ruido; tras normalizar nombres da 18, y son reales
69(`matilda`, `pacha`, `puerta-…`, `adb`, `ModemManager`). Afinarlo no es cosmética: es la diferencia
70entre que alguien lo lea o no.
71
72## 3. Etapa 2 — el plan *(implementado: `scripts/mudanza/planear.py`)*
73
74El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las decisiones puestas
75(`muda` / `muere`), más el orden y las dependencias. Requisitos:
76
771. **Nada sin decidir se ejecuta.** Una entrada vacía aborta: el silencio no es consentimiento.
782. **Lo que muere se dice por su nombre**, con su tamaño, antes de borrar nada.
793. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió.
804. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
81
82### 3 bis · El tercer camino: **mudar · respaldar · abandonar** *(pedido del usuario, 2026-09-16)*
83
84> «la herramienta debería mostrarme una lista de cada cosa con una corta descripción y la opción de
85> yo elegir mudar, mover a un directorio para respaldar ese directorio, o abandonarlo»
86
87Las tres partes de esa frase eran tres huecos distintos, y los tres se cerraron.
88
89#### 1. «cada cosa» — el censo medía RAÍCES, no cosas
90
91`/home` era **una** entrada de 25 G y **una** decisión. Adentro hay cosas de dueños distintos: repos
92con remoto, SDKs que se re-bajan, 4,3 G de caché, y trabajo que no está en ningún otro lado. Con una
93sola decisión sólo se puede acertar a lo bruto — mudar 25 G para salvar 200 M, o abandonar 25 G y
94perder ese 200 M. **La granularidad de la lista es la granularidad de lo que se puede decidir.**
95
96`censar.py` ahora emite un renglón por cosa (169 en gioser contra 5), un nivel hacia adentro de cada
97raíz y **dos** en `/home`, porque su primer nivel son usuarios y la decisión no es por usuario.
98Queda `--solo-totales` para la vista vieja, que es la que dimensiona la mudanza y la que mira el
99preflight: las dos vistas viajan en el censo, `datos` y `datos_raiz`.
100
101⚠ Y una lección repetida: el primer intento mandaba las ~110 sondas en **una** llamada, se pasaba
102del timeout, y como el parser no encontraba ninguna línea devolvía **lista vacía** — «no hay datos»
103en vez de «no pude medirlos». Va en tandas de 12 y **una tanda que falla se nombra**.
104
105#### 2. «una corta descripción» — hechos, nunca un juicio
106
107De cada cosa se reporta lo que se puede comprobar, lo más decisivo primero:
108
109```
110/home/sergio/gioser-web     428M  LO SIRVE gioser.net, www.gioser.net · repo git → gitea@git.gioser.net:…
111/home/sergio/android-sdk   1019M  lo usa adb · lo más nuevo: 2026-06-12
112/home/sergio/accordion       37M  repo git → github.com/GotJimmy/accordion · CON CAMBIOS SIN COMMITEAR
113/home/sergio/.cache         4.3G  lo más nuevo: 2026-09-16
114/home/sergio/humanoid       200M  sin señales: ni git, ni servido, ni usado por nada vivo
115```
116
117Nada de «parece un proyecto viejo». Es repo o no lo es; lo sirve la config del servidor web o no; lo
118usa un proceso vivo o no; adentro hay `node_modules`/`target`/`venv` o no. **«sin señales» también
119es un hecho** — y es el que dice dónde hay que mirar a mano.
120
121#### 3. «yo elegir» entre tres — y qué recomienda la máquina
122
123`DECISIONES` pasa de `("muda","muere")` a `("muda","respalda","muere")`. El camino que faltaba es el
124que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero perder*. Con
125dos opciones, todo lo dudoso se marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que
126nadie iba a usar.
127
128La regla de fondo **no cambió**: qué datos VALEN no se deduce de la máquina, y la mayoría sale sin
129recomendación a propósito. Pero hay cuatro cosas que la máquina sí sabe, y callarlas obliga a mirar
130169 entradas a ojo:
131
132| hecho | recomienda | por qué |
133|---|---|---|
134| lo sirve el servidor web · lo usa un proceso vivo | `muda` | si no viaja, ese sitio o ese servicio deja de existir |
135| es caché o estado transitorio (`.cache`, `.gradle`, `node_modules`…) | `muere` | el destino lo vuelve a crear solo |
136| repo git **limpio** con remoto | `muere` | se vuelve a clonar |
137| repo **sin** remoto, o **con cambios sin commitear** | `respalda` | esa parte existe únicamente en este disco |
138
139⚠ **Y el cruce que evita el peor error**: un remoto que apunta a ESTA MISMA máquina no es un
140respaldo, es el mismo disco. Sin eso, «tiene remoto ⇒ se vuelve a clonar» mandaría a abandonar un
141repo cuyo único ejemplar se borra con el servidor. Medido en gioser: el remoto de
142`/mnt/vvv/humanoid` es `ssh://git@127.0.0.1:2345/…`, el gitea de la propia caja.
143
144#### 4. Y las decisiones ahora se GUARDAN
145
146`--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal y las 169
147elecciones se perdían. El censo **es** la interfaz (§3), así que ahora se vuelve a escribir con las
148decisiones puestas —temporal + `fsync` + `rename`, copia `.bak`, y **releído antes de pisar nada**—
149y se aborta si el fichero regenerado perdió alguna. Decidir 169 cosas siempre se hace a medias; que
150sobreviva a medias es parte del diseño, no una comodidad.
151
152Para que eso fuera posible hubo que hacer el emisor del censo **idempotente**: escribía
153`decision = ""` fijo al final de cada entrada, así que re-emitir un censo decidido producía la clave
154dos veces y el TOML dejaba de parsear. O sea que la herramienta no podía volver a escribir su propio
155fichero.
156
157#### 5. El paso `respaldo`, y los dos bugs que encontró el guardián de dos sentidos
158
159Lo marcado `respalda` va a un **corral** —por defecto el Storage Box, leído de
160`respaldo-storagebox.sh` para que no haya una segunda dirección escrita a mano— con el nombre
161aplanado (`/home/sergio/humanoid` → `home-sergio-humanoid`), y **no se instala en el destino**: la
162caja nueva no se entera de que existe. Sin corral configurado el plan **aborta**: un `rsync` a
163ninguna parte devuelve 0 y se lee igual que un respaldo.
164
165Probar el paso emitido con rotura a propósito **y** con el control que tiene que pasar destapó dos
166cosas que la lectura no veía:
167
1681. **`--mkpath` no es opcional**: sin él rsync muere con `mkdir … failed` en cuanto el corral tiene
169   un nivel que no existe — y el primer respaldo siempre lo tiene.
1702. **Un FICHERO no lleva barra final.** Con el censo detallado entran ficheros sueltos
171   (`mapas.tgz`, `gu.png`, un `.docx`), y `rsync fichero/ destino/` no es «copiá el fichero»: es un
172   error. El censo ya dice cuál es cuál; el paso de `datos` tenía el mismo defecto latente.
173
174La verificación no usa `du` ni `find`, sino **rsync en seco contra el destino real**: el corral
175puede ser un Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías, y cuyo `du`
176además **comprime** (1,3 G subidos se ven como 576 M — un número que parece copia truncada y no lo
177es). Lo que se cuenta son los ficheros que a rsync todavía le faltarían.
178
179#### 6. De paso: el aplicador en seco decía «✅ todos los pasos ejecutados y verificados»
180
181Con `--dry-run` no se ejecuta nada, y el resumen de siempre contaba cero ejecutados igual que cero
182fallidos. Ahora dice **«EN SECO: N pasos mostrados, NINGUNO ejecutado y NINGUNO verificado»**. Es la
183misma regla que el resto del aplicador: *un paso que no se ejecutó no se marca como hecho*.
184
185### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
186
187Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
188bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: **pelean** con el
189destino. `planear.py --revisar` muestra todo agrupado, con su recomendación **y su razón**:
190
191| familia | motivo | ejemplos en gioser |
192|---|---|---|
193| hardware local | una caja remota no tiene ese dispositivo | `bluetoothd` `ModemManager` `upowerd` `adb` |
194| escritorio o pantalla | un servidor no los tiene | `waypipe` |
195| red del destino | **pelearían**: el destino la configura con su propio init | `NetworkManager` `dhcpcd` |
196| lo provee el init del destino | no se muda | `udevd` `dbus-daemon` `elogind` `polkitd` `agetty` |
197
198**11 de 37 servicios de gioser caen ahí.** Y el motivo no es cortesía: *una recomendación sin razón
199no se puede discutir, así que o se acepta a ciegas o se ignora entera* — las dos son malas.
200
201#### La recomendación DERIVADA, que es la más fuerte
202
203Si el binario de un servicio vive en un árbol que **no se muda**, el servicio no va a poder arrancar
204allá. Eso no hace falta saberlo: se calcula, cruzando el `cmdline` que el censo leyó de `/proc`
205contra las decisiones de datos. Probado en los dos sentidos:
206
207```
208/mnt/vvv → muere :  puerta-f6e393ff  ⇒  NO mudar — «su binario vive en /mnt/vvv, que NO se muda»
209/mnt/vvv → muda  :  puerta-f6e393ff  ⇒  mudar    — «corre y sirve 34221, y NADIE lo declara»
210```
211
212**Por eso los DATOS se deciden primero**: de ellos se deriva la recomendación de los servicios, y al
213revés no se puede calcular. La revisión lo hace en ese orden y lo explica.
214
215#### Y lo que NO se recomienda solo
216
217Los **datos** salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina.
218`terapeuta.ec` eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía
219decidir eso por él.
220
221### Lo implementado
222
223`planear.py --censo <f> --target <host> [--decide] --out plan.toml`
224
225- **Aborta con código 2** si queda algo sin decidir, listando qué. Probado.
226- `--decide` recorre lo pendiente y propone (`vivo`→muda, `ya-mudado`/`backend-caido`→muere,
227  `no-declarado`→muda). **La sugerencia se imprime, nunca se aplica sola.**
228- **Cada paso lleva su comando literal y su verificación**, así que el plan se ejecuta a mano, línea
229  por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
230- El preflight dimensiona con el **tamaño de copia** (hardlinks expandidos), no con `du`; y si algún
231  tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo
232  cuando hace falta.
233- El paso «muere» **no borra nada**: lista con nombre y tamaño lo que se pierde al apagar el origen.
234
235### ⚠ El dato que vuelve accionable a un `no-declarado`: su invocación
236
237Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es
238**cómo se está ejecutando ahora**, y eso se lee de `/proc`. El censo captura `cmdline` y `cwd` de
239cada servicio vivo, y el plan los emite en el paso:
240
241```
242#   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
243#   cwd    : /mnt/vvv/tawasuyu/shared/tejido
244#   puertos: 34221, 44961
245```
246
247Ese servicio de gioser es **un binario de `target/debug/deps/`** corriendo en producción, con dos
248puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de
249memoria. Ahora está escrito.
250
251⚠ Se lee `cmdline` y `cwd`, **nunca `environ`**: el entorno suele traer tokens y contraseñas, y un
252censo que se guarda en un fichero y se comparte no puede llevarlos.
253
254## 4. Etapa 3 — declarar y aplicar *(`declarar.py` implementado)*
255
256### 4.0 `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca
257
258Es el paso que cierra la mudanza: **copiar un binario no lo levanta al arrancar**. Toma la
259invocación que el censo leyó de `/proc` y emite `seed.card.json`.
260
261**Por qué no alcanza con `arje-absorb`**: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y
262está bien hecho, pero lee **la declaración del init ajeno** — justo la que miente. En gioser
263`rc-status` daba `stopped` para cinco servicios vivos, y los `no-declarado` no aparecen en ninguna
264declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan:
265absorb para lo declarado, esto para lo que corre.
266
267Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos.** La tarjeta de caddy sale
268con su invocación real (`/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile`), que
269es exactamente lo que faltaba para que no muriera en el próximo reinicio.
270
271**Tres reglas:**
272
2731. **Un servicio sin `cmdline` legible NO se emite**, y se dice por su nombre. Una tarjeta que no
274   puede arrancar es peor que una ausente: la ausente falla ruidosamente al primer arranque, la rota
275   arranca el sistema y deja el servicio caído sin que nada avise.
2762. **El `cwd` se preserva envolviendo.** El payload `Native` de arje acepta `exec`/`argv`/`envp` y
277   **no `cwd`** (comprobado sobre la semilla real del producto). 9 de los 37 servicios de gioser
278   dependen de su directorio; sin envolver arrancarían en `/` y fallarían de un modo difícil de
279   atribuir. Se emite `sh -c 'cd … && exec …'`, el idioma que la propia tarjeta de `sshd` ya usa.
2803. **IDs deterministas** derivados del nombre ⇒ el mismo censo produce la misma semilla **byte a
281   byte** (verificado con `sha256`), así que dos corridas se comparan con `diff`. Un fichero
282   generado que cambia en cada corrida no se puede revisar.
283
284⚠ **`envp` va vacío a propósito**: el censo no lee `/proc/<pid>/environ` porque trae tokens. Un
285servicio que dependa de variables necesita que alguien las escriba, y se avisa.
286
287⚠ **Un bug que costó el 70 % del censo y vale como lección**: `cmdline` y `cwd` se leían en un solo
288comando, y como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el
289comando entero salía ≠0 y se descartaba **también el `cmdline`**, que sí se podía leer. 26 de 37
290servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se
291pudo». **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**
292
293### 4.0 bis · De dónde sale cada binario — «instalá el paquete» no es una instrucción
294
295El paso de servicio decía «instalá el binario/paquete en el destino», que es lo que uno ya sabía. La
296respuesta útil sale de cruzar **dos hechos**: quién posee el fichero en el ORIGEN (se lo pregunta el
297censo al gestor de paquetes de esa máquina, en un solo lote) y si el corpus de takana tiene una
298receta con ese nombre. Tres casos, tres trabajos distintos:
299
300| clase | qué significa | qué hay que hacer |
301|---|---|---|
302| `receta-takana` | takana ya sabe construirlo | instalarlo del repo firmado — **no** copiar el binario |
303| `paquete-ajeno` | lo trae el gestor del origen | escribir una receta, o `qorpa` (ADR 0015) |
304| `suelto` | **nadie lo provee**: puesto a mano | llevarlo con su entorno, o escribirle receta |
305
306**Medido sobre gioser (37 servicios vivos):** 4 `receta-takana` · 4 `paquete-ajeno` · **11 `suelto`**
307· 7 sin binario legible. Los sueltos viven en `/usr/local/bin` o en un home — `qdrant`, `matilda`,
308`pacha-secretos`, `act_runner`, `shuma-gateway`— y **son los que se pierden al apagar el origen**.
309
310⚠ **Y corrigió un error mío.** En el SDD 28 escribí que `gitea` no tenía receta y que «es la que más
311peso tiene». Las dos cosas eran falsas: `recipes/gitea.toml` existe y está sellada. Lo afirmé de
312memoria; el programa fue a mirar. Es exactamente para esto que existe.
313
314⚠ **Y un fallo del propio clasificador, que vale como regla**: calculaba la raíz del repo con un
315`dirname` de menos, así que no encontraba ninguna receta y **contestaba `suelto` a todo** — incluido
316`caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no
317encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta.
318
319### 4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto
320
321Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché.
322`planear.py` agrupa por **familia funcional**, muestra qué equivalentes tiene takana, y recomienda —
323pero **no «el mejor» en abstracto**: un gusto disfrazado de dato es peor que no recomendar.
324
325Los criterios son hechos comprobables, en este orden:
326
3271. **El que YA CORRE, si takana lo construye.** Y la razón es el costo real: *cambiar de servidor web
328   no es cambiar un binario — es reescribir la configuración entera*. Eso casi siempre pesa más que
329   cualquier ventaja teórica del otro.
3302. Si el que corre **no** está en el catálogo pero un equivalente sí, se recomienda ése — porque es
331   el que takana puede construir, firmar y reproducir — **diciendo lo que cuesta**.
3323. Si no hay ninguno, se dice eso. **No se sugiere «usá otro» cuando ese otro tampoco está.**
333
334Medido contra el catálogo real:
335
336```
337caddy  → alternativas: **caddy**✓  nginx✗  apache✗  lighttpd✗  traefik✓  haproxy✗
338         recomendado: caddy — es el que YA corre y takana lo construye (sellado).
339                      Cambiar obliga a REESCRIBIR la configuración entera.
340
341nginx  → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia
342                      sí están sellados: caddy, traefik. Cambiar implica reescribir la
343                      configuración: es una decisión, no un trámite.
344
345redis  → alternativas: redis✗  valkey✗  memcached✗
346         recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el
347                      catálogo. Hay que escribir la receta del que se elija.
348```
349
350⚠ **El dato que la recomendación destapa**: de servidores web el corpus sólo tiene **`caddy` y
351`traefik`**. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la
352recomendación misma.
353
354Si se elige un equivalente, queda en el censo (`alternativa = "…"`) y **el plan lo dice en su paso**,
355con la advertencia arriba de todo: la configuración del origen no sirve tal cual.
356
357### 4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala
358
359`declarar.py --perfil-out` emite además un `[perfil.<label>]` listo para `docs/state/targets.toml`.
360
361**Por qué un perfil y no una lista de `install`.** Mudar servicio por servicio a una caja viva es lo
362que uno haría en cualquier distro, y en takana va contra el diseño: el software de una máquina se
363**declara** y viene en la imagen, reproducible y firmado. Un servidor armado a fuerza de
364instalaciones sueltas no se puede volver a construir — que es exactamente el problema que esta
365mudanza existe para no repetir: **el `caddy` de gioser no tiene dueño ni receta, y se pierde con la
366máquina**.
367
368Y hay un impedimento medido, no estético: `takana install` **reproduce desde fuente**, y eso exige el
369LAB entero en el cliente ([SDD 28 §5.4](28-servidor-de-produccion.md)). Una caja de destino no lo
370tiene. El perfil esquiva el problema por el lado correcto: el software entra **al armar la imagen**,
371en el hub, que sí tiene lab.
372
373Generado desde el censo de gioser:
374
375```toml
376[perfil.gioser-mudado]
377descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
378hereda = ["base"]
379paquetes = [
380  "caddy",
381  "gitea",
382  "python3",  # python3, uvicorn
383]
384```
385
386Verificado que el instrumental lo acepta: `scripts/targets.py gioser-mudado` expande a **33 raíces**.
387
388⚠ **Y dice lo que NO puede cubrir**, uno por uno con su motivo: 34 servicios entre `suelto`,
389`paquete-ajeno` y `desconocido`. *Un perfil que se calla lo que le falta sale N/N describiendo un
390servidor incompleto* — la lección de `foot` en `escritorio-sway`, que llegó a 121/121 sellado y sin
391emulador de terminal.
392
393### 4.1.bis Los tres artefactos declarativos
394
395Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir **tres documentos que
396reconstruyen el servidor desde cero**:
397
398| artefacto | qué declara | lo consume |
399|---|---|---|
400| `[perfil.<x>]` → `targets.toml` | **qué software** lleva | `servidor-image.sh` al armar la imagen |
401| `seed.card.json` | **qué corre y cómo** | `arje-zero` al arrancar |
402| `plan.toml` | **qué datos y en qué orden** | `aplicar.py`, o un humano línea por línea |
403
404Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
405Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
406
407### 4.0 quindecies · ⚠ 26 repositorios existen SÓLO en la máquina que se va a borrar
408
409> **Corrección de rumbo (2026-09-14, decisión del usuario): la copia fuera NO se consigue espejando
410> a un tercero, se consigue MUDANDO.** En cuanto el gitea vive en la caja nueva, esos 26 repos dejan
411> de existir sólo en la máquina que se borra — que es exactamente lo que este paso pide. El objetivo
412> del frente es dejar de pagar dos máquinas, y montar una dependencia con GitHub va en contra.
413> `espejar-repos.sh` sigue en el repo como herramienta (redundancia extra el día que se quiera), pero
414> **no es el paso 1 del plan**: el paso 1 es mudar el gitea, y el criterio de verificación es el
415> mismo de siempre — clonar de verdad desde el destino antes de apagar nada.
416
417
418Es el hallazgo más grave de este frente, y va por delante de todo lo demás en el plan. Buscando cómo
419escribir la receta de `tawasuyu` apareció que **su único remoto es el gitea de gioser**
420(`ssh://gitea@git.tawasuyu.net:2345/…`, y `git.tawasuyu.net` resuelve a `204.168.193.248`, que es
421gioser). Al mirar el resto: **28 repositorios con remoto en esta máquina, 26 sin ninguna copia
422fuera.** Sólo `takana` y `llimphi-standalone` tienen espejo externo.
423
424Apagar el origen no borra unos servicios: borra **el código con el que se volverían a construir**. Y
425los clones de trabajo están en el mismo disco, que también muere. Entre los 26 está `tawasuyu`, que
426produce **10 de los 13 binarios que nadie más provee** — la dependencia circular completa.
427
428No se ve desde ninguna otra parte del censo: un repo no es un proceso, ni un puerto, ni un dominio.
429Se descubre cuando ya no hay de dónde sacarlo.
430
431El censo lo mira ahora (`[[repo]]`), y lo que reporta **no es «tiene remoto» sino si alguno de sus
432remotos NO es esta máquina** — un remoto que apunta afuera es la prueba de que el código sobrevive.
433En el plan es el **paso 1**, por delante del rescate de binarios: aquello pierde un servicio, esto
434pierde la posibilidad de reconstruirlo. El propio takana ya resuelve esto con `pushurl` doble
435(`scripts/espejo-setup.sh`), así que el arreglo está escrito.
436
437### 4.0 quaterdecies · Los 13 binarios sueltos son casi un solo árbol
438
439De los 13 servicios cuyo binario **nadie provee** —los que se pierden al apagar el origen— **diez
440salen del mismo repositorio**, `tawasuyu` (hoy en `bad13117c`):
441
442| binario | paquete del workspace |
443|---|---|
444| `matilda` | `matilda` |
445| `pacha` | `pacha-cli` |
446| `pacha-secretos` | `pacha-secretos` |
447| `sandokan-watch` | `sandokan-seguridad-core` |
448| `shuma-daemon` · `shuma-gateway` | homónimos |
449| `tejido` | `tejido` |
450| `tupu` | `tupu-cli` |
451| `willay-crosscheck` | `willay-cruce` |
452| `willay-daemon` | `willay-daemon` |
453
454(Los otros tres son ajenos: `act_runner`, `adb` y el `puerta-…` de `target/debug`.)
455
456O sea que **una receta cubre diez servicios**, y el trabajo de escribirla se paga diez veces.
457
458#### El tamaño real es una quinta parte del que aparenta
459
460El `Cargo.lock` del workspace tiene **2823 crates**, pero eso es el workspace entero —con su stack
461gráfico, audio y Android—. El subárbol que esos diez binarios necesitan de verdad son **524**. Y de
462todos ellos, sólo **cinco traen C**:
463
464```
465  aws-lc-sys   ring        ← criptografía: compilan C/asm, necesitan cmake
466  dirs-sys  inotify-sys  netlink-sys   ← bindings puros, sin librería externa
467```
468
469`aws-lc-sys` y `ring` son los conocidos de esta cadena; los otros tres no cuestan nada.
470
471#### ⚠ Lo que hay que decidir antes de escribirla: los dos pines no coinciden
472
473`tawasuyu/rust-toolchain.toml` fija **1.96.0**, y lo argumenta en el propio fichero: *una distro que
474promete builds deterministas y atestación firmada no puede tener el compilador flotando*. El lab de
475takana trae **1.97.0**, y es **rodante** por diseño — `lab-toolchain.lock` detecta la deriva, no la
476evita.
477
478Así que una receta takana de tawasuyu compilaría con un rustc distinto del que el propio tawasuyu
479exige. No es un detalle de estilo: **el binario resultante no sería el mismo que está corriendo**, y
480rompería el contrato de atestación de tawasuyu. Tres salidas, y es una decisión, no un trámite:
481
4821. **Pinear 1.96.0 para esta receta** — honra el contrato de tawasuyu; hay que meter ese rustc en el
483   lab, como ya se hace con las 84 recetas que pinean `zig_version=0.13.0`.
4842. **Mover el pin de tawasuyu a 1.97.0** — decisión del otro frente, no de éste.
4853. **Llevar los binarios tal cual** — es lo que el plan hace hoy, y significa que no reproducen.
486
487⚠ Y no se construye en el origen: 4 núcleos y ~2 G disponibles. Eso va al worker
488(`dev.gioser.net`, 6 núcleos / 16 G), que además es gratis.
489
490### 4.0 terdecies · El cruce que nadie hacía: un dominio que se muda sin su backend
491
492La config del servidor **declara de qué servicios depende** — cada `reverse_proxy` y cada
493`php_fastcgi` apuntan a algo. Cruzar eso contra la decisión tomada sobre cada servicio caza un fallo
494que ninguna otra parte puede ver:
495
496> el dominio se **muda** y el servicio que lo sirve está marcado **muere**.
497
498Las dos decisiones son razonables por separado —el dominio se usa, el servicio parecía
499prescindible— y juntas dejan el sitio nuevo devolviendo 502. No lo ve el DNS, no lo ven los procesos,
500y no lo ve quien decide **de a una entrada por vez**, que es como se decide.
501
502También caza el revés, y en gioser eso es lo que apareció: dos sitios proxean a un puerto que
503**ningún servicio censado sirve**, o sea que ya devuelven 502 *hoy, en el origen*.
504
505```
506  mail.sigma.gioser.net  ↳ localhost:9000   nadie escucha ahí
507  api.gioser.net         ↳ localhost:8000   nadie escucha ahí
508```
509
510Confirmado aparte con `ss -lntp`: efectivamente no hay nada en 8000 ni en 9000.
511
512#### Y un falso positivo que hubo que matar primero
513
514La primera corrida acusaba a `sergio.gioser.net` de mudarse dejando atrás a `shuma`. Era falso, y la
515causa estaba **en el censo**: los puertos se adjudicaban por prefijo de nombre
516(`proc.startswith(name[:15])`), así que un `shuma` **declarado-muerto** se quedaba con el 7378 — que
517lo escucha `shuma-gateway`, comprobado con `ss -lntp`. Un declarado-muerto **por definición no puede
518estar escuchando**.
519
520Ahora los puertos se atan por **PID**, que es lo único sin ambigüedad. El puerto es la señal más
521fuerte de que algo sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de
522él — y aquí se derivó un guardián acusando al inocente, que es la forma más rápida de que un guardián
523se deje de leer.
524
525### 4.0 duodecies · La cuenta de lo que falta, bien hecha
526
527`origen_binario` buscaba la receta por el **nombre del servicio**, y ése casi nunca es el nombre del
528paquete: `sshd` lo trae `openssh`, `crond` lo trae `cronie`. El censo ya le preguntó al gestor de
529paquetes quién posee cada binario, así que ese nombre también se prueba. Con eso el perfil pasó de
530**4 recetas a 7**, y la clasificación de los 27 servicios quedó así:
531
532```
533  receta-takana  9      paquete-ajeno 13
534  suelto         9      interprete     4      borrado 4
535```
536
537#### La trampa: el intérprete NO es el programa
538
539`openclaw` lo posee el paquete `nodejs`, y `fail2ban-server`, `glances` y `uvicorn` los posee
540`python`. Contarlos como cubiertos porque existe `recipes/nodejs.toml` haría salir el perfil **N/N
541describiendo un servidor al que le faltan cuatro programas**. Tienen clase propia, `interprete`, y
542cuentan **las dos cosas a la vez**, porque las dos son ciertas:
543
544- su **runtime entra al perfil** — `openclaw` necesita `nodejs` en la imagen pase lo que pase;
545- el **servicio sigue listado como no cubierto**, con su motivo.
546
547Meterlo sólo en las raíces haría un perfil que miente; dejarlo sólo en los faltantes armaría una
548imagen sin runtime, y el programa, cuando llegue, no arrancaría.
549
550(Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix
551empaqueta `python`, el catálogo tiene `python3.toml`. Sin ese alias, tres servicios decían «hace
552falta una receta takana» teniendo el runtime sellado — dos trabajos muy distintos.)
553
554#### Sellada y sin sellar no son lo mismo
555
556El perfil las listaba igual. Una sellada se **instala** del repo firmado; una sin sellar hay que
557**construirla**, y eso puede ser una tarde. Salió con un caso real: `recipes/qdrant.toml` entró al
558catálogo desde otro frente **mientras se escribía esto** y no tiene artefacto. Ahora el perfil lo
559marca en la línea y lo resume al pie.
560
561### 4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA
562
563El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir:
564`/proc/<pid>/exe` termina en `(deleted)` para **cuatro servicios de gioser**. El fichero ya no está
565en disco; sólo vive el inodo que sostiene su proceso.
566
567Y en tres de los cuatro hay **ahora otro fichero en la misma ruta, de distinto tamaño**:
568
569| servicio | corriendo | en su ruta hoy |
570|---|---|---|
571| `tejido` | 12 750 368 B | 13 815 864 B |
572| `shuma-gateway` | 8 431 896 B | 10 363 504 B |
573| `pacha-secretos` | 8 634 240 B | 8 647 456 B |
574| `puerta-f6e393ff…` | 197 262 440 B | *no hay nada* |
575
576Copiar la ruta **no falla**: muda otra cosa, y el servicio nuevo no es el que estaba andando. Es el
577modo de fallo más caro que hay en este documento — todo verde, todo distinto.
578
579Se recuperan leyendo `/proc/<pid>/exe`, **y sólo mientras el proceso viva**. En una mudanza que
580termina *borrando el origen*, un reinicio de gioser antes de este paso pierde esos binarios para
581siempre. Por eso el rescate va **antes del preflight**: es el único paso del plan que puede volverse
582imposible mientras se piensa el resto.
583
584Su verificación **compara tamaños** contra el que corre, y no por celo: un `cat` de un `/proc` que ya
585no existe crea un fichero **vacío** y devuelve 0. Probado en los dos sentidos — el paso real pasa, y
586con un fichero vacío a propósito el guardián dice `FALTA tejido` y sale 1.
587
588Los cuatro ya están rescatados en `work/mudanza/rescate/`, con su `SHA256SUMS`.
589
590### 4.0 decies · Leer la config del servidor: un fósil se detecta SIN tocar la red
591
592La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el
593**fichero de configuración**, y leerlo no toca al origen — ni una petición, ni riesgo de fail2ban.
594
595Para leerlo se agregó un **lector de Caddy** al centro (`formatos/caddy.py`, que ya tenía el
596escritor), y el censo lo usa en vez de tener su propio parser a medias: los lectores de nginx y
597apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note.
598
599El resultado inmediato, sobre el Caddyfile real de gioser — **cinco sitios apuntan a un `root` que no
600existe**, y son justo los que devolvían los 502 que en su día hicieron que el censo *se baneara a sí
601mismo* al sondearlos por HTTP:
602
603```
604  aura.gioser.net       → /var/www/aura_frontend        NO EXISTE
605  sigma.gioser.net      → /var/www/sigma/frontend       NO EXISTE
606  summa.gioser.net      → /var/www/summa/frontend       NO EXISTE
607  kosmofono.gioser.net  → /var/www/kosmofono/frontend   NO EXISTE
608  dev.summa.gioser.net  → /var/www/summa-dev/frontend   NO EXISTE
609```
610
611Es evidencia **más fuerte que el DNS**: no hay nada que servir, devuelve 502 resuelva donde resuelva.
612
613#### Y sirve para lo contrario, que es donde el aviso hacía daño
614
615Un directorio que la config **sí** referencia no es un huérfano, diga lo que diga su nombre. Sin
616esto, el plan marcaba `/var/www/git-tawasuyu` como «nadie lo recuerda» estando servido — y ese aviso,
617aplicado, **tira `git.tawasuyu.net`**.
618
619La primera versión del lector seguía fallando en eso, y el motivo es instructivo: el `root` de ese
620sitio vive **dentro de un `handle`**, y yo saltaba los bloques anidados enteros por no saber
621modelarlos. Que el pivote no sepa **modelar** algo no es razón para no **verlo**: ahora el bloque
622sigue marcado SIN-TRADUCIR, pero sus `root` y `reverse_proxy` se leen. Con eso aparecieron dos raíces
623ausentes más, que también estaban anidadas.
624
625#### Falsos sitios: una lista negra no alcanzaba
626
627Detectar la cabecera de un sitio descartando directivas conocidas (`handle {`, `route {`, `@bot {`)
628dejaba pasar `log { output file … {` y el snippet `(acceso) {` — **dos dominios inventados** que el
629censo habría puesto a decidir. La regla que aguanta es positiva: *todos* los tokens de la cabecera
630tienen que **parecer una dirección**.
631
632### 4.0 nonies · La cadena entera, y el preflight que medía contra el sistema de ficheros equivocado
633
634`censar → planear → aplicar` corrió de punta a punta contra gioser: **59 pasos**, 20 ejecutables y 39
635manuales, correctamente separados — el aplicador marca los manuales `pendiente-humano` y la corrida
636no se considera completa.
637
638Y ahí el ensayo en seco destapó lo que ninguna prueba de juguete iba a mostrar: **33,6 G de datos
639contra una raíz de 3,5 G libres.**
640
641```
642  /dev/root    3,5 G libres   /
643  /dev/sdb    19,0 G libres   /store
644  /dev/sda4   66,2 G libres   /work
645```
646
647Dos cosas estaban mal a la vez:
648
649- **El plan copiaba ruta → misma ruta**, sin forma de decir dónde cae cada árbol en el destino. Ahora
650  `[[datos]]` tiene `destino` (vacío = la misma ruta), y el fallo que evita es caro: aparecía a mitad
651  de un `rsync` de 22 G.
652- **El preflight sumaba todo y lo comparaba contra `/`.** Eso acierta *por casualidad* mientras todo
653  caiga en `/`, y da un veredicto **completamente falso** en cuanto una ruta va a otro montaje — en
654  las dos direcciones. Ahora mide **por sistema de ficheros**, agrupando los destinos, y el veredicto
655  lo saca el propio comando en vez de un humano leyendo una columna:
656
657```
658  ✗  /       necesita 34386 MiB · libres  3596  ⇐ /var/www /var/lib /srv /opt /home
659     NO ENTRA en /: faltan 30790 MiB                                        (exit 1)
660
661  ✓  /work   necesita 34386 MiB · libres 66192  ⇐ /work/var/www …/work/home (exit 0)
662```
663
664Los dos controles corridos contra la caja de verdad: el negativo falla y el positivo pasa.
665
666### 4.0 octies · `/proc` es un formato de origen más — y había DOS emisores de tarjetas
667
668`declarar.py` tenía su propio molde de card de arje, y `formatos/arje.py` el suyo. Dos emisores del
669mismo formato es exactamente el N×M que el pivote existe para evitar, y no quedó en teoría: **cada
670copia tenía un campo mal de la raíz, y ninguno de los dos el mismo.**
671
672| campo de la raíz | `declarar.py` | `formatos/arje.py` | la semilla REAL del producto |
673|---|---|---|---|
674| `provides` | `["Spawn","Journal"]` ✓ | `[]` ✗ | `["Spawn","Journal"]` |
675| `supervision` | `Restart{…}` ✗ | `"OneShot"` ✓ | `"OneShot"` |
676
677Las dos consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni
678dónde escribir; y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
679
680Ahora hay un lector `proc` —el censo es un formato de origen, igual que systemd u OpenRC— y el
681escritor `arje` es el único que emite tarjetas. `declarar.py` queda con lo suyo: el **perfil**.
682
683```
684familia «servicio»:  systemd ─┐
685                     openrc  ─┼─►  Servicio/Sistema  ──►  arje
686                     proc    ─┘
687```
688
689Los tres se complementan y ninguno solo alcanza: systemd y OpenRC leen **lo declarado**, que es lo
690que miente —`rc-status` daba `stopped` para cinco servicios vivos—, y `proc` lee **lo que corre**,
691que es lo único donde aparecen los 15 `no-declarado`.
692
693#### Una semilla vacía parecería un éxito
694
695Si ningún servicio tiene `decision = "muda"`, el lector `proc` **no devuelve cero tarjetas en
696silencio**: lo dice y sale ≠0. Es el mismo modo de fallo que un artefacto vacío en el store — un
697ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
698
699#### El acta se estaba ahogando en su propio ruido
700
701La primera versión anotaba el `envp` vacío **una vez por servicio**: 26 líneas idénticas que tapaban
702los dos hallazgos que sí eran de un servicio concreto —`shuma-daemon` corre como `sergio`, y los que
703no tienen línea de comando—. La limitación es del *lector* y vale igual para todos, así que va **una
704entrada nombrando a los 26**. Un acta donde casi todo es la misma línea se deja de leer, y entonces
705no queda ningún acta. Lo mismo con la supervisión. Y al revés: las entradas que sí son por servicio
706ahora lo **nombran** (`gitea: cwd=/var/lib/gitea`), porque cuatro `WorkingDirectory=/home/sergio`
707sin nombre obligan a buscar cuál es cuál.
708
709### 4.0 septies · El censo real de gioser, y dos huecos que destapó
710
711Corrido contra `204.168.193.248` — que es `gioser.net`, identificado **por IP y no por hostname**,
712porque `hostname` dice `momento`:
713
714```
715── servicios ──                       ── dominios (sonda DNS) ──
716  vivo               19                 apunta-aca      12
717⚠ no-declarado       18                 ya-mudado        4
718  declarado-muerto   85                 fosil-sin-dns   13
719```
720
721#### Tres nombres equivocados, y los tres llegaban al plan
722
723El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
724clasificación —no cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<nombre>` y el
725que va de `label` en la tarjeta de arje.
726
727- **`comm` está capado a 15 caracteres.** `willay-crosscheck` llegaba como `willay-crossche`, y con
728  ese nombre no casaba contra su declaración ⇒ figuraba como **`no-declarado`** teniendo su tarjeta
729  en la semilla de arje. La línea de comando trae el nombre entero.
730- **`supervise-daemon` no es un servicio, es un envoltorio.** Los cinco de gioser se fundían en UNA
731  entrada llamada `supervise-daemo`; y del otro lado, `dbus`, `metalog`, `dhcpcd`, `squid` y
732  `shuma-daemon` salían como **`declarado-muerto` estando vivos** — el mismo agujero que el censo
733  existe para tapar, entrando por la otra puerta. El nombre real es su `argv[1]`; el **comando** real
734  es el último token antes del `--` suelto.
735- **`head -15` con `ppid==1`** era un resto de tubería reparentado, contado como servicio
736  `no-declarado`. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
737
738#### «No se pudo leer su binario» eran dos cosas distintas
739
7407 de 38 servicios salían `desconocido`. La causa no era una: muchos demonios **reescriben su
741`argv[0]`** (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
742comando no dice cuál es el binario — pero `/proc/<pid>/exe` sí, y necesita ser dueño o root. Con el
743censo corriendo como root, los 7 se resuelven:
744
745```
746  uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
747  root     : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
748```
749
750Así que el censo ahora **dice cuál de las dos cosas pasó**: «repetilo con sudo» y «averigualo a mano»
751son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
752
753Y desenvolver al supervisor destapó un dato que se estaba perdiendo: `shuma-daemon` corre con
754`--user sergio`. La tarjeta de arje **no tiene campo de usuario**, así que sin eso a la vista un
755servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión.
756
757#### El HTTP toca al origen; el DNS no
758
759Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso
760el censo en local los salteaba, y quedaban 29 de 66 ítems **sin recomendación**. Pero lo que dispara
761la jaula es el HTTP: `getent` le pregunta al DNS, no al servidor. Con `--probe-dns` se sondea sólo el
762nombre, **sin una sola petición a gioser**, y eso ya contesta la pregunta que más pesa: cuáles siguen
763apuntando acá y cuáles son fósiles. 29 de 29 clasificados; sólo **12 hay que reapuntar**.
764
765Lo que el DNS solo no puede decir es si el backend contesta, así que esos quedan en `apunta-aca`
766— clase propia, **no `vivo`**. Decir «vivo» sin haber pedido una página sería la respuesta falsa con
767forma de respuesta que este censo existe para evitar.
768
769#### Los fósiles quedaban fuera de la lista, y por eso sus datos viajaban
770
771`entradas()` filtraba los `fosil-sin-dns` con el argumento de que un dominio sin DNS no necesita
772ningún paso. Es cierto para el DNS y **falso para lo que arrastra**: son 13 de 29, y `terapeuta.ec`
773tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran **invisibles**, así
774que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — exactamente el «mudar
775fósiles» que este plan existe para evitar, y contra la regla 2, que dice que lo que muere se nombra
776**antes** de borrar nada.
777
778### 4.0 sexies · El centro ENGANCHADO al plan
779
780El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Es cierto y
781es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora ese
782párrafo es **un paso con su comando literal**:
783
784```
785▸ traducir declaración de caddy (openrc → arje)        [verifica: humano]
786   scripts/mudanza/traducir.py --from openrc --to arje \
787         --in /etc/init.d/caddy --out caddy.card.json
788
789▸ traducir configuración de nginx → caddy              [verifica: humano]
790   scripts/mudanza/traducir.py --from nginx --to caddy \
791         --in /etc/nginx/nginx.conf --out Caddyfile
792```
793
794Dos traducciones distintas por servicio, y confundirlas es caro: la **declaración** (cómo se levanta)
795y la **configuración** (qué hace). Un servicio que se muda a sí mismo sólo necesita la primera —
796ofrecerle traducir su config sería inventarle trabajo.
797
798Los pares se le **preguntan al registro de plugins**, no se listan en `planear.py`: una lista propia
799se desincronizaría del centro y el plan ofrecería una traducción que no existe. Cuando no hay par, el
800paso lo dice y manda a `--list`.
801
802Y la verificación es **humana**, a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir,
803así que dar el paso por bueno por su código de salida sería exactamente al revés de lo que hay que
804mirar. Lo que este paso verifica es que alguien **leyó el acta**.
805
806#### ⚠ El plan que generábamos no era TOML válido
807
808Salió al meter el primer comando multilínea, pero estaba latente desde el principio. Dos modos de
809fallo, y el segundo es el peor:
810
811| | cadena básica `"""` | cadena literal `'''` |
812|---|---|---|
813| `awk "\$1==1"` (el `verifica` de todo servicio) | **ilegible**: `Unescaped '\'` | parsea idéntico |
814| `cmd --from x \` + salto | parsea… y **se come el salto y la sangría** | parsea idéntico |
815
816El primero rompe ruidosamente. El segundo **parsea y miente**: el plan se lee bien y el comando que
817sale no es el que se escribió. El plan es el producto —se revisa, se versiona, se lleva a otro
818proveedor y se vuelve a correr—, así que uno que no vuelve a parsear no sirve para ninguna de las dos
819cosas para las que existe.
820
821Arreglado usando la cadena literal, y con un guardián que **escribe y relee** antes de tocar el
822disco: si el TOML generado no parsea, no se escribe nada. Cuesta milisegundos y convierte un fallo
823diferido —que aparecía cuando alguien iba a *ejecutar* el plan— en uno inmediato.
824
825### 4.0 quinquies · El CENTRO de traducción — pivote y plugins
826
827Cambiar de nginx a caddy no es cambiar un binario: es **reescribir la configuración**. `traducir.py`
828hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
829
830#### ⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE
831
832Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son **20 traductores**,
833y cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un
834formato nuevo cuesta uno o dos plugins y **estrena todos los pares de golpe**.
835
836```
837  formatos/nginx.py   ─┐                      ┌─►  formatos/caddy.py
838  formatos/apache.py  ─┼─►  modelo pivote  ───┤
839  (el próximo)        ─┘    Config/Sitio/Ruta └─►  (el próximo)
840```
841
842**Probado, no argumentado**: el lector de apache se agregó **sin tocar el escritor de Caddy**, y con
843eso `apache → caddy` funciona sin que exista ningún traductor apache→caddy. Las dos salidas las
844valida el caddy del propio corpus:
845
846```
847apache → caddy :  Valid configuration
848nginx  → caddy :  Valid configuration
849```
850
851Un formato se agrega dejando un fichero en `formatos/` con `@lector("x")` y/o `@escritor("y")`. El
852centro los **descubre**: no hay una lista que editar, porque una lista mantenida a mano se
853desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
854habilita y cuántos plugins costaron.
855
856#### El pivote es uno POR FAMILIA
857
858Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el
859«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro
860**empareja sólo dentro de la familia**:
861
862| familia | modelo | lectores | escritores |
863|---|---|---|---|
864| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
865| `servicio` | `Servicio` / `Sistema` | systemd, openrc | arje |
866
867Un par cruzado se rechaza con un error, no con un intento:
868
869```
870$ traducir.py --from systemd --to caddy …
871✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
872  No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.
873```
874
875#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema
876
877El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
878real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio
879**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie
880vuelve a leer**. Sale como SIN-TRADUCIR, con su línea.
881
882Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce
883⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que
884lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del
885humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un
886servicio **menos confinado** que el original.
887
888Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
889semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
890tarjeta real de `sshd`**, más `_mudanza` de procedencia.
891
892#### `openrc → arje`: casi la mitad de los servicios NO son declaraciones
893
894Un unit de systemd se lee. Un servicio de OpenRC es un **script de shell** que puede hacer cualquier
895cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real de gioser —la máquina que esta
896mudanza borra— **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que
897valga: leer las variables y emitir una tarjeta produciría un servicio que arranca *otra cosa*.
898
899Por eso la regla va al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR y no se emite
900tarjeta**. Los números del corpus real cierran solos:
901
902```
903  ficheros en /etc/init.d      : 121
904  no son openrc-run            :   1
905  con start()/stop() propia    :  60   → no se emite tarjeta
906  declarativos sin command=    :   1   → no hay qué arrancar
907  ⇒ traducibles                :  59   ← y el lector emitió exactamente 59
908```
909
910Lo otro que OpenRC obliga a ir a buscar es **`/etc/conf.d/<servicio>`**: el servicio está partido en
911dos ficheros y **los argumentos de verdad viven en el segundo**. A diferencia del `EnvironmentFile=`
912de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático **no lo
913dice el script** sino el enlace en `/etc/runlevels/`.
914
915#### Tres defectos que sólo aparecieron corriendo contra las 121 de verdad
916
917- **`name=` no es un identificador, es un rótulo**: en gioser vale `Aura Backend`, con espacio — que
918  se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
919  además es lo que usan `rc-update` y `/etc/runlevels`.
920- **Ids repetidos**: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos, y son
921  scripts de OpenRC válidos. Dos servicios con el mismo nombre dan **el mismo id determinista** ⇒ una
922  semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
923- **Procedencia cableada**: la tarjeta decía `"desde": "systemd"` viniendo de OpenRC. El campo que
924  existe para saber de dónde salió algo era el que mentía.
925
926#### Lector y escritor no opinan del mismo campo
927
928`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía
929dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector
930registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de
931un `0` que obliga a buscar a mano lo que ya se sabía.
932
933#### El acta viaja DENTRO del modelo
934
935Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que
936un **lector** no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor
937la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.
938
939Y el escritor puede **agregarle**: Caddy no tiene equivalente directo de `location ~`, así que en vez
940de emitir un `handle` de prefijo —que *se parece* y no es lo mismo— lo manda al acta. **Parecerse es
941peor que faltar.**
942
943#### Doctrina heredada de `soltar`/`paskaq` (tawasuyu)
944
945`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, `.mdb`). El dominio es otro
946—datos, no configuración— pero sus principios son exactamente los que hacía falta, y se adoptan tal
947cual:
948
949- **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá cada directiva no
950  entendida sale con su línea y su texto. **Un traductor que descarta en silencio te deja un servidor
951  sin una redirección o sin una regla de auth, y el sitio parece funcionar.**
952- **No adivinar en silencio**: toda heurística queda como decisión explícita, no aplicada de tapadillo.
953- **Procedencia total**: cada bloque del resultado dice de qué línea del original salió.
954- **Determinista**: mismo fichero, misma salida.
955
956Y lo que NO es: un traductor completo de nginx. Traduce el núcleo común y **declara todo lo demás**.
957*Uno honesto que cubre el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el
958100 % es una trampa.*
959
960#### ⚠ Validar con el binario real encontró dos bugs, y uno cambiaba el sentido
961
962Leer la salida no alcanzaba. Pasarla por `caddy validate` —con el caddy del propio corpus— destapó:
963
9641. **`ambiguous site definition`**: en nginx dos `server` con el mismo `server_name` se distinguen por
965   su `listen`; en Caddy, por el **esquema de la dirección**. Sin traducir eso, los bloques colisionan
966   y el fichero ni carga.
9672. **Y el grave**: un `if (...) { return 403; }` salía como `respond 403` **incondicional** — el sitio
968   entero devolviendo 403. La directiva vivía dentro de un bloque que yo mismo había declarado
969   intraducible, y se absorbía igual al bloque de afuera.
970
971   Eso es peor que la pérdida silenciosa que la doctrina de `soltar` advierte: **no pierde, CAMBIA el
972   sentido**. Ahora todo lo que está dentro de un bloque opaco sale como `SIN-TRADUCIR` con su motivo:
973   *«sacarla de ahí le cambiaría el sentido — dejaría de ser condicional»*.
974
975Con los dos arreglados, el caddy del corpus contesta **`Valid configuration`**.
976
977#### Y la receta de caddy, terminada
978
979El `caddy` del corpus era un **import de nix en crudo**: su cabecera decía «PUNTO DE PARTIDA, no
980final» y su `commit` era el TAG flotante `v2.11.4` — contra el ADR 0006, porque un tag se mueve y la
981receta pasa a construir otra cosa sin que el hash lo note. Anclado a `e2eee6a7…` con `takana pin`; el
982hash se movió a propósito. 77 MB estáticos, **0 intérpretes requeridos**, y un `file-server` de
983prueba contesta 200.
984
985Importaba terminarla por una razón medida: **el `caddy` de gioser no tiene dueño** — binario puesto a
986mano, sin paquete y sin receta — y se pierde con la máquina. Con la receta, el servidor nuevo lo
987declara y lo reconstruye.
988
989### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)*
990
991`aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]`
992
993**La regla que lo define: un paso que no se ejecutó NO se marca como hecho.** Un plan tiene pasos de
994dos naturalezas y confundirlas es la forma más fácil de dar una mudanza por buena sin estarlo:
995
996- **ejecutable** — tiene comandos de verdad (copiar, comprobar espacio);
997- **manual** — su `cmd` son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada
998  que correr, y **un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok»**.
999  Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.
1000
1001Acá un paso manual queda `pendiente-humano`, la corrida **no** se considera completa (sale con ≠0), y
1002se confirma con `--hecho N` — que además **se niega** si el paso sí tenía comandos: «corrélo, no lo
1003marques».
1004
1005**Se le cree a la verificación, no al exit code.** El `rsync` que llenó el disco devolvió **0** y
1006dejó 1367 artefactos vacíos. Un paso pasa a `ok` sólo si su verificación pasa; si el comando salió
1007bien y la verificación falló, queda `sospechoso`, que se informa como peor que un fallo.
1008
1009**Y las verificaciones están TIPADAS** (`cmd` / `humano`): «la columna Available debe ser > X» no es
1010un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.
1011
1012**Reanudable**: el estado vive en `<plan>.estado.json`, escrito con temporal + `fsync` + `rename` —
1013la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.
1014
1015#### ⚠ Y una verificación que era decorativa
1016
1017La primera versión del paso de datos verificaba así:
1018
1019```
1020ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'
1021```
1022
1023Imprimía el número de vacíos **y devolvía 0 igual**. En la primera corrida real dio «✓ verificado: 2»
1024— donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.
1025
1026Ahora **compara**: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a
1027propósito y con el control que tiene que pasar:
1028
1029```
1030copia NO hecha  → ficheros: origen=5 destino=0  ⇒ FALLA
1031copia hecha     → ficheros: origen=5 destino=5  ⇒ PASA
1032```
1033
1034Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
1035
10361. **Idempotente y reanudable.** Cada copia grande se cortó al menos una vez.
10372. **Verifica en DESTINO, no en origen.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
1038   dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
10393. **Los servicios `no-declarado` se declaran**, no se replican a mano. Es la salida natural hacia
1040   `arje-absorb`, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero **leyendo la
1041   declaración, que es justo la que miente**: hay que alimentarlo con el censo.
10424. **Deja el servidor corriendo**, y lo prueba desde afuera: cada dominio `vivo` del plan tiene que
1043   contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.
1044
1045## 5. La imagen, independiente del proveedor
1046
1047Lo que hoy la ata a Hetzner es poco y está medido:
1048
1049- **Arranque: ✅ RESUELTO (2026-09-11).** La imagen ahora arranca por **BIOS y por UEFI**, y es la
1050  misma imagen. Layout: `p1` BIOS-boot · `p2` ESP FAT32 · `p3` `/` · `p4` estado · `p5` store.
1051
1052  **Un solo `grub.cfg`**: el `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan los dos a
1053  `(hd0,gpt3)/boot/grub`, así que hay una sola línea de comando y un solo sitio donde editarla. Dos
1054  imágenes hermanas obligarían a elegir por proveedor y a que divergieran — y ya divergían: la
1055  hermana EFI documentaba etiquetas `takana-*` que su propio código no escribe (corregido).
1056
1057  **No se usa EFI-stub directo acá** (sí lo hace `install-image-efi.sh`, ADR 0010): el stub arrancado
1058  por la ruta fallback recibe LoadOptions VACÍO, así que necesita la cmdline HORNEADA en el kernel, y
1059  la de `linux-generic` sólo trae la consola — sin `root=`. Hornearla ataría la línea de comando al
1060  ArtifactHash del kernel. Con GRUB EFI la cmdline vive en el `grub.cfg`, que es donde se edita.
1061
1062  **Verificado con la MISMA imagen en los dos firmwares**, hasta SSH:
1063
1064  | | UEFI (OVMF) | BIOS (SeaBIOS) |
1065  |---|---|---|
1066  | `/sys/firmware/efi` | **presente** | ausente |
1067  | PID 1 | `arje-zero` | `arje-zero` |
1068  | raíz · store | `sda3` · `sda5` | `sda3` · `sda5` |
1069- **Red**: `netup` hace DHCPv4 y ya cubre el caso difícil (IP `/32` con la puerta fuera del prefijo,
1070  que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores.
1071- **Instalación**: `rescue → dd → reboot` no es de Hetzner: sirve en cualquier entorno de rescate con
1072  SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor.
1073- **Disco**: ya se resuelve solo — el store es la última partición y crece al disco entero en el
1074  primer arranque.
1075
1076⇒ **la imagen está más cerca de ser portable que la herramienta de mudarse.** Por eso el orden es
1077censo → plan → aplicación, y la portabilidad de la imagen va en paralelo.
1078
1079## 10. ¿Se puede borrar gioser ya? — el barrido del 2026-09-18
1080
1081La condición que puso el usuario fue: **«no borramos gioser hasta tenerte corriendo de aquel lado,
1082con capacidad de compilar tawasuyu y las recetas de takana»**. Eso está cumplido y medido:
1083
1084| capacidad | prueba |
1085|---|---|
1086| recetas de takana, desde fuente | `linux-firmware`, `wireless-regdb`, `firmware-tigerlake`, `intel-ucode`, `sof-firmware` selladas **en la caja** |
1087| recetas de tawasuyu, desde fuente | `puriy-costura` clonada, vendoreada, compilada y sellada **al mismo hash que predice el hub** |
1088| compilador propio | `rust` 1.97.0 sellado; `rustc`/`cargo` corren en la caja |
1089| driver de C | `/usr/bin/cc` (SDD 28 §6.52); compila C y proc-macros |
1090| el latido | el cron de cosecha commitea Y empuja (13:01 del 18-09) |
1091
1092**Los dominios ya no dependen de gioser.** Los diez de `gioser.net`/`tawasuyu.net` resuelven a la
1093caja. Las excepciones son `terapeuta.ec`, `andino.ec` y `mercedes.andino.ec`, y el hallazgo es otro:
1094**no resuelven en NINGÚN lado** —NXDOMAIN desde dos resolvedores— aunque gioser las siga sirviendo
1095por IP (`curl --resolve` da 200). O sea que para el público **ya están caídas**, y lo que se perdería
1096al apagar gioser es un sitio que hoy nadie puede alcanzar por su nombre.
1097
1098### ⚠ Y el dato que cambia el orden de las cosas: a gioser ya NO SE ENTRA
1099
1100```
1101  gioser 204.168.193.248   22/tcp cerrado · 2222 cerrado · 22022 cerrado
1102                          443 abierto · 80 abierto · 2345 abierto
1103  hcloud dice `running`
1104```
1105
1106Probado desde **tres** orígenes distintos —el laptop, la caja y el worker—: el 22 está cerrado en los
1107tres. La web contesta, el ssh no. `hcloud` lo ve `running`. **La administración de gioser está
1108perdida**; para sacar cualquier cosa más haría falta el modo rescate de Hetzner.
1109
1110Eso importa porque **quedan 127 de 150 entradas de datos SIN DECIDIR** en
1111`docs/state/mudanza-decisiones-pendientes.txt` (23 ya tienen decisión: 6 `muda`, 3 `respalda`,
111214 `muere`). Entre las indecisas hay cosas que sólo el usuario puede clasificar —`/var/lib/gitea`,
1113`/home/sergio/.claude`, `/var/lib/sigma`, showreels…— y **hoy no se pueden bajar sin rescate**.
1114
1115> **Lo honesto, en una línea:** *técnicamente* la caja ya reemplaza a gioser; lo que bloquea el
1116> borrado no es capacidad sino **datos sin clasificar en una máquina en la que ya no se puede entrar**.
1117> El orden correcto es: rescate → bajar lo que el usuario marque → recién ahí borrar.
1118> ⛔ Y nada de esto se hace sin que el usuario lo pida por su nombre: gioser es servidor protegido.
1119
1120## 11. La batuta: repos y sitios listos para trabajar DESDE la caja *(2026-09-18)*
1121
1122### terapeuta y los `.ec` — decidido
1123
1124El usuario decidió **respaldar, no mudar**, y los dominios `.ec` **vencieron hace tiempo**. Eso
1125explica el NXDOMAIN del §10 y **cierra el punto**: no son un bloqueo para apagar gioser.
1126
1127### Los repos: los tres al lado, y de `sergio`
1128
1129```
1130  /work/sergio/takana       ← sergio/takana.git       (estaba en gitea; faltaba el clon de trabajo)
1131  /work/sergio/tawasuyu     ← tawasuyu/tawasuyu.git
1132  /work/sergio/gioser-web   ← sergio/gioser-web.git   (sólo estaba en gitea: no había clon)
1133```
1134
1135El gitea de la caja ya tenía `sergio/takana.git` **al lado de** `sergio/tawasuyu.git` —y la org
1136`tawasuyu` con sus 21 repos—; lo que faltaba era el **árbol de trabajo**. Los tres quedan en
1137`/work/sergio`, de `sergio:sergio`, y **los tres empujan**: se le generó a `sergio` una clave en la
1138caja y se registró en su cuenta de gitea por la API (con un token de `gitea admin`). Comprobado con
1139`git push --dry-run` en los tres.
1140
1141### Los sitios: publicar pasa a ser `git pull`
1142
1143Lo que la mudanza dejó eran **copias a mano** —4,2 G del monorepo sólo para `tawasuyu.net`— sin forma
1144de refrescarlas. El contenido estaba, de hecho, **al día**: se comparó lo servido con lo que generan
1145hoy los repos y coincidía byte a byte. Lo que estaba roto era el **modelo**: cuando el repo cambiara,
1146la web no se iba a enterar, y eso no se nota, porque **un sitio viejo responde 200 igual que uno
1147nuevo**.
1148
1149Ahora Caddy sirve directo de los clones:
1150
1151| sitio | raíz |
1152|---|---|
1153| `tawasuyu.net`, `www` | `/work/sergio/tawasuyu` (con los rewrites a `web/tawasuyu-web`) |
1154| `gioser.net` | `/work/sergio/gioser-web/` |
1155| `takana.gioser.net`, `hifas.gioser.net` | `/work/sergio/gioser-web/{takana,hifas}` — las genera `hermanas.py` |
1156| `gioser.net/{humanoid,muestras,vivo,ende,reencuentro}` | `/work/www/gioser-web/…` — **medios**, no están en git |
1157
1158`scripts/servidor/publicar-webs.sh` hace el ciclo entero: `pull --ff-only` (no pisa cambios locales
1159de un árbol que está sirviendo), regenera las dos hermanas —`hermanas.py` trae el destino **cableado**
1160a `/home/sergio/gioser-web`, ruta de gioser que acá no existe, así que se le pasa el del clon sin
1161tocar ese repo— y **comprueba**: lo servido byte a byte contra el disco, los medios en 200, y
1162`/.git/config` en **404**, porque servir desde un clon no puede exponer el repo. Liberados **4,2 G**.
1163
1164⚠ **Lo de `humanoid` es distinto y conviene decirlo:** su repo es el proyecto **Android** (gradle:
1165consola, cubo, keyboard, launcher, matiz, nimbo, nonet, notas), y lo que se sirve en
1166`gioser.net/humanoid` son **APK ya construidos** — salida de release que no vive en ningún repo.
1167Refrescar esa página no es publicar: es **compilar**, y pide Android SDK + Java, que la caja no tiene.
1168

29-mudanza.md

SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta

Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28:

«quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay, qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo corriendo. Todo automático, sin IA.»

0. La corrección que ordena todo

El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso es configurar un servidor, no construir un producto. caddy no es un hueco de la imagen: es una instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.

La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios sirven, qué datos pesan— se censa en la máquina vieja, se confirma con el usuario, y se ejecuta.

Y hay una consecuencia de método que conviene decir fuerte: todo lo que hice a mano en el SDD 28 es la especificación de este programa. Censar, cruzar declaración contra realidad, separar vivo de fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo «¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. No hace falta IA: hace falta que esté escrito.

1. Las tres etapas

   CENSO                  PLAN                      APLICACIÓN
   censar.py    →    <censo>.toml (decisiones)  →   ejecutar, idempotente
   sólo lee          revisable · exportable         deja el server corriendo

El fichero del medio es el producto real: es «los pasos exportables». Se revisa, se versiona, se lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que se hizo.

2. Etapa 1 — el censo (implementado: scripts/mudanza/censar.py)

La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO, y reportar tres clases:

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

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

⚠ Y el criterio que decide si el censo sirve: un guardián con hallazgos falsos se ignora entero. La primera versión daba 28 no-declarado con ruido; tras normalizar nombres da 18, y son reales (matilda, pacha, puerta-…, adb, ModemManager). Afinarlo no es cosmética: es la diferencia entre que alguien lo lea o no.

3. Etapa 2 — el plan (implementado: scripts/mudanza/planear.py)

El censo emite cada entrada con decision = "". El plan es ese fichero con las decisiones puestas (muda / muere), más el orden y las dependencias. Requisitos:

  1. Nada sin decidir se ejecuta. Una entrada vacía aborta: el silencio no es consentimiento.
  2. Lo que muere se dice por su nombre, con su tamaño, antes de borrar nada.
  3. El plan es exportable y re-ejecutable en otro proveedor. Es el artefacto que el usuario pidió.
  4. Un TUI para llenarlo, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.

3 bis · El tercer camino: mudar · respaldar · abandonar (pedido del usuario, 2026-09-16)

«la herramienta debería mostrarme una lista de cada cosa con una corta descripción y la opción de yo elegir mudar, mover a un directorio para respaldar ese directorio, o abandonarlo»

Las tres partes de esa frase eran tres huecos distintos, y los tres se cerraron.

1. «cada cosa» — el censo medía RAÍCES, no cosas

/home era una entrada de 25 G y una decisión. Adentro hay cosas de dueños distintos: repos con remoto, SDKs que se re-bajan, 4,3 G de caché, y trabajo que no está en ningún otro lado. Con una sola decisión sólo se puede acertar a lo bruto — mudar 25 G para salvar 200 M, o abandonar 25 G y perder ese 200 M. La granularidad de la lista es la granularidad de lo que se puede decidir.

censar.py ahora emite un renglón por cosa (169 en gioser contra 5), un nivel hacia adentro de cada raíz y dos en /home, porque su primer nivel son usuarios y la decisión no es por usuario. Queda --solo-totales para la vista vieja, que es la que dimensiona la mudanza y la que mira el preflight: las dos vistas viajan en el censo, datos y datos_raiz.

⚠ Y una lección repetida: el primer intento mandaba las ~110 sondas en una llamada, se pasaba del timeout, y como el parser no encontraba ninguna línea devolvía lista vacía — «no hay datos» en vez de «no pude medirlos». Va en tandas de 12 y una tanda que falla se nombra.

2. «una corta descripción» — hechos, nunca un juicio

De cada cosa se reporta lo que se puede comprobar, lo más decisivo primero:

/home/sergio/gioser-web     428M  LO SIRVE gioser.net, www.gioser.net · repo git → gitea@git.gioser.net:…
/home/sergio/android-sdk   1019M  lo usa adb · lo más nuevo: 2026-06-12
/home/sergio/accordion       37M  repo git → github.com/GotJimmy/accordion · CON CAMBIOS SIN COMMITEAR
/home/sergio/.cache         4.3G  lo más nuevo: 2026-09-16
/home/sergio/humanoid       200M  sin señales: ni git, ni servido, ni usado por nada vivo

Nada de «parece un proyecto viejo». Es repo o no lo es; lo sirve la config del servidor web o no; lo usa un proceso vivo o no; adentro hay node_modules/target/venv o no. «sin señales» también es un hecho — y es el que dice dónde hay que mirar a mano.

3. «yo elegir» entre tres — y qué recomienda la máquina

DECISIONES pasa de ("muda","muere") a ("muda","respalda","muere"). El camino que faltaba es el que la gente usa de verdad: esto no lo quiero corriendo allá, pero tampoco lo quiero perder. Con dos opciones, todo lo dudoso se marcaba muda «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar.

La regla de fondo no cambió: qué datos VALEN no se deduce de la máquina, y la mayoría sale sin recomendación a propósito. Pero hay cuatro cosas que la máquina sí sabe, y callarlas obliga a mirar 169 entradas a ojo:

hechorecomiendapor qué
lo sirve el servidor web · lo usa un proceso vivomudasi no viaja, ese sitio o ese servicio deja de existir
es caché o estado transitorio (.cache, .gradle, node_modules…)muereel destino lo vuelve a crear solo
repo git limpio con remotomuerese vuelve a clonar
repo sin remoto, o con cambios sin commitearrespaldaesa parte existe únicamente en este disco

⚠ Y el cruce que evita el peor error: un remoto que apunta a ESTA MISMA máquina no es un respaldo, es el mismo disco. Sin eso, «tiene remoto ⇒ se vuelve a clonar» mandaría a abandonar un repo cuyo único ejemplar se borra con el servidor. Medido en gioser: el remoto de /mnt/vvv/humanoid es ssh://git@127.0.0.1:2345/…, el gitea de la propia caja.

4. Y las decisiones ahora se GUARDAN

--decide sólo las usaba para generar el plan de esa corrida: cerrabas la terminal y las 169 elecciones se perdían. El censo es la interfaz (§3), así que ahora se vuelve a escribir con las decisiones puestas —temporal + fsync + rename, copia .bak, y releído antes de pisar nada— y se aborta si el fichero regenerado perdió alguna. Decidir 169 cosas siempre se hace a medias; que sobreviva a medias es parte del diseño, no una comodidad.

Para que eso fuera posible hubo que hacer el emisor del censo idempotente: escribía decision = "" fijo al final de cada entrada, así que re-emitir un censo decidido producía la clave dos veces y el TOML dejaba de parsear. O sea que la herramienta no podía volver a escribir su propio fichero.

5. El paso respaldo, y los dos bugs que encontró el guardián de dos sentidos

Lo marcado respalda va a un corral —por defecto el Storage Box, leído de respaldo-storagebox.sh para que no haya una segunda dirección escrita a mano— con el nombre aplanado (/home/sergio/humanoid → home-sergio-humanoid), y no se instala en el destino: la caja nueva no se entera de que existe. Sin corral configurado el plan aborta: un rsync a ninguna parte devuelve 0 y se lee igual que un respaldo.

Probar el paso emitido con rotura a propósito y con el control que tiene que pasar destapó dos cosas que la lectura no veía:

  1. --mkpath no es opcional: sin él rsync muere con mkdir … failed en cuanto el corral tiene un nivel que no existe — y el primer respaldo siempre lo tiene.
  2. Un FICHERO no lleva barra final. Con el censo detallado entran ficheros sueltos (mapas.tgz, gu.png, un .docx), y rsync fichero/ destino/ no es «copiá el fichero»: es un error. El censo ya dice cuál es cuál; el paso de datos tenía el mismo defecto latente.

La verificación no usa du ni find, sino rsync en seco contra el destino real: el corral puede ser un Storage Box, cuya shell restringida no tiene find ni acepta tuberías, y cuyo du además comprime (1,3 G subidos se ven como 576 M — un número que parece copia truncada y no lo es). Lo que se cuenta son los ficheros que a rsync todavía le faltarían.

6. De paso: el aplicador en seco decía «✅ todos los pasos ejecutados y verificados»

Con --dry-run no se ejecuta nada, y el resumen de siempre contaba cero ejecutados igual que cero fallidos. Ahora dice «EN SECO: N pasos mostrados, NINGUNO ejecutado y NINGUNO verificado». Es la misma regla que el resto del aplicador: un paso que no se ejecutó no se marca como hecho.

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

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

familiamotivoejemplos en gioser
hardware localuna caja remota no tiene ese dispositivobluetoothd ModemManager upowerd adb
escritorio o pantallaun servidor no los tienewaypipe
red del destinopelearían: el destino la configura con su propio initNetworkManager dhcpcd
lo provee el init del destinono se mudaudevd dbus-daemon elogind polkitd agetty

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

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

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

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

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

Y lo que NO se recomienda solo

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

Lo implementado

planear.py --censo <f> --target <host> [--decide] --out plan.toml

⚠ El dato que vuelve accionable a un no-declarado: su invocación

Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es cómo se está ejecutando ahora, y eso se lee de /proc. El censo captura cmdline y cwd de cada servicio vivo, y el plan los emite en el paso:

#   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
#   cwd    : /mnt/vvv/tawasuyu/shared/tejido
#   puertos: 34221, 44961

Ese servicio de gioser es un binario de target/debug/deps/ corriendo en producción, con dos puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de memoria. Ahora está escrito.

⚠ Se lee cmdline y cwd, nunca environ: el entorno suele traer tokens y contraseñas, y un censo que se guarda en un fichero y se comparte no puede llevarlos.

4. Etapa 3 — declarar y aplicar (declarar.py implementado)

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

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

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

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

Tres reglas:

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

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

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

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

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

clasequé significaqué hay que hacer
receta-takanatakana ya sabe construirloinstalarlo del repo firmado — no copiar el binario
paquete-ajenolo trae el gestor del origenescribir una receta, o qorpa (ADR 0015)
sueltonadie lo provee: puesto a manollevarlo con su entorno, o escribirle receta

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

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

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

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

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

Los criterios son hechos comprobables, en este orden:

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

Medido contra el catálogo real:

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

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

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

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

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

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

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

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

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

Generado desde el censo de gioser:

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

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

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

4.1.bis Los tres artefactos declarativos

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

artefactoqué declaralo consume
[perfil.<x>] → targets.tomlqué software llevaservidor-image.sh al armar la imagen
seed.card.jsonqué corre y cómoarje-zero al arrancar
plan.tomlqué datos y en qué ordenaplicar.py, o un humano línea por línea

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

4.0 quindecies · ⚠ 26 repositorios existen SÓLO en la máquina que se va a borrar

Corrección de rumbo (2026-09-14, decisión del usuario): la copia fuera NO se consigue espejando a un tercero, se consigue MUDANDO. En cuanto el gitea vive en la caja nueva, esos 26 repos dejan de existir sólo en la máquina que se borra — que es exactamente lo que este paso pide. El objetivo del frente es dejar de pagar dos máquinas, y montar una dependencia con GitHub va en contra. espejar-repos.sh sigue en el repo como herramienta (redundancia extra el día que se quiera), pero no es el paso 1 del plan: el paso 1 es mudar el gitea, y el criterio de verificación es el mismo de siempre — clonar de verdad desde el destino antes de apagar nada.

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

binariopaquete del workspace
matildamatilda
pachapacha-cli
pacha-secretospacha-secretos
sandokan-watchsandokan-seguridad-core
shuma-daemon · shuma-gatewayhomónimos
tejidotejido
tuputupu-cli
willay-crosscheckwillay-cruce
willay-daemonwillay-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:

  1. 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.
  2. Mover el pin de tawasuyu a 1.97.0 — decisión del otro frente, no de éste.
  3. 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:

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:

serviciocorriendoen su ruta hoy
tejido12 750 368 B13 815 864 B
shuma-gateway8 431 896 B10 363 504 B
pacha-secretos8 634 240 B8 647 456 B
puerta-f6e393ff…197 262 440 Bno 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:

  ✗  /       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ízdeclarar.pyformatos/arje.pyla semilla REAL del producto
provides["Spawn","Journal"] ✓[] ✗["Spawn","Journal"]
supervisionRestart{…} ✗"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.

«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 \ + saltoparsea… y se come el salto y la sangríaparsea idéntico

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

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

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

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

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

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

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

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

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

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

El pivote es uno POR FAMILIA

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lector y escritor no opinan del mismo campo

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

El acta viaja DENTRO del modelo

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

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

Doctrina heredada de soltar/paskaq (tawasuyu)

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

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

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

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

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

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

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

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

Y la receta de caddy, terminada

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

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

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

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

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

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

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

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

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

⚠ Y una verificación que era decorativa

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

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

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

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

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

Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:

  1. Idempotente y reanudable. Cada copia grande se cortó al menos una vez.
  2. Verifica en DESTINO, no en origen. «Lo copié» no prueba nada: el rsync que llenó el disco dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
  3. Los servicios no-declarado se declaran, no se replican a mano. Es la salida natural hacia arje-absorb, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero leyendo la declaración, que es justo la que miente: hay que alimentarlo con el censo.
  4. Deja el servidor corriendo, y lo prueba desde afuera: cada dominio vivo del plan tiene que contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.

5. La imagen, independiente del proveedor

Lo que hoy la ata a Hetzner es poco y está medido:

⇒ la imagen está más cerca de ser portable que la herramienta de mudarse. Por eso el orden es censo → plan → aplicación, y la portabilidad de la imagen va en paralelo.

10. ¿Se puede borrar gioser ya? — el barrido del 2026-09-18

La condición que puso el usuario fue: «no borramos gioser hasta tenerte corriendo de aquel lado, con capacidad de compilar tawasuyu y las recetas de takana». Eso está cumplido y medido:

capacidadprueba
recetas de takana, desde fuentelinux-firmware, wireless-regdb, firmware-tigerlake, intel-ucode, sof-firmware selladas en la caja
recetas de tawasuyu, desde fuentepuriy-costura clonada, vendoreada, compilada y sellada al mismo hash que predice el hub
compilador propiorust 1.97.0 sellado; rustc/cargo corren en la caja
driver de C/usr/bin/cc (SDD 28 §6.52); compila C y proc-macros
el latidoel cron de cosecha commitea Y empuja (13:01 del 18-09)

Los dominios ya no dependen de gioser. Los diez de gioser.net/tawasuyu.net resuelven a la caja. Las excepciones son terapeuta.ec, andino.ec y mercedes.andino.ec, y el hallazgo es otro: no resuelven en NINGÚN lado —NXDOMAIN desde dos resolvedores— aunque gioser las siga sirviendo por IP (curl --resolve da 200). O sea que para el público ya están caídas, y lo que se perdería al apagar gioser es un sitio que hoy nadie puede alcanzar por su nombre.

⚠ Y el dato que cambia el orden de las cosas: a gioser ya NO SE ENTRA

  gioser 204.168.193.248   22/tcp cerrado · 2222 cerrado · 22022 cerrado
                          443 abierto · 80 abierto · 2345 abierto
  hcloud dice `running`

Probado desde tres orígenes distintos —el laptop, la caja y el worker—: el 22 está cerrado en los tres. La web contesta, el ssh no. hcloud lo ve running. La administración de gioser está perdida; para sacar cualquier cosa más haría falta el modo rescate de Hetzner.

Eso importa porque quedan 127 de 150 entradas de datos SIN DECIDIR en docs/state/mudanza-decisiones-pendientes.txt (23 ya tienen decisión: 6 muda, 3 respalda, 14 muere). Entre las indecisas hay cosas que sólo el usuario puede clasificar —/var/lib/gitea, /home/sergio/.claude, /var/lib/sigma, showreels…— y hoy no se pueden bajar sin rescate.

Lo honesto, en una línea: técnicamente la caja ya reemplaza a gioser; lo que bloquea el borrado no es capacidad sino datos sin clasificar en una máquina en la que ya no se puede entrar. El orden correcto es: rescate → bajar lo que el usuario marque → recién ahí borrar. ⛔ Y nada de esto se hace sin que el usuario lo pida por su nombre: gioser es servidor protegido.

11. La batuta: repos y sitios listos para trabajar DESDE la caja (2026-09-18)

terapeuta y los .ec — decidido

El usuario decidió respaldar, no mudar, y los dominios .ec vencieron hace tiempo. Eso explica el NXDOMAIN del §10 y cierra el punto: no son un bloqueo para apagar gioser.

Los repos: los tres al lado, y de sergio

  /work/sergio/takana       ← sergio/takana.git       (estaba en gitea; faltaba el clon de trabajo)
  /work/sergio/tawasuyu     ← tawasuyu/tawasuyu.git
  /work/sergio/gioser-web   ← sergio/gioser-web.git   (sólo estaba en gitea: no había clon)

El gitea de la caja ya tenía sergio/takana.git al lado de sergio/tawasuyu.git —y la org tawasuyu con sus 21 repos—; lo que faltaba era el árbol de trabajo. Los tres quedan en /work/sergio, de sergio:sergio, y los tres empujan: se le generó a sergio una clave en la caja y se registró en su cuenta de gitea por la API (con un token de gitea admin). Comprobado con git push --dry-run en los tres.

Los sitios: publicar pasa a ser git pull

Lo que la mudanza dejó eran copias a mano —4,2 G del monorepo sólo para tawasuyu.net— sin forma de refrescarlas. El contenido estaba, de hecho, al día: se comparó lo servido con lo que generan hoy los repos y coincidía byte a byte. Lo que estaba roto era el modelo: cuando el repo cambiara, la web no se iba a enterar, y eso no se nota, porque un sitio viejo responde 200 igual que uno nuevo.

Ahora Caddy sirve directo de los clones:

sitioraíz
tawasuyu.net, www/work/sergio/tawasuyu (con los rewrites a web/tawasuyu-web)
gioser.net/work/sergio/gioser-web/
takana.gioser.net, hifas.gioser.net/work/sergio/gioser-web/{takana,hifas} — las genera hermanas.py
gioser.net/{humanoid,muestras,vivo,ende,reencuentro}/work/www/gioser-web/… — medios, no están en git

scripts/servidor/publicar-webs.sh hace el ciclo entero: pull --ff-only (no pisa cambios locales de un árbol que está sirviendo), regenera las dos hermanas —hermanas.py trae el destino cableado a /home/sergio/gioser-web, ruta de gioser que acá no existe, así que se le pasa el del clon sin tocar ese repo— y comprueba: lo servido byte a byte contra el disco, los medios en 200, y /.git/config en 404, porque servir desde un clon no puede exponer el repo. Liberados 4,2 G.

⚠ Lo de humanoid es distinto y conviene decirlo: su repo es el proyecto Android (gradle: consola, cubo, keyboard, launcher, matiz, nimbo, nonet, notas), y lo que se sirve en gioser.net/humanoid son APK ya construidos — salida de release que no vive en ningún repo. Refrescar esa página no es publicar: es compilar, y pide Android SDK + Java, que la caja no tiene.