Files
SergioandClaude Opus 5 61c33800c5 traducir: lector de OpenRC — el par que cierra el camino de gioser, y casi la mitad NO se puede leer
`openrc → arje` sin escribir ningún traductor de ese par: cuarto plugin, cuarto par. OpenRC es el
init de gioser, la máquina que esta mudanza termina borrando.

**El hecho que manda acá: un servicio de OpenRC es un PROGRAMA, no una declaración.** Un unit de
systemd se lee; un script de OpenRC puede hacer cualquier cosa antes de arrancar nada. Medido sobre
el `/etc/init.d` real: **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=`
que valga — leer las variables y emitir tarjeta daría un servicio que arranca OTRA COSA. Regla al
revés que en systemd: `start()` propia ⇒ SIN-TRADUCIR y NO se emite tarjeta.

Los números del corpus real cierran solos: 121 − 1 (no es openrc-run) − 60 (start propia) − 1 (sin
`command=`) = 59, y el lector emitió exactamente 59, con 59 ids únicos.

Dos cosas que OpenRC obliga a ir a buscar fuera del script: `/etc/conf.d/<x>`, donde viven los
argumentos de verdad (a diferencia del `EnvironmentFile=` de systemd, éste SÍ está en la máquina: se
lee y se aplica), y `/etc/runlevels/`, que es lo único que dice si el servicio arranca solo.

**Tres defectos que sólo aparecieron corriendo contra las 121 de verdad**, no sobre un ejemplo mío:

- `name=` no es un identificador sino un rótulo humano: en gioser vale «Aura Backend», con espacio,
  y se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero.
- Ids repetidos: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos y son scripts
  válidos; dos con el mismo nombre dan el MISMO id determinista ⇒ semilla con dos cards homónimas.
  El escritor lo detecta y no emite la segunda.
- La tarjeta decía `"desde": "systemd"` viniendo de OpenRC: el campo que existe para saber de dónde
  salió algo era justo el que mentía. Estaba cableado.

Y el centro deja de filtrar la entrada por extensión: los servicios de OpenRC no tienen ninguna, y
filtrar en el centro es que lo que no entra se pierda EN SILENCIO. Filtra el lector, que sabe, y lo
anota — así un `.bak` en `/etc/init.d` se REPORTA en vez de desaparecer.

Controles: dos corridas dan el fichero byte a byte idéntico; los tres pares previos sin regresión
(`nginx → caddy` sigue dando `Valid configuration`, `systemd → arje` sigue emitiendo sus 3 tarjetas).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 20:30:20 +00:00

157 lines
8.0 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()