planear: el centro de traducción ENGANCHADO al plan — y el plan que emitíamos no era TOML válido

El plan ya advertía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Cierto, y
la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora es un PASO
con su comando literal: `traducir.py --from openrc --to arje --in /etc/init.d/caddy …`.

Dos traducciones distintas por servicio, y confundirlas es caro: la DECLARACIÓN (cómo se levanta,
`openrc`/`systemd` → tarjeta de arje) y la CONFIGURACIÓN (qué hace, sólo si se eligió un
equivalente). Un servicio que se muda a sí mismo no necesita la segunda: ofrecérsela es inventarle
trabajo. Los pares se le PREGUNTAN al registro de plugins, no se listan acá — una lista propia se
desincroniza del centro y el plan ofrecería una traducción que no existe. Sin par, el paso lo dice.

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 al revés de lo que hay que mirar. Lo que verifica
es que alguien LEYÓ el acta.

**Y en el camino salió un fallo del producto: el plan no era TOML válido.** Apareció con el primer
comando multilínea, pero estaba latente desde el principio y tiene DOS modos:

  · `awk "\$1==1"` —que está en el `verifica` de TODO servicio— con cadena básica da
    `Unescaped '\' in a string`: el plan queda ILEGIBLE.
  · `cmd --from x \` + salto: la cadena básica PARSEA y se come el salto y la sangría ⇒ el comando
    que sale NO es el que se escribió, sin que nada falle. Ése es el peor.

El plan es el producto: se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr. Uno
que no vuelve a parsear no sirve para ninguna de las dos cosas para las que existe. Arreglado con
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. Convierte un fallo diferido —aparecía cuando alguien iba a EJECUTAR el
plan— en uno inmediato.

Probado de punta a punta: el comando que el plan emite, copiado tal cual, traduce el
`/etc/init.d/caddy` real de esta máquina a una tarjeta con `Restart{initial:3000}` (de su
`respawn_delay=3`) y reporta la única `reload()` que no se puede portar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 20:49:52 +00:00
co-authored by Claude Opus 5
parent 07cc386580
commit 3aab82d6f6
2 changed files with 174 additions and 3 deletions
+47
View File
@@ -301,6 +301,53 @@ reconstruyen el servidor desde cero**:
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 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`
+127 -3
View File
@@ -22,13 +22,23 @@ herramienta o a mano, línea por línea, sin la herramienta y sin este repo.
caché regenerable que llenó un disco.
· **Los servicios `no-declarado` no se copian: se DECLARAN.** Copiar un binario no lo levanta al
arrancar — el caddy de la caja nueva murió en el primer reinicio por exactamente eso.
· **La traducción de la configuración es un PASO, no una advertencia.** Decir «cambiar de servidor
web obliga a reescribir la config» es cierto y deja al humano con un párrafo y ninguna herramienta.
El plan emite el comando literal de `traducir.py` cuando el par EXISTE —se le pregunta al registro
de plugins, no se lista acá— y dice que no existe cuando no. Su verificación es HUMANA: el
traductor sale ≠0 si algo quedó sin traducir, así que darlo por bueno por código de salida sería
justo al revés de lo que hay que mirar.
Uso:
scripts/mudanza/planear.py --censo censo.toml --target root@1.2.3.4 [--decide] --out plan.toml
"""
import argparse, os, sys, shlex, time, tomllib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import formatos # noqa: E402
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_AQUI = os.path.join("scripts", "mudanza", "traducir.py")
# ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
@@ -121,6 +131,77 @@ FAMILIAS = {
}
# ── enganche con el CENTRO DE TRADUCCIÓN ────────────────────────────────────────────────────────
# El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es
# cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta.
# Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad:
# los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se
# desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo.
# Dónde vive la declaración del servicio en cada init de origen.
_RUTA_UNIDAD = {"openrc": "/etc/init.d/{n}",
"systemd": "/etc/systemd/system/{n}.service"}
# Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que
# nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se
# adivina — se dice que hay que ubicarlo a mano.
_RUTA_CONFIG = {"nginx": "/etc/nginx/nginx.conf",
"apache": "/etc/apache2/apache2.conf",
"httpd": "/etc/httpd/conf/httpd.conf",
"caddy": "/etc/caddy/Caddyfile"}
def traducciones(s):
"""Comandos de traducción que le corresponden a un servicio que se muda.
Dos cosas distintas, y confundirlas es un error caro:
· la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje;
· la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy.
Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer
traducirla sería inventar trabajo."""
lectores, escritores = formatos.cargar()
out = []
# 1) la declaración → tarjeta de arje (el init del destino)
if "arje" in escritores:
for fuente in s.get("declared_in", []):
if fuente in lectores and fuente in _RUTA_UNIDAD:
ruta = _RUTA_UNIDAD[fuente].format(n=s["name"])
out.append({
"que": f"declaración de {s['name']} ({fuente} → arje)",
"cmd": f"{_AQUI} --from {fuente} --to arje \\\n"
f" --in {ruta} --out {s['name']}.card.json",
"nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con "
"código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, "
"es el acta pidiendo que la leas."})
break
# 2) la configuración → sólo si se cambia de servicio
alt = s.get("alternativa")
if alt and alt != s["name"]:
desde, hacia = s["name"].lower().replace("_", "-"), alt.lower()
if desde in lectores and hacia in escritores and \
lectores[desde][1] == escritores[hacia][1]:
org = _RUTA_CONFIG.get(desde, f"<config de {desde}: ubicala>")
dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
out.append({
"que": f"configuración de {desde}{hacia}",
"cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n"
f" --in {org} --out {os.path.basename(dst)}",
"nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar "
"sin resolver el acta es desplegar un servidor incompleto que además "
"parece funcionar."})
else:
# No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada.
out.append({
"que": f"configuración de {desde}{hacia}",
"cmd": f"# NO hay traductor `{desde}{hacia}` en el centro.\n"
f"# Ver los que sí hay: {_AQUI} --list\n"
f"# Hay que reescribir la configuración a mano.",
"nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."})
return out
def _receta(nombre, repo):
"""(existe, sellado) para un nombre de receta."""
import glob
@@ -389,6 +470,27 @@ def validar(censo):
# ── plan ────────────────────────────────────────────────────────────────────────────────────────
_TRIPLE_SIMPLE = "'" * 3
_TRIPLE_DOBLE = '"' * 3
def bloque_toml(texto):
"""Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
(`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
if _TRIPLE_SIMPLE not in texto:
return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
def pasos(censo, target, key):
ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
if key:
@@ -439,6 +541,15 @@ def pasos(censo, target, key):
# ── 2. servicios ────────────────────────────────────────────────────────────────────────────
for s in svc_muda:
nd = s.get("class") == "no-declarado"
# Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no
# metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py`
# sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería
# justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta.
for tr in traducciones(s):
paso("traducir", f"traducir {tr['que']}", tr["cmd"],
verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es"
f" trabajo a mano que el destino NO va a hacer solo.",
verifica_tipo="humano", nota=tr["nota"])
paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""),
((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n"
f"# Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n"
@@ -504,12 +615,12 @@ def emitir(censo, P, target):
L.append(f'n = {i}')
L.append(f'clase = "{p["clase"]}"')
L.append(f'titulo = """{p["titulo"]}"""')
L.append(f'cmd = """\n{p["cmd"]}\n"""')
L.append("cmd = " + bloque_toml(p["cmd"]))
if p["verifica"]:
L.append(f'verifica = """\n{p["verifica"]}\n"""')
L.append("verifica = " + bloque_toml(p["verifica"]))
L.append(f'verifica_tipo = "{p["verifica_tipo"]}"')
if p["nota"]:
L.append(f'nota = """{p["nota"]}"""')
L.append("nota = " + bloque_toml(p["nota"]))
L.append("")
return "\n".join(L) + "\n"
@@ -543,6 +654,19 @@ def main():
for c, n in por_clase.items():
print(f" {c:10} {n:3} paso(s)")
txt = emitir(censo, P, a.target)
# ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ──────────────────────
# No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea
# rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino
# DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y
# releer cuesta milisegundos y convierte un fallo diferido en uno inmediato.
try:
tomllib.loads(txt)
except tomllib.TOMLDecodeError as e:
sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n"
f" No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n"
f" exportar, que son las dos únicas cosas para las que existe.")
if a.out:
open(a.out, "w").write(txt)
print(f"\n plan → {a.out} ({len(P)} pasos, con su comando literal cada uno)")