tawasuyu

sergio/takana

> 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…

TOML 1182Shell 87Rust 63Markdown 541592 Definitions

⎇ 033913bb4a44024912eae5385df2060f8c1921b8 scripts/mudanza/traducir.py Python · 271 lines · 14.2 KB History Who touched what raw

traducir.py: nginx → caddy, con acta — y validar con el binario real cazó un bug que CAMBIABA el sentido 033913bb4 · Sergio ·

1#!/usr/bin/env python3
2"""traducir.py — traduce la CONFIGURACIÓN de un servicio a la de su equivalente (SDD 29).
3
4Mudar de nginx a caddy no es cambiar un binario: es reescribir la configuración. Esto hace la parte
5mecánica y —sobre todo— **dice con nombre y número de línea lo que NO pudo traducir**.
6
7── DOCTRINA HEREDADA DE `soltar`/`paskaq` (tawasuyu) ────────────────────────────────────────────
8`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, .mdb). El dominio es otro,
9pero sus principios son exactamente los que un traductor de configuración necesita, y se adoptan:
10
11  · **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá: cada
12    directiva que no se entendió sale con su NÚMERO DE LÍNEA y su texto. Un traductor que descarta
13    en silencio una directiva es catastrófico — te deja un servidor sin una redirección, sin una
14    regla de auth o sin una cabecera, y el sitio parece funcionar.
15  · **No adivinar en silencio.** Toda heurística queda registrada como decisión, no aplicada de
16    tapadillo.
17  · **Procedencia total.** Cada línea del resultado sabe de qué línea del original salió.
18  · **Determinista**: mismo archivo, misma salida, byte a byte.
19
20── LO QUE ESTO NO ES ───────────────────────────────────────────────────────────────────────────
21No es un traductor completo de nginx: nginx tiene cientos de directivas, `if`, `rewrite` con regex,
22`map`, módulos. **Traduce el núcleo común y declara todo lo demás.** Un traductor honesto que cubre
23el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.
24
25Uso:
26    scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf --out Caddyfile
27    scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf   # sólo el acta
28"""
29import argparse, re, sys
30
31# ── lectura de nginx ────────────────────────────────────────────────────────────────────────────
32
33def tokenizar_nginx(texto):
34    """Directivas con su número de línea y su anidamiento. Suficiente para el núcleo; no pretende
35    ser el parser de nginx."""
36    out, nivel, buf, ln_ini = [], 0, "", 0
37    for ln, linea in enumerate(texto.split("\n"), 1):
38        s = linea.split("#")[0].strip()
39        if not s:
40            continue
41        if not buf:
42            ln_ini = ln
43        for ch in s:
44            if ch == "{":
45                out.append((ln_ini, nivel, buf.strip(), "abre"))
46                nivel += 1
47                buf = ""
48            elif ch == "}":
49                if buf.strip():
50                    out.append((ln_ini, nivel, buf.strip(), "dir"))
51                nivel -= 1
52                out.append((ln_ini, nivel, "", "cierra"))
53                buf = ""
54            elif ch == ";":
55                out.append((ln_ini, nivel, buf.strip(), "dir"))
56                buf = ""
57            else:
58                buf += ch
59        if buf and not buf.endswith(" "):
60            buf += " "
61    return out
62
63
64# ── traducción nginx → caddy ────────────────────────────────────────────────────────────────────
65
66def nginx_a_caddy(texto):
67    """Devuelve `(caddyfile, acta)`. El acta es la parte que no se puede omitir."""
68    toks = tokenizar_nginx(texto)
69    acta, sitios = [], []
70    actual, pila, loc = None, [], None
71
72    def anota(ln, estado, directiva, motivo=""):
73        acta.append({"linea": ln, "estado": estado, "directiva": directiva, "motivo": motivo})
74
75    # ⚠ Profundidad de bloques que NO se pudieron traducir (`if`, `map`, `stream`, `upstream`).
76    # Sin esto, las directivas de adentro se absorbían como si fueran del servidor: un
77    # `if (...) { return 403; }` salía como un `respond 403` INCONDICIONAL, o sea el sitio entero
78    # devolviendo 403. Encontrado validando la salida con el caddy real. Es el modo de fallo que la
79    # doctrina de `soltar` llama pérdida silenciosa — sólo que peor: no pierde, CAMBIA el sentido.
80    opaco = 0
81
82    i = 0
83    while i < len(toks):
84        ln, niv, txt, tipo = toks[i]
85        i += 1
86        cab = txt.split()[0] if txt else ""
87
88        if tipo == "abre":
89            pila.append(cab)
90            if cab == "server":
91                actual = {"nombres": [], "puertos": [], "root": None, "index": None,
92                          "locations": [], "tls": {}, "linea": ln}
93                anota(ln, "traducido", "server {", "→ un bloque de sitio de Caddy")
94            elif cab == "location":
95                partes = txt.split()
96                loc = {"ruta": partes[1] if len(partes) > 1 else "/", "dirs": [], "linea": ln}
97                if len(partes) > 2:
98                    anota(ln, "SIN-TRADUCIR", txt,
99                          "location con modificador (~, ~*, ^~, =): las reglas por regex de nginx no "
100                          "tienen equivalente directo en Caddy — hay que reescribirla a mano")
101            elif cab in ("http", "events", "upstream", "map", "if", "stream"):
102                if cab in ("map", "if", "stream", "upstream"):
103                    opaco += 1
104                    anota(ln, "SIN-TRADUCIR", txt + " {",
105                          f"`{cab}` no tiene traducción mecánica: su semántica en nginx depende del "
106                          f"orden de evaluación y del contexto — y TODO lo de adentro queda sin "
107                          f"traducir, no se absorbe al bloque de afuera")
108            continue
109
110        if tipo == "cierra":
111            c = pila.pop() if pila else ""
112            if c in ("map", "if", "stream", "upstream") and opaco:
113                opaco -= 1
114            if c == "location" and actual is not None and loc:
115                actual["locations"].append(loc); loc = None
116            elif c == "server" and actual is not None:
117                sitios.append(actual); actual = None
118            continue
119
120        # ── directivas ──────────────────────────────────────────────────────────────────────────
121        if opaco:
122            anota(ln, "SIN-TRADUCIR", txt,
123                  "está dentro de un bloque que no se pudo traducir: sacarla de ahí le cambiaría el "
124                  "sentido (dejaría de ser condicional)")
125            continue
126        if actual is None:
127            if cab in ("worker_processes", "user", "pid", "events", "include", "worker_connections",
128                       "sendfile", "keepalive_timeout", "gzip", "default_type", "log_format",
129                       "access_log", "error_log", "types_hash_max_size", "tcp_nopush"):
130                anota(ln, "no-aplica", txt,
131                      "ajuste global de nginx sin equivalente necesario: Caddy lo maneja solo")
132            elif txt:
133                anota(ln, "SIN-TRADUCIR", txt, "directiva global no reconocida")
134            continue
135
136        p = txt.split()
137        if cab == "server_name":
138            actual["nombres"] += [x for x in p[1:] if x != "_"]
139            anota(ln, "traducido", txt, "→ nombre del sitio")
140        elif cab == "listen":
141            actual["puertos"].append(p[1] if len(p) > 1 else "")
142            if "ssl" in p:
143                anota(ln, "traducido", txt, "→ Caddy hace TLS automático; `ssl` no se escribe")
144            else:
145                anota(ln, "traducido", txt, "→ puerto del sitio")
146        elif cab == "root":
147            (loc or actual)["root" if loc is None else "root"] = p[1] if len(p) > 1 else ""
148            if loc is not None:
149                loc["dirs"].append(("root", p[1] if len(p) > 1 else ""))
150            anota(ln, "traducido", txt, "→ `root *`")
151        elif cab == "index":
152            actual["index"] = " ".join(p[1:])
153            anota(ln, "traducido", txt, "→ `file_server` usa index por defecto")
154        elif cab in ("ssl_certificate", "ssl_certificate_key"):
155            actual["tls"][cab] = p[1] if len(p) > 1 else ""
156            anota(ln, "traducido", txt, "→ `tls <cert> <key>`")
157        elif cab == "proxy_pass" and loc is not None:
158            loc["dirs"].append(("proxy", p[1] if len(p) > 1 else ""))
159            anota(ln, "traducido", txt, "→ `reverse_proxy`")
160        elif cab == "try_files" and loc is not None:
161            loc["dirs"].append(("try_files", " ".join(p[1:])))
162            anota(ln, "traducido", txt, "→ `try_files`")
163        elif cab == "return":
164            destino = " ".join(p[1:])
165            (loc["dirs"] if loc is not None else actual.setdefault("dirs", [])).append(("return", destino))
166            anota(ln, "traducido", txt, "→ `redir`")
167        elif cab in ("proxy_set_header", "add_header"):
168            anota(ln, "decision", txt,
169                  "Caddy pone por su cuenta las cabeceras de proxy habituales (X-Forwarded-*). Si "
170                  "ésta es de las habituales, sobra; si no, hay que escribirla con `header_up`")
171        elif cab in ("access_log", "error_log", "client_max_body_size", "expires", "autoindex"):
172            anota(ln, "decision", txt, "tiene equivalente en Caddy pero con otra forma: revisalo")
173        else:
174            anota(ln, "SIN-TRADUCIR", txt, "directiva no reconocida por este traductor")
175
176    # ── emitir Caddyfile ────────────────────────────────────────────────────────────────────────
177    L = ["# Generado por scripts/mudanza/traducir.py desde una configuración de nginx.",
178         "# ⚠ NO es una traducción completa. Leé el ACTA: lo que no se pudo traducir está ahí con su",
179         "#   número de línea. Desplegar esto sin mirar el acta es desplegar un servidor incompleto.",
180         ""]
181    for s in sitios:
182        # En nginx dos `server` con el MISMO server_name se distinguen por su `listen`; en Caddy se
183        # distinguen por el ESQUEMA de la dirección. Sin traducir eso, los dos bloques colisionan y
184        # caddy rechaza el fichero con «ambiguous site definition» — encontrado al validar con el
185        # binario real, no al leer la salida.
186        solo_80 = bool(s["puertos"]) and all(p.split(":")[-1] == "80" for p in s["puertos"])
187        pref = "http://" if solo_80 else ""
188        nombres = ", ".join(pref + n for n in s["nombres"]) or (":80" if solo_80 else ":443")
189        L.append(f"{nombres} {{")
190        L.append(f"\t# desde el `server {{` de la línea {s['linea']} del nginx.conf")
191        if s["tls"].get("ssl_certificate"):
192            L.append(f"\ttls {s['tls'].get('ssl_certificate','')} {s['tls'].get('ssl_certificate_key','')}")
193        if s.get("root"):
194            L.append(f"\troot * {s['root']}")
195        for k, v in s.get("dirs", []):
196            if k == "return":
197                code, *dest = v.split()
198                L.append(f"\tredir {' '.join(dest)} {code}" if dest else f"\trespond {code}")
199        for lo in s["locations"]:
200            cuerpo = []
201            for k, v in lo["dirs"]:
202                if k == "proxy":
203                    cuerpo.append(f"\t\treverse_proxy {v.rstrip('/').replace('http://','').replace('https://','')}")
204                elif k == "root":
205                    cuerpo.append(f"\t\troot * {v}")
206                elif k == "try_files":
207                    cuerpo.append(f"\t\ttry_files {v}")
208                elif k == "return":
209                    code, *dest = v.split()
210                    cuerpo.append(f"\t\tredir {' '.join(dest)} {code}" if dest else f"\t\trespond {code}")
211            if cuerpo:
212                L.append(f"\thandle {lo['ruta']} {{    # location de la línea {lo['linea']}")
213                L += cuerpo
214                L.append("\t}")
215        if s.get("root"):
216            L.append("\tfile_server")
217        L.append("}")
218        L.append("")
219    return "\n".join(L), acta
220
221
222def imprimir_acta(acta):
223    por = {}
224    for a in acta:
225        por.setdefault(a["estado"], []).append(a)
226    print("\n══ ACTA DE TRADUCCIÓN ══")
227    print(f"   {len(acta)} directiva(s) leídas\n")
228    orden = ["SIN-TRADUCIR", "decision", "traducido", "no-aplica"]
229    titulo = {"SIN-TRADUCIR": "⚠ NO SE PUDO TRADUCIR — hay que escribirlas a mano",
230              "decision": "◆ traducidas con una DECISIÓN — revisalas",
231              "traducido": "✓ traducidas",
232              "no-aplica": "· no aplican en Caddy"}
233    for e in orden:
234        if e not in por:
235            continue
236        print(f"── {titulo[e]} ({len(por[e])}) " + "─" * 24)
237        for a in por[e] if e in ("SIN-TRADUCIR", "decision") else por[e][:6]:
238            print(f"   línea {a['linea']:>4}  {a['directiva'][:64]}")
239            if a["motivo"] and e in ("SIN-TRADUCIR", "decision"):
240                print(f"              └─ {a['motivo'][:100]}")
241        if e not in ("SIN-TRADUCIR", "decision") and len(por[e]) > 6:
242            print(f"   … y {len(por[e])-6} más")
243        print()
244    n = len(por.get("SIN-TRADUCIR", []))
245    if n:
246        print(f"   ⚠ {n} directiva(s) sin traducir. Desplegar sin revisarlas es desplegar un servidor")
247        print("     INCOMPLETO que además parece funcionar — el modo de fallo más caro.")
248    return n
249
250
251def main():
252    ap = argparse.ArgumentParser(description="Traduce la configuración de un servicio a su equivalente.")
253    ap.add_argument("--from", dest="desde", required=True, choices=["nginx"])
254    ap.add_argument("--to", dest="hacia", required=True, choices=["caddy"])
255    ap.add_argument("--in", dest="entrada", required=True)
256    ap.add_argument("--out", dest="salida", help="fichero de salida (default: sólo el acta)")
257    a = ap.parse_args()
258
259    texto = open(a.entrada).read()
260    salida, acta = nginx_a_caddy(texto)
261    n = imprimir_acta(acta)
262    if a.salida:
263        open(a.salida, "w").write(salida + "\n")
264        print(f"\n   {a.hacia} → {a.salida}")
265    else:
266        print(salida)
267    sys.exit(1 if n else 0)
268
269
270if __name__ == "__main__":
271    main()