Cambiar de nginx a caddy es reescribir la configuración. `traducir.py` hace la parte mecánica y dice
CON NÚMERO DE LÍNEA lo que no pudo traducir.
Doctrina heredada de `soltar`/`paskaq` (tawasuyu): su dominio es otro —datos presos en formatos
cautivos— pero sus principios son los que hacían falta. **Elisión honesta**: lo que no se pudo
traducir se reporta con su línea y su motivo, porque 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**: cada heurística queda como decisión explícita. **Procedencia**: cada bloque dice de qué
línea salió.
⚠ **Validar con el caddy real destapó dos bugs que leer la salida no mostraba:**
1. `ambiguous site definition` — en nginx dos `server` con el mismo nombre se distinguen por su
`listen`; en Caddy, por el esquema de la dirección.
2. **El grave**: un `if (...) { return 403; }` salía como `respond 403` INCONDICIONAL — el sitio
entero devolviendo 403. La directiva estaba dentro de un bloque declarado intraducible y se
absorbía igual al de afuera. Es peor que la pérdida silenciosa: no pierde, CAMBIA el sentido.
Ahora todo lo que vive en un bloque opaco sale `SIN-TRADUCIR` con su motivo.
Con los dos arreglados: `Valid configuration` según el caddy del propio corpus.
Y es honesto sobre su alcance: traduce el núcleo común y declara el resto. Uno que cubre el 70 % y
dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
444 lines
25 KiB
Markdown
444 lines
25 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 quinquies · Traducir la configuración — `traducir.py`
|
|
|
|
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**.
|
|
|
|
#### 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.
|