> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
traducir: CENTRO de traducción con modelo pivote y plugins — N+M en vez de N×M
Traducir de a pares 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.
**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 de ese par. Las dos salidas las valida el
caddy del propio corpus: `Valid configuration` en las dos.
Los plugins se DESCUBREN (`formatos/` + `@lector` / `@escritor`): una lista mantenida a mano se
desincroniza y el formato nuevo «no existe» sin que nada falle. `--list` dice qué pares habilita.
**El acta viaja DENTRO del modelo**, y es la decisión que hace que el pivote no mienta: si fuera un
efecto de cada traductor, lo que el LECTOR no entendió se perdería antes de llegar al escritor. El
escritor además puede agregarle — Caddy no tiene equivalente de `location ~`, así que en vez de
emitir un `handle` de prefijo que SE PARECE, lo manda al acta. Parecerse es peor que faltar.
Y un bug del lector de apache, encontrado probando: `ProxyPass` tiene dos formas y dentro de
`<Location>` lleva sólo la URL. Mirar sólo la de tres tokens dejaba sin traducir la forma más común y
el acta la reportaba como «no reconocida» — un falso negativo que manda a escribir a mano algo que el
lector sí sabe hacer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 79f7ad17..5ff8c8f9 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -301,11 +301,47 @@ 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 quinquies · Traducir la configuración — `traducir.py`
+### 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 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
diff --git a/scripts/mudanza/formatos/__init__.py b/scripts/mudanza/formatos/__init__.py
new file mode 100644
index 00000000..03fe2fdb
--- /dev/null
+++ b/scripts/mudanza/formatos/__init__.py
@@ -0,0 +1,37 @@
+"""Registro de PLUGINS de formato (SDD 29).
+
+Un formato se agrega dejando un fichero en este directorio que decore sus funciones con `@lector` y
+`@escritor`. No hay que tocar el centro ni ningún otro plugin: ése es el punto de tener un pivote.
+
+ @lector("nginx")
+ def leer(texto) -> Config: ...
+
+ @escritor("caddy")
+ def escribir(cfg) -> str: ...
+"""
+import importlib, pkgutil, pathlib
+
+LECTORES, ESCRITORES = {}, {}
+
+
+def lector(nombre):
+ def deco(f):
+ LECTORES[nombre] = f
+ return f
+ return deco
+
+
+def escritor(nombre):
+ def deco(f):
+ ESCRITORES[nombre] = f
+ return f
+ return deco
+
+
+def cargar():
+ """Importa todos los plugins del directorio. Descubrimiento, no lista cableada: una lista que hay
+ que editar a mano se desincroniza y el formato nuevo 'no existe' sin que nada falle."""
+ for m in pkgutil.iter_modules([str(pathlib.Path(__file__).parent)]):
+ if not m.name.startswith("_"):
+ importlib.import_module(f"{__name__}.{m.name}")
+ return LECTORES, ESCRITORES
diff --git a/scripts/mudanza/formatos/apache.py b/scripts/mudanza/formatos/apache.py
new file mode 100644
index 00000000..8b1601f3
--- /dev/null
+++ b/scripts/mudanza/formatos/apache.py
@@ -0,0 +1,106 @@
+"""Plugin de formato: LECTOR de Apache httpd (SDD 29).
+
+Existe para PROBAR que el pivote paga: con este fichero, `apache → caddy` funciona **sin haber
+escrito un traductor apache→caddy**. El escritor de Caddy no se tocó. Ése es el argumento entero
+contra traducir de a pares — N×M contra N+M.
+"""
+import os, re, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Config, Sitio, Ruta, Accion # noqa: E402
+from formatos import lector # noqa: E402
+
+IRRELEVANTES = {"ServerRoot", "Listen", "LoadModule", "TypesConfig", "ErrorLog", "CustomLog",
+ "LogLevel", "Timeout", "KeepAlive", "User", "Group", "PidFile", "ServerAdmin"}
+
+
+@lector("apache")
+def leer(texto):
+ cfg = Config(formato_origen="apache")
+ A = cfg.acta
+ sitio, ruta = None, None
+
+ for ln, linea in enumerate(texto.split("\n"), 1):
+ s = linea.split("#")[0].strip()
+ if not s:
+ continue
+ m = re.match(r'<(/?)(\w+)([^>]*)>', s)
+ if m:
+ cierra, tag, arg = m.group(1), m.group(2), m.group(3).strip()
+ if tag == "VirtualHost":
+ if cierra:
+ if sitio:
+ cfg.sitios.append(sitio); sitio = None
+ else:
+ sitio = Sitio(origen=ln)
+ puerto = arg.split(":")[-1].strip('"> ')
+ if puerto.isdigit():
+ sitio.puertos.append(puerto)
+ A.anota(ln, "traducido", s)
+ elif tag in ("Directory", "Location", "LocationMatch", "DirectoryMatch"):
+ if cierra:
+ if sitio and ruta:
+ sitio.rutas.append(ruta); ruta = None
+ else:
+ clase = "regex" if tag.endswith("Match") else "prefijo"
+ ruta = Ruta(patron=arg.strip('"'), clase=clase, origen=ln)
+ elif tag in ("IfModule", "Files", "FilesMatch", "Limit"):
+ A.anota(ln, "SIN-TRADUCIR", s,
+ f"`<{tag}>` no tiene traducción mecánica: su efecto depende de qué módulos "
+ f"están cargados y del orden")
+ continue
+
+ p = s.split()
+ cab = p[0] if p else ""
+ destino = ruta if ruta is not None else sitio
+ if sitio is None:
+ if cab in IRRELEVANTES:
+ A.anota(ln, "no-aplica", s, "ajuste global del proceso httpd")
+ else:
+ A.anota(ln, "SIN-TRADUCIR", s, "directiva global no reconocida")
+ continue
+
+ if cab in ("ServerName", "ServerAlias"):
+ sitio.nombres += [x.strip('"') for x in p[1:]]
+ A.anota(ln, "traducido", s)
+ elif cab == "DocumentRoot":
+ sitio.raiz = p[1].strip('"') if len(p) > 1 else ""
+ A.anota(ln, "traducido", s)
+ elif cab == "DirectoryIndex":
+ sitio.indices = p[1:]
+ A.anota(ln, "traducido", s)
+ elif cab == "SSLCertificateFile":
+ sitio.tls["cert"] = p[1].strip('"') if len(p) > 1 else ""
+ A.anota(ln, "traducido", s)
+ elif cab == "SSLCertificateKeyFile":
+ sitio.tls["key"] = p[1].strip('"') if len(p) > 1 else ""
+ A.anota(ln, "traducido", s)
+ elif cab in ("ProxyPass", "ProxyPassReverse") and len(p) > 1:
+ # ⚠ `ProxyPass` tiene DOS formas y la diferencia importa: dentro de un `<Location>` lleva
+ # sólo la URL (`ProxyPass http://…`), fuera lleva ruta y URL (`ProxyPass /api http://…`).
+ # Mirar sólo la de tres tokens dejaba sin traducir justo la forma que más se usa, y el
+ # acta lo reportaba como «directiva no reconocida» — un falso negativo que manda a
+ # escribir a mano algo que el lector sí sabe hacer.
+ url = p[2].strip('"') if len(p) > 2 else p[1].strip('"')
+ if cab == "ProxyPassReverse":
+ A.anota(ln, "no-aplica", s, "el destino reescribe las cabeceras del proxy por su cuenta")
+ else:
+ if len(p) > 2 and ruta is None:
+ destino.rutas.append(Ruta(patron=p[1].strip('"'), clase="prefijo", origen=ln))
+ destino.rutas[-1].acciones.append(Accion("proxy", url))
+ else:
+ destino.acciones.append(Accion("proxy", url))
+ A.anota(ln, "traducido", s)
+ elif cab == "Redirect" and len(p) > 1:
+ code = p[1] if p[1].isdigit() else "302"
+ dest = p[-1]
+ destino.acciones.append(Accion("redir", f"{code} {dest}"))
+ A.anota(ln, "traducido", s)
+ elif cab in ("RewriteEngine", "RewriteCond", "RewriteRule"):
+ A.anota(ln, "SIN-TRADUCIR", s,
+ "mod_rewrite no tiene traducción mecánica: es un lenguaje propio con estado "
+ "entre reglas")
+ elif cab in ("Options", "AllowOverride", "Require", "Order", "Allow", "Deny"):
+ A.anota(ln, "decision", s, "control de acceso o de listado: el destino lo expresa distinto")
+ else:
+ A.anota(ln, "SIN-TRADUCIR", s, "directiva no reconocida por este lector")
+ return cfg
diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py
new file mode 100644
index 00000000..5e4d41d8
--- /dev/null
+++ b/scripts/mudanza/formatos/caddy.py
@@ -0,0 +1,74 @@
+"""Plugin de formato: ESCRITOR de Caddy (SDD 29)."""
+import os, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from formatos import escritor # noqa: E402
+
+
+def _direcciones(s):
+ """En nginx dos `server` con el MISMO nombre se distinguen por su `listen`; en Caddy, por el
+ ESQUEMA de la dirección. Sin traducir eso, los bloques colisionan y caddy rechaza el fichero
+ entero con «ambiguous site definition» — encontrado validando con el binario real, no leyendo."""
+ solo_80 = bool(s.puertos) and all(p == "80" for p in s.puertos)
+ pref = "http://" if solo_80 else ""
+ return ", ".join(pref + n for n in s.nombres) or (":80" if solo_80 else ":443")
+
+
+def _emitir_acciones(acciones, ind):
+ L = []
+ for a in acciones:
+ if a.tipo == "proxy":
+ L.append(f"{ind}reverse_proxy {a.valor.rstrip('/').replace('http://','').replace('https://','')}")
+ elif a.tipo == "archivos":
+ L.append(f"{ind}root * {a.valor}")
+ elif a.tipo == "intentar":
+ L.append(f"{ind}try_files {a.valor}")
+ elif a.tipo == "redir":
+ p = a.valor.split()
+ code = p[0] if p and p[0].isdigit() else ""
+ dest = " ".join(p[1:]) if code else a.valor
+ L.append(f"{ind}redir {dest} {code}".rstrip() if dest else f"{ind}respond {code}")
+ return L
+
+
+@escritor("caddy")
+def escribir(cfg):
+ A = cfg.acta
+ L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.",
+ "# ⚠ NO es una traducción completa. Abajo va el ACTA de lo que NO se pudo traducir, con su",
+ "# línea de origen. Desplegar esto sin resolverlo es desplegar un servidor incompleto.",
+ ""]
+ for s in cfg.sitios:
+ L.append(f"{_direcciones(s)} {{")
+ L.append(f"\t# desde la línea {s.origen} del {cfg.formato_origen} original")
+ if s.tls.get("cert"):
+ L.append(f"\ttls {s.tls.get('cert','')} {s.tls.get('key','')}")
+ if s.raiz:
+ L.append(f"\troot * {s.raiz}")
+ L += _emitir_acciones(s.acciones, "\t")
+ for r in s.rutas:
+ if r.clase == "regex":
+ # Caddy no tiene un equivalente directo de `location ~`. Se DECLARA en el acta en vez
+ # de emitir algo parecido: un `handle` de prefijo no es lo mismo que una regex, y
+ # parecerse es peor que faltar.
+ A.anota(r.origen, "SIN-TRADUCIR", f"ruta por regex: {r.patron}",
+ "Caddy no tiene equivalente directo de `location ~`; se escribe con "
+ "`@matcher path_regexp` y hay que revisar el orden de evaluación")
+ continue
+ cuerpo = _emitir_acciones(r.acciones, "\t\t")
+ if cuerpo:
+ L.append(f"\thandle {r.patron} {{ # línea {r.origen}")
+ L += cuerpo
+ L.append("\t}")
+ if s.raiz:
+ L.append("\tfile_server")
+ L.append("}")
+ L.append("")
+
+ pend = A.sin_traducir()
+ if pend:
+ L.append("# ══ ACTA: lo que NO se tradujo — hay que resolverlo a mano ══")
+ for e in pend:
+ L.append(f"# línea {e['linea']:>4} {e['directiva'][:70]}")
+ if e["motivo"]:
+ L.append(f"# └─ {e['motivo'][:96]}")
+ return "\n".join(L) + "\n"
diff --git a/scripts/mudanza/formatos/nginx.py b/scripts/mudanza/formatos/nginx.py
new file mode 100644
index 00000000..49df4af1
--- /dev/null
+++ b/scripts/mudanza/formatos/nginx.py
@@ -0,0 +1,131 @@
+"""Plugin de formato: LECTOR de nginx (SDD 29)."""
+import os, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Config, Sitio, Ruta, Accion # noqa: E402
+from formatos import lector # noqa: E402
+
+# Bloques cuya semántica depende del orden de evaluación o del contexto: no se traducen, y —esto es
+# lo importante— TODO lo de adentro tampoco. Un `return 403` sacado de su `if` deja de ser
+# condicional y pasa a aplicar siempre: no se pierde información, se CAMBIA el sentido.
+OPACOS = {"map", "if", "stream", "upstream", "geo", "split_clients"}
+
+# Ajustes del proceso nginx que no tienen ni necesitan equivalente.
+GLOBALES_IRRELEVANTES = {
+ "worker_processes", "worker_connections", "user", "pid", "include", "sendfile",
+ "keepalive_timeout", "gzip", "default_type", "log_format", "types_hash_max_size",
+ "tcp_nopush", "multi_accept", "server_tokens", "charset",
+}
+
+
+def _tokenizar(texto):
+ out, nivel, buf, ln_ini = [], 0, "", 0
+ for ln, linea in enumerate(texto.split("\n"), 1):
+ s = linea.split("#")[0].strip()
+ if not s:
+ continue
+ if not buf:
+ ln_ini = ln
+ for ch in s:
+ if ch == "{":
+ out.append((ln_ini, nivel, buf.strip(), "abre")); nivel += 1; buf = ""
+ elif ch == "}":
+ if buf.strip():
+ out.append((ln_ini, nivel, buf.strip(), "dir"))
+ nivel -= 1; out.append((ln_ini, nivel, "", "cierra")); buf = ""
+ elif ch == ";":
+ out.append((ln_ini, nivel, buf.strip(), "dir")); buf = ""
+ else:
+ buf += ch
+ if buf and not buf.endswith(" "):
+ buf += " "
+ return out
+
+
+@lector("nginx")
+def leer(texto):
+ cfg = Config(formato_origen="nginx")
+ A = cfg.acta
+ pila, sitio, ruta, opaco = [], None, None, 0
+
+ for ln, _niv, txt, tipo in _tokenizar(texto):
+ cab = txt.split()[0] if txt else ""
+
+ if tipo == "abre":
+ pila.append(cab)
+ if cab in OPACOS:
+ opaco += 1
+ A.anota(ln, "SIN-TRADUCIR", txt + " {",
+ f"`{cab}` depende del orden de evaluación y del contexto; TODO lo de adentro "
+ f"queda sin traducir — sacarlo del bloque le cambiaría el sentido")
+ elif cab == "server" and not opaco:
+ sitio = Sitio(origen=ln)
+ elif cab == "location" and not opaco:
+ p = txt.split()
+ if len(p) > 2: # modificador: ~ ~* ^~ =
+ clase = {"~": "regex", "~*": "regex", "=": "exacto", "^~": "prefijo"}.get(p[1], "regex")
+ ruta = Ruta(patron=p[2], clase=clase, origen=ln)
+ else:
+ ruta = Ruta(patron=p[1] if len(p) > 1 else "/", clase="prefijo", origen=ln)
+ continue
+
+ if tipo == "cierra":
+ c = pila.pop() if pila else ""
+ if c in OPACOS and opaco:
+ opaco -= 1
+ elif c == "location" and sitio is not None and ruta is not None:
+ sitio.rutas.append(ruta); ruta = None
+ elif c == "server" and sitio is not None:
+ cfg.sitios.append(sitio); sitio = None
+ continue
+
+ if opaco:
+ A.anota(ln, "SIN-TRADUCIR", txt,
+ "está dentro de un bloque que no se pudo traducir: sacarla de ahí dejaría de ser "
+ "condicional")
+ continue
+
+ p = txt.split()
+ destino = ruta if ruta is not None else sitio
+ if sitio is None:
+ if cab in GLOBALES_IRRELEVANTES or cab.endswith("_log"):
+ A.anota(ln, "no-aplica", txt, "ajuste global del proceso nginx, sin equivalente necesario")
+ elif txt:
+ A.anota(ln, "SIN-TRADUCIR", txt, "directiva global no reconocida")
+ continue
+
+ if cab == "server_name":
+ sitio.nombres += [x for x in p[1:] if x != "_"]
+ A.anota(ln, "traducido", txt)
+ elif cab == "listen":
+ sitio.puertos.append(p[1].split(":")[-1] if len(p) > 1 else "")
+ A.anota(ln, "traducido", txt)
+ elif cab == "root":
+ if ruta is not None:
+ ruta.acciones.append(Accion("archivos", p[1] if len(p) > 1 else ""))
+ else:
+ sitio.raiz = p[1] if len(p) > 1 else ""
+ A.anota(ln, "traducido", txt)
+ elif cab == "index":
+ sitio.indices = p[1:]
+ A.anota(ln, "traducido", txt)
+ elif cab in ("ssl_certificate", "ssl_certificate_key"):
+ sitio.tls["cert" if cab == "ssl_certificate" else "key"] = p[1] if len(p) > 1 else ""
+ A.anota(ln, "traducido", txt)
+ elif cab == "proxy_pass":
+ destino.acciones.append(Accion("proxy", p[1] if len(p) > 1 else ""))
+ A.anota(ln, "traducido", txt)
+ elif cab == "try_files":
+ destino.acciones.append(Accion("intentar", " ".join(p[1:])))
+ A.anota(ln, "traducido", txt)
+ elif cab == "return":
+ destino.acciones.append(Accion("redir", " ".join(p[1:])))
+ A.anota(ln, "traducido", txt)
+ elif cab in ("proxy_set_header", "add_header"):
+ A.anota(ln, "decision", txt,
+ "las cabeceras de proxy habituales (X-Forwarded-*) las pone el destino por su "
+ "cuenta; si ésta no es de ésas, hay que escribirla")
+ elif cab in ("client_max_body_size", "expires", "autoindex", "access_log", "error_log"):
+ A.anota(ln, "decision", txt, "tiene equivalente pero con otra forma: revisalo")
+ else:
+ A.anota(ln, "SIN-TRADUCIR", txt, "directiva no reconocida por este lector")
+ return cfg
diff --git a/scripts/mudanza/modelo.py b/scripts/mudanza/modelo.py
new file mode 100644
index 00000000..3efde8fb
--- /dev/null
+++ b/scripts/mudanza/modelo.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""modelo.py — el MODELO PIVOTE de configuración de servicio, y el acta que lo acompaña (SDD 29).
+
+── POR QUÉ UN PIVOTE Y NO PARES ────────────────────────────────────────────────────────────────
+Traducir de a pares crece en N×M: 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. Es la diferencia entre un centro
+de traducción y una pila de conversores.
+
+── EL ACTA VIAJA DENTRO DEL MODELO, NO AL COSTADO ──────────────────────────────────────────────
+Ésta 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 puede emitirlo como comentario y el humano lo ve en el fichero que va a desplegar.
+
+Doctrina heredada de `soltar`/`paskaq` (tawasuyu): **elisión honesta** — lo que no se pudo traducir
+se reporta con su línea y su motivo, nunca se descarta en silencio.
+"""
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Accion:
+ """Qué hace una ruta. `tipo` ∈ {proxy, archivos, redir, responder, intentar}."""
+ tipo: str
+ valor: str = ""
+ extra: dict = field(default_factory=dict)
+
+
+@dataclass
+class Ruta:
+ """Un camino dentro de un sitio. `clase` ∈ {prefijo, exacto, regex}.
+
+ `regex` se conserva a propósito aunque casi ningún destino lo soporte igual: **perder la
+ distinción sería traducir mal en silencio**. El escritor decide si sabe emitirla o la manda al
+ acta."""
+ patron: str
+ clase: str = "prefijo"
+ acciones: list = field(default_factory=list)
+ origen: int = 0
+
+
+@dataclass
+class Sitio:
+ nombres: list = field(default_factory=list)
+ puertos: list = field(default_factory=list) # "80", "443"
+ tls: dict = field(default_factory=dict) # {cert, key}
+ raiz: str = ""
+ indices: list = field(default_factory=list)
+ rutas: list = field(default_factory=list)
+ acciones: list = field(default_factory=list) # a nivel de sitio
+ origen: int = 0
+
+
+@dataclass
+class Acta:
+ """Registro de procedencia. Cuatro estados, y el que importa es el primero."""
+ entradas: list = field(default_factory=list)
+
+ def anota(self, linea, estado, texto, motivo=""):
+ # estado ∈ {SIN-TRADUCIR, decision, traducido, no-aplica}
+ self.entradas.append({"linea": linea, "estado": estado,
+ "directiva": texto, "motivo": motivo})
+
+ def sin_traducir(self):
+ return [e for e in self.entradas if e["estado"] == "SIN-TRADUCIR"]
+
+ def por_estado(self):
+ d = {}
+ for e in self.entradas:
+ d.setdefault(e["estado"], []).append(e)
+ return d
+
+
+@dataclass
+class Config:
+ """Lo que un lector produce y un escritor consume."""
+ formato_origen: str = ""
+ sitios: list = field(default_factory=list)
+ acta: Acta = field(default_factory=Acta)
diff --git a/scripts/mudanza/traducir.py b/scripts/mudanza/traducir.py
index 74523cb4..53d2ee90 100755
--- a/scripts/mudanza/traducir.py
+++ b/scripts/mudanza/traducir.py
@@ -1,269 +1,101 @@
#!/usr/bin/env python3
-"""traducir.py — traduce la CONFIGURACIÓN de un servicio a la de su equivalente (SDD 29).
+"""traducir.py — CENTRO de traducción de configuraciones, por modelo pivote y plugins (SDD 29).
-Mudar de nginx a caddy no es cambiar un binario: es reescribir la configuración. Esto hace la parte
-mecánica y —sobre todo— **dice con nombre y número de línea lo que NO pudo traducir**.
+Mudar de un servicio a su equivalente no es cambiar un binario: es **reescribir la configuración**.
+Esto 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,
-pero sus principios son exactamente los que un traductor de configuración necesita, y se adoptan:
+── POR QUÉ UN CENTRO Y NO TRADUCTORES DE A PARES ───────────────────────────────────────────────
+De a pares el trabajo crece en **N×M**: con nginx, apache, caddy, haproxy y lighttpd son 20
+traductores, y cada formato nuevo agrega 2N. Con un **modelo pivote** son **N lectores + M
+escritores**: un formato nuevo cuesta uno o dos plugins y estrena todos los pares de golpe.
- · **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá: cada
- directiva que no se entendió sale con su NÚMERO DE LÍNEA y su texto. Un traductor que descarta
- en silencio una directiva es catastrófico — te deja un servidor sin una redirección, sin una
- regla de auth o sin una cabecera, y el sitio parece funcionar.
- · **No adivinar en silencio.** Toda heurística queda registrada como decisión, no aplicada de
- tapadillo.
- · **Procedencia total.** Cada línea del resultado sabe de qué línea del original salió.
- · **Determinista**: mismo archivo, misma salida, byte a byte.
+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.
-── LO QUE ESTO NO ES ───────────────────────────────────────────────────────────────────────────
-No es un traductor completo de nginx: nginx tiene cientos de directivas, `if`, `rewrite` con regex,
-`map`, módulos. **Traduce el núcleo común y declara todo lo demás.** Un traductor honesto que cubre
-el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.
+── LOS PLUGINS ────────────────────────────────────────────────────────────────────────────────
+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 que hay que mantener a mano se
+desincroniza y el formato nuevo «no existe» sin que nada falle.
+
+── DOCTRINA HEREDADA DE `soltar`/`paskaq` (tawasuyu) ───────────────────────────────────────────
+`soltar` desencadena datos presos en formatos cautivos. Otro dominio, mismos principios:
+**elisión honesta** (lo no traducido se reporta con línea y motivo), **no adivinar en silencio**,
+**procedencia total** y **determinismo**. Y una consecuencia de diseño propia: **el acta viaja DENTRO
+del modelo**, no al costado — si fuera de cada traductor, lo que el LECTOR no entendió se perdería
+antes de llegar al escritor, y el fichero que uno despliega no lo diría.
Uso:
- scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf --out Caddyfile
- scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf # sólo el acta
+ scripts/mudanza/traducir.py --list
+ scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf --out Caddyfile
+ scripts/mudanza/traducir.py --from apache --to caddy --in httpd.conf --out Caddyfile
"""
-import argparse, re, sys
-
-# ── lectura de nginx ────────────────────────────────────────────────────────────────────────────
-
-def tokenizar_nginx(texto):
- """Directivas con su número de línea y su anidamiento. Suficiente para el núcleo; no pretende
- ser el parser de nginx."""
- out, nivel, buf, ln_ini = [], 0, "", 0
- for ln, linea in enumerate(texto.split("\n"), 1):
- s = linea.split("#")[0].strip()
- if not s:
- continue
- if not buf:
- ln_ini = ln
- for ch in s:
- if ch == "{":
- out.append((ln_ini, nivel, buf.strip(), "abre"))
- nivel += 1
- buf = ""
- elif ch == "}":
- if buf.strip():
- out.append((ln_ini, nivel, buf.strip(), "dir"))
- nivel -= 1
- out.append((ln_ini, nivel, "", "cierra"))
- buf = ""
- elif ch == ";":
- out.append((ln_ini, nivel, buf.strip(), "dir"))
- buf = ""
- else:
- buf += ch
- if buf and not buf.endswith(" "):
- buf += " "
- return out
-
-
-# ── traducción nginx → caddy ────────────────────────────────────────────────────────────────────
-
-def nginx_a_caddy(texto):
- """Devuelve `(caddyfile, acta)`. El acta es la parte que no se puede omitir."""
- toks = tokenizar_nginx(texto)
- acta, sitios = [], []
- actual, pila, loc = None, [], None
-
- def anota(ln, estado, directiva, motivo=""):
- acta.append({"linea": ln, "estado": estado, "directiva": directiva, "motivo": motivo})
-
- # ⚠ Profundidad de bloques que NO se pudieron traducir (`if`, `map`, `stream`, `upstream`).
- # Sin esto, las directivas de adentro se absorbían como si fueran del servidor: un
- # `if (...) { return 403; }` salía como un `respond 403` INCONDICIONAL, o sea el sitio entero
- # devolviendo 403. Encontrado validando la salida con el caddy real. Es el modo de fallo que la
- # doctrina de `soltar` llama pérdida silenciosa — sólo que peor: no pierde, CAMBIA el sentido.
- opaco = 0
-
- i = 0
- while i < len(toks):
- ln, niv, txt, tipo = toks[i]
- i += 1
- cab = txt.split()[0] if txt else ""
-
- if tipo == "abre":
- pila.append(cab)
- if cab == "server":
- actual = {"nombres": [], "puertos": [], "root": None, "index": None,
- "locations": [], "tls": {}, "linea": ln}
- anota(ln, "traducido", "server {", "→ un bloque de sitio de Caddy")
- elif cab == "location":
- partes = txt.split()
- loc = {"ruta": partes[1] if len(partes) > 1 else "/", "dirs": [], "linea": ln}
- if len(partes) > 2:
- anota(ln, "SIN-TRADUCIR", txt,
- "location con modificador (~, ~*, ^~, =): las reglas por regex de nginx no "
- "tienen equivalente directo en Caddy — hay que reescribirla a mano")
- elif cab in ("http", "events", "upstream", "map", "if", "stream"):
- if cab in ("map", "if", "stream", "upstream"):
- opaco += 1
- anota(ln, "SIN-TRADUCIR", txt + " {",
- f"`{cab}` no tiene traducción mecánica: su semántica en nginx depende del "
- f"orden de evaluación y del contexto — y TODO lo de adentro queda sin "
- f"traducir, no se absorbe al bloque de afuera")
- continue
-
- if tipo == "cierra":
- c = pila.pop() if pila else ""
- if c in ("map", "if", "stream", "upstream") and opaco:
- opaco -= 1
- if c == "location" and actual is not None and loc:
- actual["locations"].append(loc); loc = None
- elif c == "server" and actual is not None:
- sitios.append(actual); actual = None
- continue
-
- # ── directivas ──────────────────────────────────────────────────────────────────────────
- if opaco:
- anota(ln, "SIN-TRADUCIR", txt,
- "está dentro de un bloque que no se pudo traducir: sacarla de ahí le cambiaría el "
- "sentido (dejaría de ser condicional)")
- continue
- if actual is None:
- if cab in ("worker_processes", "user", "pid", "events", "include", "worker_connections",
- "sendfile", "keepalive_timeout", "gzip", "default_type", "log_format",
- "access_log", "error_log", "types_hash_max_size", "tcp_nopush"):
- anota(ln, "no-aplica", txt,
- "ajuste global de nginx sin equivalente necesario: Caddy lo maneja solo")
- elif txt:
- anota(ln, "SIN-TRADUCIR", txt, "directiva global no reconocida")
- continue
-
- p = txt.split()
- if cab == "server_name":
- actual["nombres"] += [x for x in p[1:] if x != "_"]
- anota(ln, "traducido", txt, "→ nombre del sitio")
- elif cab == "listen":
- actual["puertos"].append(p[1] if len(p) > 1 else "")
- if "ssl" in p:
- anota(ln, "traducido", txt, "→ Caddy hace TLS automático; `ssl` no se escribe")
- else:
- anota(ln, "traducido", txt, "→ puerto del sitio")
- elif cab == "root":
- (loc or actual)["root" if loc is None else "root"] = p[1] if len(p) > 1 else ""
- if loc is not None:
- loc["dirs"].append(("root", p[1] if len(p) > 1 else ""))
- anota(ln, "traducido", txt, "→ `root *`")
- elif cab == "index":
- actual["index"] = " ".join(p[1:])
- anota(ln, "traducido", txt, "→ `file_server` usa index por defecto")
- elif cab in ("ssl_certificate", "ssl_certificate_key"):
- actual["tls"][cab] = p[1] if len(p) > 1 else ""
- anota(ln, "traducido", txt, "→ `tls <cert> <key>`")
- elif cab == "proxy_pass" and loc is not None:
- loc["dirs"].append(("proxy", p[1] if len(p) > 1 else ""))
- anota(ln, "traducido", txt, "→ `reverse_proxy`")
- elif cab == "try_files" and loc is not None:
- loc["dirs"].append(("try_files", " ".join(p[1:])))
- anota(ln, "traducido", txt, "→ `try_files`")
- elif cab == "return":
- destino = " ".join(p[1:])
- (loc["dirs"] if loc is not None else actual.setdefault("dirs", [])).append(("return", destino))
- anota(ln, "traducido", txt, "→ `redir`")
- elif cab in ("proxy_set_header", "add_header"):
- anota(ln, "decision", txt,
- "Caddy pone por su cuenta las cabeceras de proxy habituales (X-Forwarded-*). Si "
- "ésta es de las habituales, sobra; si no, hay que escribirla con `header_up`")
- elif cab in ("access_log", "error_log", "client_max_body_size", "expires", "autoindex"):
- anota(ln, "decision", txt, "tiene equivalente en Caddy pero con otra forma: revisalo")
- else:
- anota(ln, "SIN-TRADUCIR", txt, "directiva no reconocida por este traductor")
+import argparse, os, sys
- # ── emitir Caddyfile ────────────────────────────────────────────────────────────────────────
- L = ["# Generado por scripts/mudanza/traducir.py desde una configuración de nginx.",
- "# ⚠ NO es una traducción completa. Leé el ACTA: lo que no se pudo traducir está ahí con su",
- "# número de línea. Desplegar esto sin mirar el acta es desplegar un servidor incompleto.",
- ""]
- for s in sitios:
- # En nginx dos `server` con el MISMO server_name se distinguen por su `listen`; en Caddy se
- # distinguen por el ESQUEMA de la dirección. Sin traducir eso, los dos bloques colisionan y
- # caddy rechaza el fichero con «ambiguous site definition» — encontrado al validar con el
- # binario real, no al leer la salida.
- solo_80 = bool(s["puertos"]) and all(p.split(":")[-1] == "80" for p in s["puertos"])
- pref = "http://" if solo_80 else ""
- nombres = ", ".join(pref + n for n in s["nombres"]) or (":80" if solo_80 else ":443")
- L.append(f"{nombres} {{")
- L.append(f"\t# desde el `server {{` de la línea {s['linea']} del nginx.conf")
- if s["tls"].get("ssl_certificate"):
- L.append(f"\ttls {s['tls'].get('ssl_certificate','')} {s['tls'].get('ssl_certificate_key','')}")
- if s.get("root"):
- L.append(f"\troot * {s['root']}")
- for k, v in s.get("dirs", []):
- if k == "return":
- code, *dest = v.split()
- L.append(f"\tredir {' '.join(dest)} {code}" if dest else f"\trespond {code}")
- for lo in s["locations"]:
- cuerpo = []
- for k, v in lo["dirs"]:
- if k == "proxy":
- cuerpo.append(f"\t\treverse_proxy {v.rstrip('/').replace('http://','').replace('https://','')}")
- elif k == "root":
- cuerpo.append(f"\t\troot * {v}")
- elif k == "try_files":
- cuerpo.append(f"\t\ttry_files {v}")
- elif k == "return":
- code, *dest = v.split()
- cuerpo.append(f"\t\tredir {' '.join(dest)} {code}" if dest else f"\t\trespond {code}")
- if cuerpo:
- L.append(f"\thandle {lo['ruta']} {{ # location de la línea {lo['linea']}")
- L += cuerpo
- L.append("\t}")
- if s.get("root"):
- L.append("\tfile_server")
- L.append("}")
- L.append("")
- return "\n".join(L), acta
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import formatos # noqa: E402
def imprimir_acta(acta):
- por = {}
- for a in acta:
- por.setdefault(a["estado"], []).append(a)
+ por = acta.por_estado()
print("\n══ ACTA DE TRADUCCIÓN ══")
- print(f" {len(acta)} directiva(s) leídas\n")
- orden = ["SIN-TRADUCIR", "decision", "traducido", "no-aplica"]
+ print(f" {len(acta.entradas)} directiva(s) leídas\n")
titulo = {"SIN-TRADUCIR": "⚠ NO SE PUDO TRADUCIR — hay que escribirlas a mano",
"decision": "◆ traducidas con una DECISIÓN — revisalas",
"traducido": "✓ traducidas",
- "no-aplica": "· no aplican en Caddy"}
- for e in orden:
+ "no-aplica": "· no aplican en el destino"}
+ for e in ("SIN-TRADUCIR", "decision", "traducido", "no-aplica"):
if e not in por:
continue
- print(f"── {titulo[e]} ({len(por[e])}) " + "─" * 24)
- for a in por[e] if e in ("SIN-TRADUCIR", "decision") else por[e][:6]:
+ print(f"── {titulo[e]} ({len(por[e])}) " + "─" * 22)
+ detalle = e in ("SIN-TRADUCIR", "decision")
+ for a in (por[e] if detalle else por[e][:5]):
print(f" línea {a['linea']:>4} {a['directiva'][:64]}")
- if a["motivo"] and e in ("SIN-TRADUCIR", "decision"):
- print(f" └─ {a['motivo'][:100]}")
- if e not in ("SIN-TRADUCIR", "decision") and len(por[e]) > 6:
- print(f" … y {len(por[e])-6} más")
+ if detalle and a["motivo"]:
+ print(f" └─ {a['motivo'][:98]}")
+ if not detalle and len(por[e]) > 5:
+ print(f" … y {len(por[e])-5} más")
print()
- n = len(por.get("SIN-TRADUCIR", []))
+ n = len(acta.sin_traducir())
if n:
- print(f" ⚠ {n} directiva(s) sin traducir. Desplegar sin revisarlas es desplegar un servidor")
- print(" INCOMPLETO que además parece funcionar — el modo de fallo más caro.")
+ print(f" ⚠ {n} sin traducir. Desplegar sin resolverlas es desplegar un servidor INCOMPLETO")
+ print(" que además parece funcionar — el modo de fallo más caro.")
return n
def main():
- ap = argparse.ArgumentParser(description="Traduce la configuración de un servicio a su equivalente.")
- ap.add_argument("--from", dest="desde", required=True, choices=["nginx"])
- ap.add_argument("--to", dest="hacia", required=True, choices=["caddy"])
- ap.add_argument("--in", dest="entrada", required=True)
- ap.add_argument("--out", dest="salida", help="fichero de salida (default: sólo el acta)")
+ lectores, escritores = formatos.cargar()
+ ap = argparse.ArgumentParser(description="Centro de traducción de configuraciones (pivote + plugins).")
+ ap.add_argument("--list", action="store_true", help="listar formatos disponibles y los pares que habilitan")
+ ap.add_argument("--from", dest="desde", choices=sorted(lectores))
+ ap.add_argument("--to", dest="hacia", choices=sorted(escritores))
+ ap.add_argument("--in", dest="entrada")
+ ap.add_argument("--out", dest="salida")
a = ap.parse_args()
- texto = open(a.entrada).read()
- salida, acta = nginx_a_caddy(texto)
- n = imprimir_acta(acta)
+ if a.list:
+ print(f"\n══ formatos registrados ══")
+ print(f" lectores ({len(lectores)}): {', '.join(sorted(lectores))}")
+ print(f" escritores ({len(escritores)}): {', '.join(sorted(escritores))}")
+ n, m = len(lectores), len(escritores)
+ print(f"\n pares habilitados: {n}×{m} = {n*m}, con {n+m} plugin(s).")
+ print(f" De a pares harían falta {n*m} traductores, y cada formato nuevo agregaría {n+m}.")
+ for le in sorted(lectores):
+ for es in sorted(escritores):
+ if le != es:
+ print(f" {le} → {es}")
+ return
+
+ if not (a.desde and a.hacia and a.entrada):
+ ap.error("hacen falta --from, --to y --in (o --list)")
+
+ cfg = lectores[a.desde](open(a.entrada).read())
+ texto = escritores[a.hacia](cfg) # el escritor puede AÑADIR al acta: se imprime después
+ n = imprimir_acta(cfg.acta)
if a.salida:
- open(a.salida, "w").write(salida + "\n")
+ open(a.salida, "w").write(texto)
print(f"\n {a.hacia} → {a.salida}")
else:
- print(salida)
+ print(texto)
sys.exit(1 if n else 0)