From 3aab82d6f6f171ae89a6832a090ff7d5a8d0fa07 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 20:49:52 +0000 Subject: [PATCH] =?UTF-8?q?planear:=20el=20centro=20de=20traducci=C3=B3n?= =?UTF-8?q?=20ENGANCHADO=20al=20plan=20=E2=80=94=20y=20el=20plan=20que=20e?= =?UTF-8?q?mit=C3=ADamos=20no=20era=20TOML=20v=C3=A1lido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 47 ++++++++++++++ scripts/mudanza/planear.py | 130 ++++++++++++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index ea7a727e..238b99f1 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -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` diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index 737d313a..f111ab6f 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -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"") + dst = _RUTA_CONFIG.get(hacia, f"") + 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)")