Tres cosas, y las tres quitan vueltas. 1. NO HACE FALTA NINGUNA CREDENCIAL EN EL WORKER, Y TAMPOCO UN USUARIO NUEVO `tawasuyu/tawasuyu` y `sergio/takana` son repos PÚBLICOS en el gitea. Las 23 recetas que apuntaban a `gitea@git.tawasuyu.net` (SSH, que exige la clave que es el SSH de todo) pasan a `https://git.tawasuyu.net/…`. La URL es locator y NO entra en `hash_inputs` (ADR 0013): hecho con control antes/después, **ningún ArtifactHash se movió**. Medido en el worker: `git clone --mirror --filter=blob:none` + `git archive` extrae el árbol (155 M) **sin una sola credencial**. Un usuario propio en gitea sólo haría falta para un repo PRIVADO; hoy ninguna receta usa uno. Si mañana hace falta, es una cuenta de máquina con acceso al repo que toque — nunca la clave personal. De paso, tres recetas decían «HUB-ONLY: el worker secretless recibe Connection refused». Ya no es cierto y el comentario decía lo contrario de lo que pasa: corregido en las tres. 2. LA COPIA FUERA LA DA LA MUDANZA, NO GITHUB (decisión del usuario) El «paso 1: espejar los 26 repos» deja de ser el paso 1. En cuanto el gitea vive en la caja nueva, esos repos dejan de existir sólo en la máquina que se borra — que es lo que el paso pedía. El objetivo es dejar de pagar dos máquinas, no sumar una dependencia. `espejar-repos.sh` queda como herramienta disponible. 3. EL §9 REESCRITO: qué está hecho, qué falta y en qué orden Hecho y cerrado: la caja arranca takana puro · store/grafos/granja/respaldo · repo firmado · la imagen del perfil servidor con gitea sirviendo 200 supervisado por arje · `arjectl` · y el ensayo con los datos REALES de gioser corriendo sobre takana. Falta, en orden: actualizar la caja a la imagen nueva (`upgrade`, no `dd`) → mudar los datos del gitea (`.backup` + rsync, con el origen parado en el corte) → caddy con los 6 dominios vivos → DNS → verificar desde fuera con un clone real → el resto de servicios del censo → borrar gioser con las 8 puertas en verde. Bloqueantes con su tamaño: `git` sellado sin `remote-http` (afecta a clonar DESDE una caja takana, no al gitea que sirve; re-sella una raíz de `base`) y la puerta 5, que no bloquea la mudanza. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
975 lines
54 KiB
Markdown
975 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
|
||
|
||
> **Corrección de rumbo (2026-09-14, decisión del usuario): la copia fuera NO se consigue espejando
|
||
> a un tercero, se consigue MUDANDO.** En cuanto el gitea vive en la caja nueva, esos 26 repos dejan
|
||
> de existir sólo en la máquina que se borra — que es exactamente lo que este paso pide. El objetivo
|
||
> del frente es dejar de pagar dos máquinas, y montar una dependencia con GitHub va en contra.
|
||
> `espejar-repos.sh` sigue en el repo como herramienta (redundancia extra el día que se quiera), pero
|
||
> **no es el paso 1 del plan**: el paso 1 es mudar el gitea, y el criterio de verificación es el
|
||
> mismo de siempre — clonar de verdad desde el destino antes de apagar nada.
|
||
|
||
|
||
Es el hallazgo más grave de este frente, y va por delante de todo lo demás en el plan. Buscando cómo
|
||
escribir la receta de `tawasuyu` apareció que **su único remoto es el gitea de gioser**
|
||
(`ssh://gitea@git.tawasuyu.net:2345/…`, y `git.tawasuyu.net` resuelve a `204.168.193.248`, que es
|
||
gioser). Al mirar el resto: **28 repositorios con remoto en esta máquina, 26 sin ninguna copia
|
||
fuera.** Sólo `takana` y `llimphi-standalone` tienen espejo externo.
|
||
|
||
Apagar el origen no borra unos servicios: borra **el código con el que se volverían a construir**. Y
|
||
los clones de trabajo están en el mismo disco, que también muere. Entre los 26 está `tawasuyu`, que
|
||
produce **10 de los 13 binarios que nadie más provee** — la dependencia circular completa.
|
||
|
||
No se ve desde ninguna otra parte del censo: un repo no es un proceso, ni un puerto, ni un dominio.
|
||
Se descubre cuando ya no hay de dónde sacarlo.
|
||
|
||
El censo lo mira ahora (`[[repo]]`), y lo que reporta **no es «tiene remoto» sino si alguno de sus
|
||
remotos NO es esta máquina** — un remoto que apunta afuera es la prueba de que el código sobrevive.
|
||
En el plan es el **paso 1**, por delante del rescate de binarios: aquello pierde un servicio, esto
|
||
pierde la posibilidad de reconstruirlo. El propio takana ya resuelve esto con `pushurl` doble
|
||
(`scripts/espejo-setup.sh`), así que el arreglo está escrito.
|
||
|
||
### 4.0 quaterdecies · Los 13 binarios sueltos son casi un solo árbol
|
||
|
||
De los 13 servicios cuyo binario **nadie provee** —los que se pierden al apagar el origen— **diez
|
||
salen del mismo repositorio**, `tawasuyu` (hoy en `bad13117c`):
|
||
|
||
| 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.
|