El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<n>` 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 `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon`
salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar,
entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token
antes del `--` suelto (comprobado contra los cinco).
· `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a
`descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
**Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** 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 — `/proc/<pid>/exe` sí, y necesita ser dueño o root. Medido:
uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy
distintos, y confundirlos manda a alguien a investigar un permiso.
Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era
`/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado
era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que
ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de
arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a
la vista, un servicio que acá corre sin privilegios termina de root en el destino.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
673 lines
38 KiB
Markdown
673 lines
38 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 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.
|