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 ese nombre resuelve a 204.168.193.248 = 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. · `censar.py` lo mira ahora (`[[repo]]`), y reporta NO «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. La lista de «esta máquina» sale del censo mismo (sus IPs + los dominios que sirve), no de nombres cableados. · `planear.py` lo emite como PASO 1, por delante del rescate de binarios: aquello pierde un servicio, esto pierde la posibilidad de reconstruirlo. El arreglo ya está escrito en el repo —takana usa `pushurl` doble por `scripts/espejo-setup.sh`. Y una corrección de algo que dije antes: la perilla por receta que vi en `recipe.rs` es `strip_debug`, no una versión de rust. NO existe `rust_version`; sólo `zig_version`. Pinear rustc por receta para honrar el `rust-toolchain.toml` de tawasuyu (1.96.0, contra 1.97.0 del lab) sería código nuevo en takana-core, no una opción ya disponible. De paso, medido el subárbol de los diez binarios por separado: cuatro (`willay-daemon`, `sandokan-seguridad-core`, `pacha-secretos`, `tupu-cli`, entre 84 y 153 deps) NO arrastran criptografía en C; cuatro traen `ring` y dos `aws-lc-sys`. O sea que hay un escalón por donde empezar sin pelear con cmake. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
966 lines
54 KiB
Markdown
966 lines
54 KiB
Markdown
# SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta
|
||
|
||
Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28:
|
||
|
||
> «quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación
|
||
> mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro
|
||
> servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para
|
||
> reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay,
|
||
> qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo
|
||
> corriendo. Todo automático, sin IA.»
|
||
|
||
## 0. La corrección que ordena todo
|
||
|
||
El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso
|
||
es configurar un servidor, no construir un producto. **caddy no es un hueco de la imagen: es una
|
||
instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.**
|
||
|
||
La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios
|
||
sirven, qué datos pesan— **se censa en la máquina vieja**, se confirma con el usuario, y se ejecuta.
|
||
|
||
Y hay una consecuencia de método que conviene decir fuerte: **todo lo que hice a mano en el SDD 28 es
|
||
la especificación de este programa.** Censar, cruzar declaración contra realidad, separar vivo de
|
||
fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo
|
||
«¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. **No hace falta IA: hace
|
||
falta que esté escrito.**
|
||
|
||
## 1. Las tres etapas
|
||
|
||
```
|
||
CENSO PLAN APLICACIÓN
|
||
censar.py → <censo>.toml (decisiones) → ejecutar, idempotente
|
||
sólo lee revisable · exportable deja el server corriendo
|
||
```
|
||
|
||
El fichero del medio es el producto real: **es «los pasos exportables»**. Se revisa, se versiona, se
|
||
lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que
|
||
se hizo.
|
||
|
||
## 2. Etapa 1 — el censo *(implementado: `scripts/mudanza/censar.py`)*
|
||
|
||
**La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO**, y reportar tres clases:
|
||
|
||
| clase | qué es | por qué importa |
|
||
|---|---|---|
|
||
| `vivo` | declarado **y** corriendo | candidato a mudarse |
|
||
| `declarado-muerto` | declarado, sin proceso | candidato a morir |
|
||
| **`no-declarado`** | **corriendo, sin declaración** | **el trabajo real: nadie sabe cómo levantarlo** |
|
||
|
||
No es teoría — cada regla salió de un error medido:
|
||
|
||
- **`rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los cinco estaban
|
||
vivos.** Un censo que lea el init y no `/proc` produce un plan que no arranca.
|
||
- **De 29 dominios del Caddyfile, sólo 10 están vivos**: 13 no tienen DNS, 2 dan 502, y **4 ya
|
||
resuelven a otra máquina** — su bloque de config es fósil aunque el sitio funcione. Por eso el
|
||
censo compara la IP del DNS contra las IPs de la máquina: sin eso, `ya-mudado` se lee como `vivo`.
|
||
- **El caddy de la caja nueva murió en un reinicio y nadie lo notó**, porque estaba arrancado a mano
|
||
y no declarado. Ésa es la clase `no-declarado`, y es la que duele.
|
||
- **`du -sh` no dice cuánto ocupa COPIAR** un árbol con hardlinks (medido: 60 G vs 85 G). El censo
|
||
reporta los dos números.
|
||
- **Los nombres no coinciden entre init y proceso**: `act-runner`/`act_runner`, `crond`/`cronie`,
|
||
`dbus-daemon`/`dbus`, `fail2ban-server`/`fail2ban`. Sin normalizar, el mismo servicio sale a la vez
|
||
como `no-declarado` y `declarado-muerto`: las dos clases que más importan, las dos mal.
|
||
- **Lo descartado se cuenta, no se esconde.** Un proceso con `ppid==1` que no es un servicio suele
|
||
ser un huérfano, y eso es un hallazgo (los `firefox` fugados que dejaron la granja sin compilar
|
||
hora y media fueron exactamente eso).
|
||
|
||
⚠ **Y el criterio que decide si el censo sirve**: un guardián con hallazgos falsos se ignora entero.
|
||
La primera versión daba 28 `no-declarado` con ruido; tras normalizar nombres da 18, y son reales
|
||
(`matilda`, `pacha`, `puerta-…`, `adb`, `ModemManager`). Afinarlo no es cosmética: es la diferencia
|
||
entre que alguien lo lea o no.
|
||
|
||
## 3. Etapa 2 — el plan *(implementado: `scripts/mudanza/planear.py`)*
|
||
|
||
El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las decisiones puestas
|
||
(`muda` / `muere`), más el orden y las dependencias. Requisitos:
|
||
|
||
1. **Nada sin decidir se ejecuta.** Una entrada vacía aborta: el silencio no es consentimiento.
|
||
2. **Lo que muere se dice por su nombre**, con su tamaño, antes de borrar nada.
|
||
3. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió.
|
||
4. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
|
||
|
||
### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
|
||
|
||
Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
|
||
bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: **pelean** con el
|
||
destino. `planear.py --revisar` muestra todo agrupado, con su recomendación **y su razón**:
|
||
|
||
| familia | motivo | ejemplos en gioser |
|
||
|---|---|---|
|
||
| hardware local | una caja remota no tiene ese dispositivo | `bluetoothd` `ModemManager` `upowerd` `adb` |
|
||
| escritorio o pantalla | un servidor no los tiene | `waypipe` |
|
||
| red del destino | **pelearían**: el destino la configura con su propio init | `NetworkManager` `dhcpcd` |
|
||
| lo provee el init del destino | no se muda | `udevd` `dbus-daemon` `elogind` `polkitd` `agetty` |
|
||
|
||
**11 de 37 servicios de gioser caen ahí.** Y el motivo no es cortesía: *una recomendación sin razón
|
||
no se puede discutir, así que o se acepta a ciegas o se ignora entera* — las dos son malas.
|
||
|
||
#### La recomendación DERIVADA, que es la más fuerte
|
||
|
||
Si el binario de un servicio vive en un árbol que **no se muda**, el servicio no va a poder arrancar
|
||
allá. Eso no hace falta saberlo: se calcula, cruzando el `cmdline` que el censo leyó de `/proc`
|
||
contra las decisiones de datos. Probado en los dos sentidos:
|
||
|
||
```
|
||
/mnt/vvv → muere : puerta-f6e393ff ⇒ NO mudar — «su binario vive en /mnt/vvv, que NO se muda»
|
||
/mnt/vvv → muda : puerta-f6e393ff ⇒ mudar — «corre y sirve 34221, y NADIE lo declara»
|
||
```
|
||
|
||
**Por eso los DATOS se deciden primero**: de ellos se deriva la recomendación de los servicios, y al
|
||
revés no se puede calcular. La revisión lo hace en ese orden y lo explica.
|
||
|
||
#### Y lo que NO se recomienda solo
|
||
|
||
Los **datos** salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina.
|
||
`terapeuta.ec` eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía
|
||
decidir eso por él.
|
||
|
||
### Lo implementado
|
||
|
||
`planear.py --censo <f> --target <host> [--decide] --out plan.toml`
|
||
|
||
- **Aborta con código 2** si queda algo sin decidir, listando qué. Probado.
|
||
- `--decide` recorre lo pendiente y propone (`vivo`→muda, `ya-mudado`/`backend-caido`→muere,
|
||
`no-declarado`→muda). **La sugerencia se imprime, nunca se aplica sola.**
|
||
- **Cada paso lleva su comando literal y su verificación**, así que el plan se ejecuta a mano, línea
|
||
por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
|
||
- El preflight dimensiona con el **tamaño de copia** (hardlinks expandidos), no con `du`; y si algún
|
||
tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo
|
||
cuando hace falta.
|
||
- El paso «muere» **no borra nada**: lista con nombre y tamaño lo que se pierde al apagar el origen.
|
||
|
||
### ⚠ El dato que vuelve accionable a un `no-declarado`: su invocación
|
||
|
||
Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es
|
||
**cómo se está ejecutando ahora**, y eso se lee de `/proc`. El censo captura `cmdline` y `cwd` de
|
||
cada servicio vivo, y el plan los emite en el paso:
|
||
|
||
```
|
||
# cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
|
||
# cwd : /mnt/vvv/tawasuyu/shared/tejido
|
||
# puertos: 34221, 44961
|
||
```
|
||
|
||
Ese servicio de gioser es **un binario de `target/debug/deps/`** corriendo en producción, con dos
|
||
puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de
|
||
memoria. Ahora está escrito.
|
||
|
||
⚠ Se lee `cmdline` y `cwd`, **nunca `environ`**: el entorno suele traer tokens y contraseñas, y un
|
||
censo que se guarda en un fichero y se comparte no puede llevarlos.
|
||
|
||
## 4. Etapa 3 — declarar y aplicar *(`declarar.py` implementado)*
|
||
|
||
### 4.0 `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca
|
||
|
||
Es el paso que cierra la mudanza: **copiar un binario no lo levanta al arrancar**. Toma la
|
||
invocación que el censo leyó de `/proc` y emite `seed.card.json`.
|
||
|
||
**Por qué no alcanza con `arje-absorb`**: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y
|
||
está bien hecho, pero lee **la declaración del init ajeno** — justo la que miente. En gioser
|
||
`rc-status` daba `stopped` para cinco servicios vivos, y los `no-declarado` no aparecen en ninguna
|
||
declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan:
|
||
absorb para lo declarado, esto para lo que corre.
|
||
|
||
Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos.** La tarjeta de caddy sale
|
||
con su invocación real (`/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile`), que
|
||
es exactamente lo que faltaba para que no muriera en el próximo reinicio.
|
||
|
||
**Tres reglas:**
|
||
|
||
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:
|
||
|
||
| clase | qué significa | qué hay que hacer |
|
||
|---|---|---|
|
||
| `receta-takana` | takana ya sabe construirlo | instalarlo del repo firmado — **no** copiar el binario |
|
||
| `paquete-ajeno` | lo trae el gestor del origen | escribir una receta, o `qorpa` (ADR 0015) |
|
||
| `suelto` | **nadie lo provee**: puesto a mano | llevarlo con su entorno, o escribirle receta |
|
||
|
||
**Medido sobre gioser (37 servicios vivos):** 4 `receta-takana` · 4 `paquete-ajeno` · **11 `suelto`**
|
||
· 7 sin binario legible. Los sueltos viven en `/usr/local/bin` o en un home — `qdrant`, `matilda`,
|
||
`pacha-secretos`, `act_runner`, `shuma-gateway`— y **son los que se pierden al apagar el origen**.
|
||
|
||
⚠ **Y corrigió un error mío.** En el SDD 28 escribí que `gitea` no tenía receta y que «es la que más
|
||
peso tiene». Las dos cosas eran falsas: `recipes/gitea.toml` existe y está sellada. Lo afirmé de
|
||
memoria; el programa fue a mirar. Es exactamente para esto que existe.
|
||
|
||
⚠ **Y un fallo del propio clasificador, que vale como regla**: calculaba la raíz del repo con un
|
||
`dirname` de menos, así que no encontraba ninguna receta y **contestaba `suelto` a todo** — incluido
|
||
`caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no
|
||
encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta.
|
||
|
||
### 4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto
|
||
|
||
Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché.
|
||
`planear.py` agrupa por **familia funcional**, muestra qué equivalentes tiene takana, y recomienda —
|
||
pero **no «el mejor» en abstracto**: un gusto disfrazado de dato es peor que no recomendar.
|
||
|
||
Los criterios son hechos comprobables, en este orden:
|
||
|
||
1. **El que YA CORRE, si takana lo construye.** Y la razón es el costo real: *cambiar de servidor web
|
||
no es cambiar un binario — es reescribir la configuración entera*. Eso casi siempre pesa más que
|
||
cualquier ventaja teórica del otro.
|
||
2. Si el que corre **no** está en el catálogo pero un equivalente sí, se recomienda ése — porque es
|
||
el que takana puede construir, firmar y reproducir — **diciendo lo que cuesta**.
|
||
3. Si no hay ninguno, se dice eso. **No se sugiere «usá otro» cuando ese otro tampoco está.**
|
||
|
||
Medido contra el catálogo real:
|
||
|
||
```
|
||
caddy → alternativas: **caddy**✓ nginx✗ apache✗ lighttpd✗ traefik✓ haproxy✗
|
||
recomendado: caddy — es el que YA corre y takana lo construye (sellado).
|
||
Cambiar obliga a REESCRIBIR la configuración entera.
|
||
|
||
nginx → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia
|
||
sí están sellados: caddy, traefik. Cambiar implica reescribir la
|
||
configuración: es una decisión, no un trámite.
|
||
|
||
redis → alternativas: redis✗ valkey✗ memcached✗
|
||
recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el
|
||
catálogo. Hay que escribir la receta del que se elija.
|
||
```
|
||
|
||
⚠ **El dato que la recomendación destapa**: de servidores web el corpus sólo tiene **`caddy` y
|
||
`traefik`**. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la
|
||
recomendación misma.
|
||
|
||
Si se elige un equivalente, queda en el censo (`alternativa = "…"`) y **el plan lo dice en su paso**,
|
||
con la advertencia arriba de todo: la configuración del origen no sirve tal cual.
|
||
|
||
### 4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala
|
||
|
||
`declarar.py --perfil-out` emite además un `[perfil.<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](28-servidor-de-produccion.md)). 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:
|
||
|
||
```toml
|
||
[perfil.gioser-mudado]
|
||
descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
|
||
hereda = ["base"]
|
||
paquetes = [
|
||
"caddy",
|
||
"gitea",
|
||
"python3", # python3, uvicorn
|
||
]
|
||
```
|
||
|
||
Verificado que el instrumental lo acepta: `scripts/targets.py gioser-mudado` expande a **33 raíces**.
|
||
|
||
⚠ **Y dice lo que NO puede cubrir**, uno por uno con su motivo: 34 servicios entre `suelto`,
|
||
`paquete-ajeno` y `desconocido`. *Un perfil que se calla lo que le falta sale N/N describiendo un
|
||
servidor incompleto* — la lección de `foot` en `escritorio-sway`, que llegó a 121/121 sellado y sin
|
||
emulador de terminal.
|
||
|
||
### 4.1.bis Los tres artefactos declarativos
|
||
|
||
Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir **tres documentos que
|
||
reconstruyen el servidor desde cero**:
|
||
|
||
| artefacto | qué declara | lo consume |
|
||
|---|---|---|
|
||
| `[perfil.<x>]` → `targets.toml` | **qué software** lleva | `servidor-image.sh` al armar la imagen |
|
||
| `seed.card.json` | **qué corre y cómo** | `arje-zero` al arrancar |
|
||
| `plan.toml` | **qué datos y en qué orden** | `aplicar.py`, o un humano línea por línea |
|
||
|
||
Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
|
||
Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
|
||
|
||
### 4.0 quindecies · ⚠ 26 repositorios existen SÓLO en la máquina que se va a borrar
|
||
|
||
Es el hallazgo más grave de este frente, y va por delante de todo lo demás en el plan. Buscando cómo
|
||
escribir la receta de `tawasuyu` apareció que **su único remoto es el gitea de gioser**
|
||
(`ssh://gitea@git.tawasuyu.net:2345/…`, y `git.tawasuyu.net` resuelve a `204.168.193.248`, que es
|
||
gioser). Al mirar el resto: **28 repositorios con remoto en esta máquina, 26 sin ninguna copia
|
||
fuera.** Sólo `takana` y `llimphi-standalone` tienen espejo externo.
|
||
|
||
Apagar el origen no borra unos servicios: borra **el código con el que se volverían a construir**. Y
|
||
los clones de trabajo están en el mismo disco, que también muere. Entre los 26 está `tawasuyu`, que
|
||
produce **10 de los 13 binarios que nadie más provee** — la dependencia circular completa.
|
||
|
||
No se ve desde ninguna otra parte del censo: un repo no es un proceso, ni un puerto, ni un dominio.
|
||
Se descubre cuando ya no hay de dónde sacarlo.
|
||
|
||
El censo lo mira ahora (`[[repo]]`), y lo que reporta **no es «tiene remoto» sino si alguno de sus
|
||
remotos NO es esta máquina** — un remoto que apunta afuera es la prueba de que el código sobrevive.
|
||
En el plan es el **paso 1**, por delante del rescate de binarios: aquello pierde un servicio, esto
|
||
pierde la posibilidad de reconstruirlo. El propio takana ya resuelve esto con `pushurl` doble
|
||
(`scripts/espejo-setup.sh`), así que el arreglo está escrito.
|
||
|
||
### 4.0 quaterdecies · Los 13 binarios sueltos son casi un solo árbol
|
||
|
||
De los 13 servicios cuyo binario **nadie provee** —los que se pierden al apagar el origen— **diez
|
||
salen del mismo repositorio**, `tawasuyu` (hoy en `bad13117c`):
|
||
|
||
| binario | paquete del workspace |
|
||
|---|---|
|
||
| `matilda` | `matilda` |
|
||
| `pacha` | `pacha-cli` |
|
||
| `pacha-secretos` | `pacha-secretos` |
|
||
| `sandokan-watch` | `sandokan-seguridad-core` |
|
||
| `shuma-daemon` · `shuma-gateway` | homónimos |
|
||
| `tejido` | `tejido` |
|
||
| `tupu` | `tupu-cli` |
|
||
| `willay-crosscheck` | `willay-cruce` |
|
||
| `willay-daemon` | `willay-daemon` |
|
||
|
||
(Los otros tres son ajenos: `act_runner`, `adb` y el `puerta-…` de `target/debug`.)
|
||
|
||
O sea que **una receta cubre diez servicios**, y el trabajo de escribirla se paga diez veces.
|
||
|
||
#### El tamaño real es una quinta parte del que aparenta
|
||
|
||
El `Cargo.lock` del workspace tiene **2823 crates**, pero eso es el workspace entero —con su stack
|
||
gráfico, audio y Android—. El subárbol que esos diez binarios necesitan de verdad son **524**. Y de
|
||
todos ellos, sólo **cinco traen C**:
|
||
|
||
```
|
||
aws-lc-sys ring ← criptografía: compilan C/asm, necesitan cmake
|
||
dirs-sys inotify-sys netlink-sys ← bindings puros, sin librería externa
|
||
```
|
||
|
||
`aws-lc-sys` y `ring` son los conocidos de esta cadena; los otros tres no cuestan nada.
|
||
|
||
#### ⚠ Lo que hay que decidir antes de escribirla: los dos pines no coinciden
|
||
|
||
`tawasuyu/rust-toolchain.toml` fija **1.96.0**, y lo argumenta en el propio fichero: *una distro que
|
||
promete builds deterministas y atestación firmada no puede tener el compilador flotando*. El lab de
|
||
takana trae **1.97.0**, y es **rodante** por diseño — `lab-toolchain.lock` detecta la deriva, no la
|
||
evita.
|
||
|
||
Así que una receta takana de tawasuyu compilaría con un rustc distinto del que el propio tawasuyu
|
||
exige. No es un detalle de estilo: **el binario resultante no sería el mismo que está corriendo**, y
|
||
rompería el contrato de atestación de tawasuyu. Tres salidas, y es una decisión, no un trámite:
|
||
|
||
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:
|
||
|
||
- su **runtime entra al perfil** — `openclaw` necesita `nodejs` en la imagen pase lo que pase;
|
||
- el **servicio sigue listado como no cubierto**, con su motivo.
|
||
|
||
Meterlo sólo en las raíces haría un perfil que miente; dejarlo sólo en los faltantes armaría una
|
||
imagen sin runtime, y el programa, cuando llegue, no arrancaría.
|
||
|
||
(Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix
|
||
empaqueta `python`, el catálogo tiene `python3.toml`. Sin ese alias, tres servicios decían «hace
|
||
falta una receta takana» teniendo el runtime sellado — dos trabajos muy distintos.)
|
||
|
||
#### Sellada y sin sellar no son lo mismo
|
||
|
||
El perfil las listaba igual. Una sellada se **instala** del repo firmado; una sin sellar hay que
|
||
**construirla**, y eso puede ser una tarde. Salió con un caso real: `recipes/qdrant.toml` entró al
|
||
catálogo desde otro frente **mientras se escribía esto** y no tiene artefacto. Ahora el perfil lo
|
||
marca en la línea y lo resume al pie.
|
||
|
||
### 4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA
|
||
|
||
El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir:
|
||
`/proc/<pid>/exe` termina en `(deleted)` para **cuatro servicios de gioser**. El fichero ya no está
|
||
en disco; sólo vive el inodo que sostiene su proceso.
|
||
|
||
Y en tres de los cuatro hay **ahora otro fichero en la misma ruta, de distinto tamaño**:
|
||
|
||
| servicio | corriendo | en su ruta hoy |
|
||
|---|---|---|
|
||
| `tejido` | 12 750 368 B | 13 815 864 B |
|
||
| `shuma-gateway` | 8 431 896 B | 10 363 504 B |
|
||
| `pacha-secretos` | 8 634 240 B | 8 647 456 B |
|
||
| `puerta-f6e393ff…` | 197 262 440 B | *no hay nada* |
|
||
|
||
Copiar la ruta **no falla**: muda otra cosa, y el servicio nuevo no es el que estaba andando. Es el
|
||
modo de fallo más caro que hay en este documento — todo verde, todo distinto.
|
||
|
||
Se recuperan leyendo `/proc/<pid>/exe`, **y sólo mientras el proceso viva**. En una mudanza que
|
||
termina *borrando el origen*, un reinicio de gioser antes de este paso pierde esos binarios para
|
||
siempre. Por eso el rescate va **antes del preflight**: es el único paso del plan que puede volverse
|
||
imposible mientras se piensa el resto.
|
||
|
||
Su verificación **compara tamaños** contra el que corre, y no por celo: un `cat` de un `/proc` que ya
|
||
no existe crea un fichero **vacío** y devuelve 0. Probado en los dos sentidos — el paso real pasa, y
|
||
con un fichero vacío a propósito el guardián dice `FALTA tejido` y sale 1.
|
||
|
||
Los cuatro ya están rescatados en `work/mudanza/rescate/`, con su `SHA256SUMS`.
|
||
|
||
### 4.0 decies · Leer la config del servidor: un fósil se detecta SIN tocar la red
|
||
|
||
La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el
|
||
**fichero de configuración**, y leerlo no toca al origen — ni una petición, ni riesgo de fail2ban.
|
||
|
||
Para leerlo se agregó un **lector de Caddy** al centro (`formatos/caddy.py`, que ya tenía el
|
||
escritor), y el censo lo usa en vez de tener su propio parser a medias: los lectores de nginx y
|
||
apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note.
|
||
|
||
El resultado inmediato, sobre el Caddyfile real de gioser — **cinco sitios apuntan a un `root` que no
|
||
existe**, y son justo los que devolvían los 502 que en su día hicieron que el censo *se baneara a sí
|
||
mismo* al sondearlos por HTTP:
|
||
|
||
```
|
||
aura.gioser.net → /var/www/aura_frontend NO EXISTE
|
||
sigma.gioser.net → /var/www/sigma/frontend NO EXISTE
|
||
summa.gioser.net → /var/www/summa/frontend NO EXISTE
|
||
kosmofono.gioser.net → /var/www/kosmofono/frontend NO EXISTE
|
||
dev.summa.gioser.net → /var/www/summa-dev/frontend NO EXISTE
|
||
```
|
||
|
||
Es evidencia **más fuerte que el DNS**: no hay nada que servir, devuelve 502 resuelva donde resuelva.
|
||
|
||
#### Y sirve para lo contrario, que es donde el aviso hacía daño
|
||
|
||
Un directorio que la config **sí** referencia no es un huérfano, diga lo que diga su nombre. Sin
|
||
esto, el plan marcaba `/var/www/git-tawasuyu` como «nadie lo recuerda» estando servido — y ese aviso,
|
||
aplicado, **tira `git.tawasuyu.net`**.
|
||
|
||
La primera versión del lector seguía fallando en eso, y el motivo es instructivo: el `root` de ese
|
||
sitio vive **dentro de un `handle`**, y yo saltaba los bloques anidados enteros por no saber
|
||
modelarlos. Que el pivote no sepa **modelar** algo no es razón para no **verlo**: ahora el bloque
|
||
sigue marcado SIN-TRADUCIR, pero sus `root` y `reverse_proxy` se leen. Con eso aparecieron dos raíces
|
||
ausentes más, que también estaban anidadas.
|
||
|
||
#### Falsos sitios: una lista negra no alcanzaba
|
||
|
||
Detectar la cabecera de un sitio descartando directivas conocidas (`handle {`, `route {`, `@bot {`)
|
||
dejaba pasar `log { output file … {` y el snippet `(acceso) {` — **dos dominios inventados** que el
|
||
censo habría puesto a decidir. La regla que aguanta es positiva: *todos* los tokens de la cabecera
|
||
tienen que **parecer una dirección**.
|
||
|
||
### 4.0 nonies · La cadena entera, y el preflight que medía contra el sistema de ficheros equivocado
|
||
|
||
`censar → planear → aplicar` corrió de punta a punta contra gioser: **59 pasos**, 20 ejecutables y 39
|
||
manuales, correctamente separados — el aplicador marca los manuales `pendiente-humano` y la corrida
|
||
no se considera completa.
|
||
|
||
Y ahí el ensayo en seco destapó lo que ninguna prueba de juguete iba a mostrar: **33,6 G de datos
|
||
contra una raíz de 3,5 G libres.**
|
||
|
||
```
|
||
/dev/root 3,5 G libres /
|
||
/dev/sdb 19,0 G libres /store
|
||
/dev/sda4 66,2 G libres /work
|
||
```
|
||
|
||
Dos cosas estaban mal a la vez:
|
||
|
||
- **El plan copiaba ruta → misma ruta**, sin forma de decir dónde cae cada árbol en el destino. Ahora
|
||
`[[datos]]` tiene `destino` (vacío = la misma ruta), y el fallo que evita es caro: aparecía a mitad
|
||
de un `rsync` de 22 G.
|
||
- **El preflight sumaba todo y lo comparaba contra `/`.** Eso acierta *por casualidad* mientras todo
|
||
caiga en `/`, y da un veredicto **completamente falso** en cuanto una ruta va a otro montaje — en
|
||
las dos direcciones. Ahora mide **por sistema de ficheros**, agrupando los destinos, y el veredicto
|
||
lo saca el propio comando en vez de un humano leyendo una columna:
|
||
|
||
```
|
||
✗ / necesita 34386 MiB · libres 3596 ⇐ /var/www /var/lib /srv /opt /home
|
||
NO ENTRA en /: faltan 30790 MiB (exit 1)
|
||
|
||
✓ /work necesita 34386 MiB · libres 66192 ⇐ /work/var/www …/work/home (exit 0)
|
||
```
|
||
|
||
Los dos controles corridos contra la caja de verdad: el negativo falla y el positivo pasa.
|
||
|
||
### 4.0 octies · `/proc` es un formato de origen más — y había DOS emisores de tarjetas
|
||
|
||
`declarar.py` tenía su propio molde de card de arje, y `formatos/arje.py` el suyo. Dos emisores del
|
||
mismo formato es exactamente el N×M que el pivote existe para evitar, y no quedó en teoría: **cada
|
||
copia tenía un campo mal de la raíz, y ninguno de los dos el mismo.**
|
||
|
||
| campo de la raíz | `declarar.py` | `formatos/arje.py` | la semilla REAL del producto |
|
||
|---|---|---|---|
|
||
| `provides` | `["Spawn","Journal"]` ✓ | `[]` ✗ | `["Spawn","Journal"]` |
|
||
| `supervision` | `Restart{…}` ✗ | `"OneShot"` ✓ | `"OneShot"` |
|
||
|
||
Las dos consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni
|
||
dónde escribir; y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
|
||
|
||
Ahora hay un lector `proc` —el censo es un formato de origen, igual que systemd u OpenRC— y el
|
||
escritor `arje` es el único que emite tarjetas. `declarar.py` queda con lo suyo: el **perfil**.
|
||
|
||
```
|
||
familia «servicio»: systemd ─┐
|
||
openrc ─┼─► Servicio/Sistema ──► arje
|
||
proc ─┘
|
||
```
|
||
|
||
Los tres se complementan y ninguno solo alcanza: systemd y OpenRC leen **lo declarado**, que es lo
|
||
que miente —`rc-status` daba `stopped` para cinco servicios vivos—, y `proc` lee **lo que corre**,
|
||
que es lo único donde aparecen los 15 `no-declarado`.
|
||
|
||
#### Una semilla vacía parecería un éxito
|
||
|
||
Si ningún servicio tiene `decision = "muda"`, el lector `proc` **no devuelve cero tarjetas en
|
||
silencio**: lo dice y sale ≠0. Es el mismo modo de fallo que un artefacto vacío en el store — un
|
||
ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
|
||
|
||
#### El acta se estaba ahogando en su propio ruido
|
||
|
||
La primera versión anotaba el `envp` vacío **una vez por servicio**: 26 líneas idénticas que tapaban
|
||
los dos hallazgos que sí eran de un servicio concreto —`shuma-daemon` corre como `sergio`, y los que
|
||
no tienen línea de comando—. La limitación es del *lector* y vale igual para todos, así que va **una
|
||
entrada nombrando a los 26**. Un acta donde casi todo es la misma línea se deja de leer, y entonces
|
||
no queda ningún acta. Lo mismo con la supervisión. Y al revés: las entradas que sí son por servicio
|
||
ahora lo **nombran** (`gitea: cwd=/var/lib/gitea`), porque cuatro `WorkingDirectory=/home/sergio`
|
||
sin nombre obligan a buscar cuál es cuál.
|
||
|
||
### 4.0 septies · El censo real de gioser, y dos huecos que destapó
|
||
|
||
Corrido contra `204.168.193.248` — que es `gioser.net`, identificado **por IP y no por hostname**,
|
||
porque `hostname` dice `momento`:
|
||
|
||
```
|
||
── servicios ── ── dominios (sonda DNS) ──
|
||
vivo 19 apunta-aca 12
|
||
⚠ no-declarado 18 ya-mudado 4
|
||
declarado-muerto 85 fosil-sin-dns 13
|
||
```
|
||
|
||
#### Tres nombres equivocados, y los tres llegaban al plan
|
||
|
||
El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
|
||
clasificación —no cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<nombre>` y el
|
||
que va de `label` en la tarjeta de arje.
|
||
|
||
- **`comm` está capado a 15 caracteres.** `willay-crosscheck` llegaba como `willay-crossche`, y con
|
||
ese nombre no casaba contra su declaración ⇒ figuraba como **`no-declarado`** teniendo su tarjeta
|
||
en la semilla de arje. La línea de comando trae el nombre entero.
|
||
- **`supervise-daemon` no es un servicio, es un envoltorio.** Los cinco de gioser se fundían en UNA
|
||
entrada llamada `supervise-daemo`; y del otro lado, `dbus`, `metalog`, `dhcpcd`, `squid` y
|
||
`shuma-daemon` salían como **`declarado-muerto` estando vivos** — el mismo agujero que el censo
|
||
existe para tapar, entrando por la otra puerta. El nombre real es su `argv[1]`; el **comando** real
|
||
es el último token antes del `--` suelto.
|
||
- **`head -15` con `ppid==1`** era un resto de tubería reparentado, contado como servicio
|
||
`no-declarado`. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
|
||
|
||
#### «No se pudo leer su binario» eran dos cosas distintas
|
||
|
||
7 de 38 servicios salían `desconocido`. La causa no era una: muchos demonios **reescriben su
|
||
`argv[0]`** (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
|
||
comando no dice cuál es el binario — pero `/proc/<pid>/exe` sí, y necesita ser dueño o root. Con el
|
||
censo corriendo como root, los 7 se resuelven:
|
||
|
||
```
|
||
uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
|
||
root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
|
||
```
|
||
|
||
Así que el censo ahora **dice cuál de las dos cosas pasó**: «repetilo con sudo» y «averigualo a mano»
|
||
son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
|
||
|
||
Y desenvolver al supervisor destapó un dato que se estaba perdiendo: `shuma-daemon` corre con
|
||
`--user sergio`. La tarjeta de arje **no tiene campo de usuario**, así que sin eso a la vista un
|
||
servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión.
|
||
|
||
#### El HTTP toca al origen; el DNS no
|
||
|
||
Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso
|
||
el censo en local los salteaba, y quedaban 29 de 66 ítems **sin recomendación**. Pero lo que dispara
|
||
la jaula es el HTTP: `getent` le pregunta al DNS, no al servidor. Con `--probe-dns` se sondea sólo el
|
||
nombre, **sin una sola petición a gioser**, y eso ya contesta la pregunta que más pesa: cuáles siguen
|
||
apuntando acá y cuáles son fósiles. 29 de 29 clasificados; sólo **12 hay que reapuntar**.
|
||
|
||
Lo que el DNS solo no puede decir es si el backend contesta, así que esos quedan en `apunta-aca`
|
||
— clase propia, **no `vivo`**. Decir «vivo» sin haber pedido una página sería la respuesta falsa con
|
||
forma de respuesta que este censo existe para evitar.
|
||
|
||
#### Los fósiles quedaban fuera de la lista, y por eso sus datos viajaban
|
||
|
||
`entradas()` filtraba los `fosil-sin-dns` con el argumento de que un dominio sin DNS no necesita
|
||
ningún paso. Es cierto para el DNS y **falso para lo que arrastra**: son 13 de 29, y `terapeuta.ec`
|
||
tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran **invisibles**, así
|
||
que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — exactamente el «mudar
|
||
fósiles» que este plan existe para evitar, y contra la regla 2, que dice que lo que muere se nombra
|
||
**antes** de borrar nada.
|
||
|
||
### 4.0 sexies · El centro ENGANCHADO al plan
|
||
|
||
El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Es cierto y
|
||
es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora ese
|
||
párrafo es **un paso con su comando literal**:
|
||
|
||
```
|
||
▸ traducir declaración de caddy (openrc → arje) [verifica: humano]
|
||
scripts/mudanza/traducir.py --from openrc --to arje \
|
||
--in /etc/init.d/caddy --out caddy.card.json
|
||
|
||
▸ traducir configuración de nginx → caddy [verifica: humano]
|
||
scripts/mudanza/traducir.py --from nginx --to caddy \
|
||
--in /etc/nginx/nginx.conf --out Caddyfile
|
||
```
|
||
|
||
Dos traducciones distintas por servicio, y confundirlas es caro: la **declaración** (cómo se levanta)
|
||
y la **configuración** (qué hace). Un servicio que se muda a sí mismo sólo necesita la primera —
|
||
ofrecerle traducir su config sería inventarle trabajo.
|
||
|
||
Los pares se le **preguntan al registro de plugins**, no se listan en `planear.py`: una lista propia
|
||
se desincronizaría del centro y el plan ofrecería una traducción que no existe. Cuando no hay par, el
|
||
paso lo dice y manda a `--list`.
|
||
|
||
Y la verificación es **humana**, a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir,
|
||
así que dar el paso por bueno por su código de salida sería exactamente al revés de lo que hay que
|
||
mirar. Lo que este paso verifica es que alguien **leyó el acta**.
|
||
|
||
#### ⚠ El plan que generábamos no era TOML válido
|
||
|
||
Salió al meter el primer comando multilínea, pero estaba latente desde el principio. Dos modos de
|
||
fallo, y el segundo es el peor:
|
||
|
||
| | cadena básica `"""` | cadena literal `'''` |
|
||
|---|---|---|
|
||
| `awk "\$1==1"` (el `verifica` de todo servicio) | **ilegible**: `Unescaped '\'` | parsea idéntico |
|
||
| `cmd --from x \` + salto | parsea… y **se come el salto y la sangría** | parsea idéntico |
|
||
|
||
El primero rompe ruidosamente. El segundo **parsea y miente**: el plan se lee bien y el comando que
|
||
sale no es el que se escribió. El plan es el producto —se revisa, se versiona, se lleva a otro
|
||
proveedor y se vuelve a correr—, así que uno que no vuelve a parsear no sirve para ninguna de las dos
|
||
cosas para las que existe.
|
||
|
||
Arreglado usando la cadena literal, y con un guardián que **escribe y relee** antes de tocar el
|
||
disco: si el TOML generado no parsea, no se escribe nada. Cuesta milisegundos y convierte un fallo
|
||
diferido —que aparecía cuando alguien iba a *ejecutar* el plan— en uno inmediato.
|
||
|
||
### 4.0 quinquies · El CENTRO de traducción — pivote y plugins
|
||
|
||
Cambiar de nginx a caddy no es cambiar un binario: es **reescribir la configuración**. `traducir.py`
|
||
hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
|
||
|
||
#### ⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE
|
||
|
||
Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son **20 traductores**,
|
||
y cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un
|
||
formato nuevo cuesta uno o dos plugins y **estrena todos los pares de golpe**.
|
||
|
||
```
|
||
formatos/nginx.py ─┐ ┌─► formatos/caddy.py
|
||
formatos/apache.py ─┼─► modelo pivote ───┤
|
||
(el próximo) ─┘ Config/Sitio/Ruta └─► (el próximo)
|
||
```
|
||
|
||
**Probado, no argumentado**: el lector de apache se agregó **sin tocar el escritor de Caddy**, y con
|
||
eso `apache → caddy` funciona sin que exista ningún traductor apache→caddy. Las dos salidas las
|
||
valida el caddy del propio corpus:
|
||
|
||
```
|
||
apache → caddy : Valid configuration
|
||
nginx → caddy : Valid configuration
|
||
```
|
||
|
||
Un formato se agrega dejando un fichero en `formatos/` con `@lector("x")` y/o `@escritor("y")`. El
|
||
centro los **descubre**: no hay una lista que editar, porque una lista mantenida a mano se
|
||
desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
|
||
habilita y cuántos plugins costaron.
|
||
|
||
#### El pivote es uno POR FAMILIA
|
||
|
||
Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el
|
||
«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro
|
||
**empareja sólo dentro de la familia**:
|
||
|
||
| familia | modelo | lectores | escritores |
|
||
|---|---|---|---|
|
||
| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
|
||
| `servicio` | `Servicio` / `Sistema` | systemd, openrc | arje |
|
||
|
||
Un par cruzado se rechaza con un error, no con un intento:
|
||
|
||
```
|
||
$ traducir.py --from systemd --to caddy …
|
||
✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
|
||
No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.
|
||
```
|
||
|
||
#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema
|
||
|
||
El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
|
||
real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio
|
||
**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie
|
||
vuelve a leer**. Sale como SIN-TRADUCIR, con su línea.
|
||
|
||
Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce
|
||
⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que
|
||
lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del
|
||
humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un
|
||
servicio **menos confinado** que el original.
|
||
|
||
Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
|
||
semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
|
||
tarjeta real de `sshd`**, más `_mudanza` de procedencia.
|
||
|
||
#### `openrc → arje`: casi la mitad de los servicios NO son declaraciones
|
||
|
||
Un unit de systemd se lee. Un servicio de OpenRC es un **script de shell** que puede hacer cualquier
|
||
cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real de gioser —la máquina que esta
|
||
mudanza borra— **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que
|
||
valga: leer las variables y emitir una tarjeta produciría un servicio que arranca *otra cosa*.
|
||
|
||
Por eso la regla va al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR y no se emite
|
||
tarjeta**. Los números del corpus real cierran solos:
|
||
|
||
```
|
||
ficheros en /etc/init.d : 121
|
||
no son openrc-run : 1
|
||
con start()/stop() propia : 60 → no se emite tarjeta
|
||
declarativos sin command= : 1 → no hay qué arrancar
|
||
⇒ traducibles : 59 ← y el lector emitió exactamente 59
|
||
```
|
||
|
||
Lo otro que OpenRC obliga a ir a buscar es **`/etc/conf.d/<servicio>`**: el servicio está partido en
|
||
dos ficheros y **los argumentos de verdad viven en el segundo**. A diferencia del `EnvironmentFile=`
|
||
de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático **no lo
|
||
dice el script** sino el enlace en `/etc/runlevels/`.
|
||
|
||
#### Tres defectos que sólo aparecieron corriendo contra las 121 de verdad
|
||
|
||
- **`name=` no es un identificador, es un rótulo**: en gioser vale `Aura Backend`, con espacio — que
|
||
se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
|
||
además es lo que usan `rc-update` y `/etc/runlevels`.
|
||
- **Ids repetidos**: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos, y son
|
||
scripts de OpenRC válidos. Dos servicios con el mismo nombre dan **el mismo id determinista** ⇒ una
|
||
semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
|
||
- **Procedencia cableada**: la tarjeta decía `"desde": "systemd"` viniendo de OpenRC. El campo que
|
||
existe para saber de dónde salió algo era el que mentía.
|
||
|
||
#### Lector y escritor no opinan del mismo campo
|
||
|
||
`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía
|
||
dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector
|
||
registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de
|
||
un `0` que obliga a buscar a mano lo que ya se sabía.
|
||
|
||
#### El acta viaja DENTRO del modelo
|
||
|
||
Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que
|
||
un **lector** no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor
|
||
la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.
|
||
|
||
Y el escritor puede **agregarle**: Caddy no tiene equivalente directo de `location ~`, así que en vez
|
||
de emitir un `handle` de prefijo —que *se parece* y no es lo mismo— lo manda al acta. **Parecerse es
|
||
peor que faltar.**
|
||
|
||
#### Doctrina heredada de `soltar`/`paskaq` (tawasuyu)
|
||
|
||
`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, `.mdb`). El dominio es otro
|
||
—datos, no configuración— pero sus principios son exactamente los que hacía falta, y se adoptan tal
|
||
cual:
|
||
|
||
- **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá cada directiva no
|
||
entendida sale con su línea y su texto. **Un traductor que descarta en silencio te deja un servidor
|
||
sin una redirección o sin una regla de auth, y el sitio parece funcionar.**
|
||
- **No adivinar en silencio**: toda heurística queda como decisión explícita, no aplicada de tapadillo.
|
||
- **Procedencia total**: cada bloque del resultado dice de qué línea del original salió.
|
||
- **Determinista**: mismo fichero, misma salida.
|
||
|
||
Y lo que NO es: un traductor completo de nginx. Traduce el núcleo común y **declara todo lo demás**.
|
||
*Uno honesto que cubre el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el
|
||
100 % es una trampa.*
|
||
|
||
#### ⚠ Validar con el binario real encontró dos bugs, y uno cambiaba el sentido
|
||
|
||
Leer la salida no alcanzaba. Pasarla por `caddy validate` —con el caddy del propio corpus— destapó:
|
||
|
||
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:
|
||
|
||
- **ejecutable** — tiene comandos de verdad (copiar, comprobar espacio);
|
||
- **manual** — su `cmd` son COMENTARIOS («instalá el paquete», «cambiá el registro A»). No hay nada
|
||
que correr, y **un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y lo marca «ok»**.
|
||
Ésa es la peor mentira posible: deja el servicio caído con el informe en verde.
|
||
|
||
Acá un paso manual queda `pendiente-humano`, la corrida **no** se considera completa (sale con ≠0), y
|
||
se confirma con `--hecho N` — que además **se niega** si el paso sí tenía comandos: «corrélo, no lo
|
||
marques».
|
||
|
||
**Se le cree a la verificación, no al exit code.** El `rsync` que llenó el disco devolvió **0** y
|
||
dejó 1367 artefactos vacíos. Un paso pasa a `ok` sólo si su verificación pasa; si el comando salió
|
||
bien y la verificación falló, queda `sospechoso`, que se informa como peor que un fallo.
|
||
|
||
**Y las verificaciones están TIPADAS** (`cmd` / `humano`): «la columna Available debe ser > X» no es
|
||
un comando, y darla por buena porque «no dio error» es exactamente cómo un aplicador miente.
|
||
|
||
**Reanudable**: el estado vive en `<plan>.estado.json`, escrito con temporal + `fsync` + `rename` —
|
||
la misma lección que costó un upgrade entero en el SDD 28 §6.13. Re-correr saltea lo ya hecho.
|
||
|
||
#### ⚠ Y una verificación que era decorativa
|
||
|
||
La primera versión del paso de datos verificaba así:
|
||
|
||
```
|
||
ssh … 'du -sh <p>; find <p> -maxdepth 1 -type d -empty | wc -l'
|
||
```
|
||
|
||
Imprimía el número de vacíos **y devolvía 0 igual**. En la primera corrida real dio «✓ verificado: 2»
|
||
— donde ese 2 eran dos directorios vacíos en destino. Un guardián que siempre pasa no es un guardián.
|
||
|
||
Ahora **compara**: cuenta los ficheros de los dos lados y falla si no coinciden. Probado con rotura a
|
||
propósito y con el control que tiene que pasar:
|
||
|
||
```
|
||
copia NO hecha → ficheros: origen=5 destino=0 ⇒ FALLA
|
||
copia hecha → ficheros: origen=5 destino=5 ⇒ PASA
|
||
```
|
||
|
||
Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
|
||
|
||
1. **Idempotente y reanudable.** Cada copia grande se cortó al menos una vez.
|
||
2. **Verifica en DESTINO, no en origen.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
|
||
dejó 1367 artefactos vacíos y sólo se vio contando en el destino.
|
||
3. **Los servicios `no-declarado` se declaran**, no se replican a mano. Es la salida natural hacia
|
||
`arje-absorb`, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero **leyendo la
|
||
declaración, que es justo la que miente**: hay que alimentarlo con el censo.
|
||
4. **Deja el servidor corriendo**, y lo prueba desde afuera: cada dominio `vivo` del plan tiene que
|
||
contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha.
|
||
|
||
## 5. La imagen, independiente del proveedor
|
||
|
||
Lo que hoy la ata a Hetzner es poco y está medido:
|
||
|
||
- **Arranque: ✅ RESUELTO (2026-09-11).** La imagen ahora arranca por **BIOS y por UEFI**, y es la
|
||
misma imagen. Layout: `p1` BIOS-boot · `p2` ESP FAT32 · `p3` `/` · `p4` estado · `p5` store.
|
||
|
||
**Un solo `grub.cfg`**: el `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan los dos a
|
||
`(hd0,gpt3)/boot/grub`, así que hay una sola línea de comando y un solo sitio donde editarla. Dos
|
||
imágenes hermanas obligarían a elegir por proveedor y a que divergieran — y ya divergían: la
|
||
hermana EFI documentaba etiquetas `takana-*` que su propio código no escribe (corregido).
|
||
|
||
**No se usa EFI-stub directo acá** (sí lo hace `install-image-efi.sh`, ADR 0010): el stub arrancado
|
||
por la ruta fallback recibe LoadOptions VACÍO, así que necesita la cmdline HORNEADA en el kernel, y
|
||
la de `linux-generic` sólo trae la consola — sin `root=`. Hornearla ataría la línea de comando al
|
||
ArtifactHash del kernel. Con GRUB EFI la cmdline vive en el `grub.cfg`, que es donde se edita.
|
||
|
||
**Verificado con la MISMA imagen en los dos firmwares**, hasta SSH:
|
||
|
||
| | UEFI (OVMF) | BIOS (SeaBIOS) |
|
||
|---|---|---|
|
||
| `/sys/firmware/efi` | **presente** | ausente |
|
||
| PID 1 | `arje-zero` | `arje-zero` |
|
||
| raíz · store | `sda3` · `sda5` | `sda3` · `sda5` |
|
||
- **Red**: `netup` hace DHCPv4 y ya cubre el caso difícil (IP `/32` con la puerta fuera del prefijo,
|
||
que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores.
|
||
- **Instalación**: `rescue → dd → reboot` no es de Hetzner: sirve en cualquier entorno de rescate con
|
||
SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor.
|
||
- **Disco**: ya se resuelve solo — el store es la última partición y crece al disco entero en el
|
||
primer arranque.
|
||
|
||
⇒ **la imagen está más cerca de ser portable que la herramienta de mudarse.** Por eso el orden es
|
||
censo → plan → aplicación, y la portabilidad de la imagen va en paralelo.
|