#!/usr/bin/env python3 """traducir.py — CENTRO de traducción de configuraciones, por modelo pivote y plugins (SDD 29). 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**. ── 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. 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. ── LOS PLUGINS, Y LA FAMILIA ─────────────────────────────────────────────────────────────────── 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. ⚠ El pivote es **uno por FAMILIA**, y eso no es taxonomía: un unit de systemd no es un sitio web, y un par cruzado produciría basura *plausible*. El centro lo rechaza con un error, no con un intento. Hoy hay dos familias — `web` (nginx, apache → caddy) y `servicio` (systemd → arje) — y agregar una tercera no toca nada de lo escrito. ── 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 --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 scripts/mudanza/traducir.py --from systemd --to arje --in /etc/systemd/system --out seed.card.json """ import argparse, os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import formatos # noqa: E402 def imprimir_acta(acta): por = acta.por_estado() print("\n══ ACTA DE TRADUCCIÓN ══") 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 el destino"} for e in ("SIN-TRADUCIR", "decision", "traducido", "no-aplica"): if e not in por: continue 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 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(acta.sin_traducir()) if n: 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 _fusionar(a, b): """Junta dos modelos de la MISMA familia: extiende cada campo lista y concatena el acta. Genérico a propósito — vale para `Config` y para `Sistema` sin que el centro sepa de ninguno de los dos. Un directorio de units es la entrada NORMAL de systemd, no un caso raro.""" import dataclasses if a is None: return b for f in dataclasses.fields(a): va, vb = getattr(a, f.name), getattr(b, f.name) if isinstance(va, list): va.extend(vb) a.acta.entradas.extend(b.acta.entradas) return a def _entradas(ruta): """Un fichero, o todos los `*.service`/`*.conf` de un directorio, en orden determinista.""" if os.path.isdir(ruta): # TODOS los ficheros regulares no ocultos, no sólo los de cierta extensión: los servicios de # OpenRC no tienen ninguna. Filtrar acá por nombre sería el centro decidiendo qué es de cada # formato — y lo que no entra se pierde EN SILENCIO. Filtra el lector, que sabe, y lo DICE. hs = sorted(f for f in os.listdir(ruta) if not f.startswith(".") and os.path.isfile(os.path.join(ruta, f))) if not hs: sys.exit(f"✗ {ruta} no tiene ningún fichero regular") return [os.path.join(ruta, h) for h in hs] return [ruta] def main(): lectores, escritores = formatos.cargar() fams = formatos.familias() 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", help="fichero, o directorio (se leen sus *.service/*.conf)") ap.add_argument("--out", dest="salida") a = ap.parse_args() if a.list: print("\n══ formatos registrados ══") tot_l, tot_e, pares = 0, 0, 0 for fam, (le, es) in fams.items(): n, m = len(le), len(es) tot_l, tot_e, pares = tot_l + n, tot_e + m, pares + n * m print(f"\n── familia «{fam}» " + "─" * 40) print(f" lectores ({n}): {', '.join(le) or '—'}") print(f" escritores ({m}): {', '.join(es) or '—'}") for x in le: for y in es: if x != y: print(f" {x} → {y}") print(f"\n {pares} par(es) habilitados con {tot_l + tot_e} plugin(s).") print(f" De a pares harían falta {pares} traductores, y cada formato nuevo agregaría " f"tantos como formatos tenga ya su familia.") print(" ⚠ Los pares se cuentan DENTRO de cada familia: un lector de servicios contra un") print(" escritor de web no es un par, es basura plausible. El centro lo rechaza.") return if not (a.desde and a.hacia and a.entrada): ap.error("hacen falta --from, --to y --in (o --list)") fl, fe = lectores[a.desde][1], escritores[a.hacia][1] if fl != fe: sys.exit(f"✗ `{a.desde}` es de la familia «{fl}» y `{a.hacia}` de la familia «{fe}».\n" f" No es un par: traducir entre familias distintas daría un fichero que PARECE\n" f" correcto. Mirá `--list` para los pares que sí existen.") modelo = None for ruta in _entradas(a.entrada): with open(ruta) as fh: modelo = _fusionar(modelo, lectores[a.desde][0](fh.read(), ruta)) texto = escritores[a.hacia][0](modelo) # el escritor puede AÑADIR al acta: se imprime después n = imprimir_acta(modelo.acta) if a.salida: open(a.salida, "w").write(texto) print(f"\n {a.hacia} → {a.salida}") else: print(texto) sys.exit(1 if n else 0) if __name__ == "__main__": main()