From fefa92ec5a635ab719659a54f9a99c8546b238c7 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 19:15:21 +0000 Subject: [PATCH] =?UTF-8?q?traducir:=20familia=20=C2=ABservicio=C2=BB=20?= =?UTF-8?q?=E2=80=94=20systemd=20=E2=86=92=20tarjeta=20de=20arje,=20y=20el?= =?UTF-8?q?=20pivote=20pasa=20a=20ser=20uno=20POR=20FAMILIA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un unit de systemd no es un sitio web. Meterlo en `Sitio`/`Ruta` sería justo el «parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro empareja SÓLO dentro de la familia. Un par cruzado se rechaza con un error, no con un intento: `systemd → caddy` sale por `sys.exit`, porque traducir entre familias daría un fichero que PARECE correcto. Dos familias hoy: `web` (nginx, apache → caddy) y `servicio` (systemd → arje). 3 pares con 5 plugins. **Dónde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema.** El payload `Native` de arje acepta `exec`, `argv` y `envp` y nada más (comprobado en la semilla real del producto): NO hay campo de usuario. Un `User=git` traducido en silencio correría el servicio COMO ROOT — escalación de privilegios en un fichero generado que nadie vuelve a leer. Sale SIN-TRADUCIR con su línea. Igual `EnvironmentFile=` (el fichero no está en la máquina donde se traduce ⇒ envp incompleto, falla tarde), `Type=forking` (arje supervisa al proceso que lanza ⇒ BUCLE DE REINICIO) y todo el endurecimiento, que callado entrega un servicio MENOS confinado que el original. Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la semilla del producto — buscarlo antes de declararlo intraducible evitó una elisión inventada. **Lector y escritor no opinan del mismo campo.** `User=` lo CAPTURA el lector y lo JUZGA el escritor; cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea, y un acta que se contradice se deja de leer. El lector registra en qué línea vio cada campo (`Servicio.lineas`) para que el escritor señale la línea real en vez de un 0. Controles: la tarjeta generada tiene el MISMO juego de claves que la tarjeta real de `sshd` del producto (+`_mudanza` de procedencia); dos corridas dan el fichero byte a byte idéntico; y la familia `web` sigue validando con el caddy del corpus (`Valid configuration` en los dos pares). De paso, `ulid_determinista` sale a `ids.py`: lo necesitaban `declarar.py` y el escritor de arje, y copiarlo habría dejado dos generadores de id que se pueden separar sin que nada falle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 43 ++++++ scripts/mudanza/declarar.py | 17 +-- scripts/mudanza/formatos/__init__.py | 30 +++- scripts/mudanza/formatos/apache.py | 4 +- scripts/mudanza/formatos/arje.py | 151 ++++++++++++++++++++ scripts/mudanza/formatos/caddy.py | 2 +- scripts/mudanza/formatos/nginx.py | 4 +- scripts/mudanza/formatos/systemd.py | 203 +++++++++++++++++++++++++++ scripts/mudanza/ids.py | 23 +++ scripts/mudanza/modelo.py | 42 ++++++ scripts/mudanza/traducir.py | 85 ++++++++--- 11 files changed, 559 insertions(+), 45 deletions(-) create mode 100644 scripts/mudanza/formatos/arje.py create mode 100644 scripts/mudanza/formatos/systemd.py create mode 100644 scripts/mudanza/ids.py diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 5ff8c8f9..e207b352 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -332,6 +332,49 @@ centro los **descubre**: no hay una lista que editar, porque una lista mantenida desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares habilita y cuántos plugins costaron. +#### El pivote es uno POR FAMILIA + +Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el +«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro +**empareja sólo dentro de la familia**: + +| familia | modelo | lectores | escritores | +|---|---|---|---| +| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy | +| `servicio` | `Servicio` / `Sistema` | systemd | arje | + +Un par cruzado se rechaza con un error, no con un intento: + +``` +$ traducir.py --from systemd --to caddy … +✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web». + No es un par: traducir entre familias distintas daría un fichero que PARECE correcto. +``` + +#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema + +El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla +real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio +**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie +vuelve a leer**. Sale como SIN-TRADUCIR, con su línea. + +Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce +⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que +lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del +humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un +servicio **menos confinado** que el original. + +Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la +semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la +tarjeta real de `sshd`**, más `_mudanza` de procedencia. + +#### Lector y escritor no opinan del mismo campo + +`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía +dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector +registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de +un `0` que obliga a buscar a mano lo que ya se sabía. + #### 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 diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py index a371385c..bf4ee37a 100755 --- a/scripts/mudanza/declarar.py +++ b/scripts/mudanza/declarar.py @@ -35,22 +35,7 @@ import argparse, hashlib, json, os, shlex, sys, tomllib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from planear import origen_binario, alternativas, _REPO # noqa: E402 - -CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # sin I, L, O, U - - -def ulid_determinista(sem, nombre): - """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real - —no hace falta— pero respeta el formato, y ser determinista es lo que permite `diff`.""" - h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest() - n = int.from_bytes(h, "big") - out = [] - for _ in range(26): - out.append(CROCKFORD[n & 31]) - n >>= 5 - s = "".join(reversed(out)) - return "0" + s[1:] # el primer carácter de un ULID válido es <= '7' - +from ids import ulid_determinista # noqa: E402 def tarjeta(nombre, cmdline, cwd, puertos): """Una entrada `genesis`. El molde sale de la semilla real del producto.""" diff --git a/scripts/mudanza/formatos/__init__.py b/scripts/mudanza/formatos/__init__.py index 03fe2fdb..08e9e70c 100644 --- a/scripts/mudanza/formatos/__init__.py +++ b/scripts/mudanza/formatos/__init__.py @@ -3,27 +3,33 @@ 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") + @lector("nginx", familia="web") def leer(texto) -> Config: ... - @escritor("caddy") + @escritor("caddy", familia="web") def escribir(cfg) -> str: ... + +⚠ **LA FAMILIA NO ES DECORACIÓN.** El pivote es uno por familia: un unit de systemd no es un sitio +web, y emparejar un lector de servicios con un escritor de web produciría basura *plausible* — el +modo de fallo caro. El registro empareja SÓLO dentro de la familia y rechaza el cruce con un error, +no con un intento. Es la misma regla del acta, aplicada un nivel más arriba: parecerse es peor que +faltar. """ import importlib, pkgutil, pathlib -LECTORES, ESCRITORES = {}, {} +LECTORES, ESCRITORES = {}, {} # nombre -> (funcion, familia) -def lector(nombre): +def lector(nombre, familia="web"): def deco(f): - LECTORES[nombre] = f + LECTORES[nombre] = (f, familia) return f return deco -def escritor(nombre): +def escritor(nombre, familia="web"): def deco(f): - ESCRITORES[nombre] = f + ESCRITORES[nombre] = (f, familia) return f return deco @@ -35,3 +41,13 @@ def cargar(): if not m.name.startswith("_"): importlib.import_module(f"{__name__}.{m.name}") return LECTORES, ESCRITORES + + +def familias(): + """{familia: (lectores, escritores)} — para listar y para contar pares de verdad.""" + d = {} + for n, (_, fam) in LECTORES.items(): + d.setdefault(fam, ([], []))[0].append(n) + for n, (_, fam) in ESCRITORES.items(): + d.setdefault(fam, ([], []))[1].append(n) + return {k: (sorted(v[0]), sorted(v[1])) for k, v in sorted(d.items())} diff --git a/scripts/mudanza/formatos/apache.py b/scripts/mudanza/formatos/apache.py index 8b1601f3..3fab2e4c 100644 --- a/scripts/mudanza/formatos/apache.py +++ b/scripts/mudanza/formatos/apache.py @@ -13,8 +13,8 @@ IRRELEVANTES = {"ServerRoot", "Listen", "LoadModule", "TypesConfig", "ErrorLog", "LogLevel", "Timeout", "KeepAlive", "User", "Group", "PidFile", "ServerAdmin"} -@lector("apache") -def leer(texto): +@lector("apache", familia="web") +def leer(texto, nombre=""): cfg = Config(formato_origen="apache") A = cfg.acta sitio, ruta = None, None diff --git a/scripts/mudanza/formatos/arje.py b/scripts/mudanza/formatos/arje.py new file mode 100644 index 00000000..d1a13bb9 --- /dev/null +++ b/scripts/mudanza/formatos/arje.py @@ -0,0 +1,151 @@ +"""Escritor de tarjetas de arje ← modelo pivote «servicio» (SDD 29). + +Emite una **semilla** (`seed.card.json`): una card `Virtual` raíz cuyos `genesis` son los servicios. +Es el mismo molde que `declarar.py` saca del censo — la diferencia es de dónde viene el dato: allá de +`/proc` (lo que CORRE), acá de los units (lo que está DECLARADO). Se complementan, y ninguno de los +dos solo alcanza: en gioser el init daba `stopped` para cinco servicios vivos. + +⚠ **LO QUE ESTE ESCRITOR NO PUEDE Y NO FINGE** + +El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla +real del producto, `crates/takana-bootstrap/src/lib.rs`). No hay campo de usuario. Por lo tanto un +`User=www-data` no tiene traducción: la tarjeta correría el servicio **como root**. Eso no es perder +una opción, es **escalar privilegios en un fichero generado que nadie vuelve a leer**, así que sale +como SIN-TRADUCIR y el traductor termina con código ≠ 0. + +`Type=forking` es el otro: arje supervisa al proceso que lanza, y un demonio que se va a segundo +plano y sale deja a su padre muerto a los milisegundos ⇒ **bucle de reinicio**. Casi siempre se +arregla con la bandera de primer plano del propio demonio (`-g 'daemon off;'`, `-D`, `--foreground`), +pero cuál es depende del demonio: decirlo es del humano, adivinarlo no. +""" +import json, shlex, sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ids import ulid_determinista # noqa: E402 +from formatos import escritor # noqa: E402 + +SEM = "takana-mudanza" + + +def _tarjeta(srv, acta): + argv_all = shlex.split(srv.cmd) if srv.cmd else [] + if not argv_all: + return None + if srv.cwd and srv.cwd not in ("/", ""): + # `Native` no tiene `cwd`: se envuelve, que es el idioma que la tarjeta de sshd ya usa. + exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(srv.cwd)} && exec {srv.cmd}"] + acta.anota(srv.lineas.get("cwd", srv.origen), "decision", f"WorkingDirectory={srv.cwd}", + "el payload `Native` no tiene `cwd`: se envuelve en `sh -c 'cd … && exec …'`") + else: + exec_, argv = argv_all[0], argv_all[1:] + + if srv.usuario and srv.usuario != "root": + acta.anota(srv.lineas.get("usuario", srv.origen), "SIN-TRADUCIR", f"User={srv.usuario}", + "el payload `Native` de arje NO tiene campo de usuario ⇒ esta tarjeta corre el " + "servicio COMO ROOT. Hay que bajar el privilegio dentro del comando " + "(p. ej. `setpriv --reuid`) antes de desplegarla") + if srv.grupo and srv.grupo != "root": + acta.anota(srv.lineas.get("grupo", srv.origen), "SIN-TRADUCIR", f"Group={srv.grupo}", + "mismo motivo que `User=`: no hay dónde ponerlo en la tarjeta") + + if srv.tipo == "forking": + acta.anota(srv.lineas.get("tipo", srv.origen), "SIN-TRADUCIR", f"Type=forking ({srv.nombre})", + "arje supervisa al proceso que lanza: un demonio que se va a segundo plano sale " + "enseguida y queda en BUCLE DE REINICIO. Hay que agregarle su bandera de primer " + "plano, que depende del demonio") + elif srv.tipo in ("notify", "dbus"): + acta.anota(srv.lineas.get("tipo", srv.origen), "decision", f"Type={srv.tipo} ({srv.nombre})", + f"arje no implementa el protocolo de `{srv.tipo}`: se trata como `simple`, o sea " + "que se da por arrancado al ejecutarse y no cuando el servicio avisa que está listo") + + # `oneshot` SÍ tiene destino: la semilla real del producto usa `\"supervision\": \"OneShot\"`. + if srv.tipo == "oneshot": + sup = "OneShot" + elif srv.reinicio == "no": + sup = "OneShot" + acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=no ({srv.nombre})", + "sin reinicio, la tarjeta se emite como `OneShot`: arje no lo revive si muere") + else: + ini = srv.reinicio_ms or 500 + sup = {"Restart": {"initial": ini, "max": max(ini * 40, 20000)}} + if srv.reinicio == "si-falla": + acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=si-falla ({srv.nombre})", + "arje reinicia SIEMPRE, no sólo tras fallo: una salida limpia también lo revive") + + if srv.entorno_ficheros: + acta.anota(srv.lineas.get("entorno_ficheros", srv.origen), "SIN-TRADUCIR", f"EnvironmentFile ({srv.nombre})", + "no se copió ninguna variable de esos ficheros: el `envp` de la tarjeta sale " + "INCOMPLETO y el servicio va a fallar tarde") + + return { + "flow": {"input": [], "output": []}, + "genesis": [], + "id": ulid_determinista(SEM, srv.nombre), + "label": srv.nombre, + "lifecycle": "daemon", + "lineage": None, + "payload": {"Native": {"argv": argv, "envp": [f"{k}={v}" for k, v in sorted(srv.entorno.items())], + "exec": exec_}}, + "permissions": {"filesystem": "read-write", "ipc": {"allow": []}, + "networking": "full", "processes": True}, + "priority": "normal", + "provides": [], + # `After=`/`Requires=` se conservan por NOMBRE: arje resuelve el orden por su grafo, y los + # objetivos de systemd (`network.target`…) no existen acá — por eso se filtran los `.target`. + "requires": sorted({d.replace(".service", "") for d in (srv.tras + srv.necesita) + if not d.endswith(".target")}), + "schema_version": 1, + "soma": { + "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{srv.nombre}"}, + "cpu_affinity": None, + "namespaces": {k: False for k in + ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")}, + "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None}, + }, + "supervision": sup, + "_mudanza": {"desde": "systemd", "descripcion": srv.descripcion, + "tipo_origen": srv.tipo, "cwd_original": srv.cwd, + "usuario_original": srv.usuario, "arranque_auto": srv.arranque_auto}, + } + + +@escritor("arje", familia="servicio") +def escribir(sis): + acta = sis.acta + hijas = [] + for srv in sis.servicios: + if not srv.arranque_auto: + # Un unit instalado pero sin `WantedBy=` no lo levanta el arranque. Declararlo igual + # cambiaría el sistema: se emite, pero se dice. + acta.anota(srv.origen, "decision", f"{srv.nombre} (sin WantedBy=)", + "el unit no se levanta al arrancar en el origen; la semilla SÍ lo va a " + "levantar. Sacalo si no lo querés al boot") + t = _tarjeta(srv, acta) + if t is None: + acta.anota(srv.origen, "SIN-TRADUCIR", srv.nombre, + "sin comando de arranque: NO se emite tarjeta. Una tarjeta que no arranca es " + "peor que ninguna — la ausente falla ruidosamente al primer arranque") + continue + hijas.append(t) + + raiz = { + "flow": {"input": [], "output": []}, + "genesis": hijas, + "id": ulid_determinista(SEM + "-raiz", "mudanza"), + "label": "mudanza", + "lifecycle": "daemon", + "lineage": None, + "payload": "Virtual", + "permissions": {"filesystem": "read-write", "ipc": {"allow": []}, + "networking": "full", "processes": True}, + "priority": "normal", + "provides": [], + "requires": [], + "schema_version": 1, + "soma": {"cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"}, + "cpu_affinity": None, + "namespaces": {k: False for k in + ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")}, + "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None}}, + "supervision": "OneShot", + } + return json.dumps(raiz, indent=1, sort_keys=True, ensure_ascii=False) + "\n" diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py index 5e4d41d8..f3e8347b 100644 --- a/scripts/mudanza/formatos/caddy.py +++ b/scripts/mudanza/formatos/caddy.py @@ -30,7 +30,7 @@ def _emitir_acciones(acciones, ind): return L -@escritor("caddy") +@escritor("caddy", familia="web") def escribir(cfg): A = cfg.acta L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.", diff --git a/scripts/mudanza/formatos/nginx.py b/scripts/mudanza/formatos/nginx.py index 49df4af1..287daa66 100644 --- a/scripts/mudanza/formatos/nginx.py +++ b/scripts/mudanza/formatos/nginx.py @@ -41,8 +41,8 @@ def _tokenizar(texto): return out -@lector("nginx") -def leer(texto): +@lector("nginx", familia="web") +def leer(texto, nombre=""): cfg = Config(formato_origen="nginx") A = cfg.acta pila, sitio, ruta, opaco = [], None, None, 0 diff --git a/scripts/mudanza/formatos/systemd.py b/scripts/mudanza/formatos/systemd.py new file mode 100644 index 00000000..4bf5d64b --- /dev/null +++ b/scripts/mudanza/formatos/systemd.py @@ -0,0 +1,203 @@ +"""Lector de units de systemd → modelo pivote «servicio» (SDD 29). + +systemd es el init de la mayoría de los servidores de los que uno se muda, así que éste es el lector +que más entradas abre. Pero un unit dice MUCHO más de lo que cualquier otro init sabe expresar, y +ahí está todo el riesgo: traducir sólo `ExecStart=` y callarse el resto produce un servicio que +arranca y **hace otra cosa**. + +⚠ Los tres campos donde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema: + + · `User=` / `Group=` — sin esto el servicio corre como root. No es «una opción menos»: es + escalación de privilegios en un fichero generado, que nadie vuelve a leer. + · `EnvironmentFile=` — el fichero no existe en la máquina donde se traduce, así que su contenido + no se puede ni mirar. Un servicio al que le falta medio entorno arranca y falla tarde. + · Los especificadores (`%i`, `%h`, `%n`…) y las plantillas `foo@.service` — no se resuelven sin + saber la instancia. Expandirlos «por lo más probable» es adivinar en silencio. + +Los tres van al acta como SIN-TRADUCIR, con su línea. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from modelo import Servicio, Sistema # noqa: E402 +from formatos import lector # noqa: E402 + +# `Restart=` de systemd → el vocabulario del pivote. `on-abnormal`/`on-watchdog`/`on-abort` son +# matices de «cuándo cuenta como fallo» que ningún otro init distingue: se acercan a `si-falla` y +# eso se ANOTA, no se hace callado. +_REINICIO = {"no": "no", "always": "siempre", "on-failure": "si-falla", "on-success": "si-falla", + "on-abnormal": "si-falla", "on-watchdog": "si-falla", "on-abort": "si-falla"} +_MATIZ = {"on-abnormal", "on-watchdog", "on-abort", "on-success"} + +# Prefijos de `ExecStart=`. Cada uno cambia la semántica del arranque y ninguno es parte del comando. +_PREFIJOS = {"-": "ignorar el fallo de arranque", "@": "argv[0] distinto del ejecutable", + "+": "correr con privilegios completos", "!": "saltar la restricción de usuario", + ":": "no expandir variables de entorno"} + + +def _seg(val): + """`RestartSec=` admite `5`, `5s`, `500ms`, `1min`. Devuelve milisegundos.""" + v = val.strip().lower() + for suf, mult in (("ms", 1), ("min", 60000), ("s", 1000), ("m", 60000)): + if v.endswith(suf): + try: + return int(float(v[:-len(suf)]) * mult) + except ValueError: + return 0 + try: + return int(float(v) * 1000) + except ValueError: + return 0 + + +def _entorno(val): + """`Environment="A=1" B=2` — comillas opcionales, varios pares por línea.""" + import shlex + d = {} + try: + piezas = shlex.split(val) + except ValueError: + piezas = val.split() + for p in piezas: + if "=" in p: + k, _, v = p.partition("=") + d[k] = v + return d + + +@lector("systemd", familia="servicio") +def leer(texto, nombre=""): + sis = Sistema(formato_origen="systemd") + acta = sis.acta + base = os.path.basename(nombre or "unidad.service") + srv = Servicio(nombre=base[:-8] if base.endswith(".service") else base) + srv.plantilla = "@." in base + if srv.plantilla: + acta.anota(0, "SIN-TRADUCIR", base, + "es una PLANTILLA (`nombre@.service`): sin saber la instancia, `%i` no se resuelve " + "y el servicio traducido arrancaría con otro argumento") + + seccion, arranques, extras = "", [], [] + lineas, i = texto.splitlines(), 0 + while i < len(lineas): + n = i + 1 + cruda = lineas[i] + # Continuación de línea: systemd la usa muchísimo en ExecStart largos, y cortar ahí deja un + # comando TRUNCADO que además parece válido. + while cruda.rstrip().endswith("\\") and i + 1 < len(lineas): + i += 1 + cruda = cruda.rstrip()[:-1] + " " + lineas[i].strip() + i += 1 + ln = cruda.strip() + if not ln or ln[0] in "#;": + continue + if ln.startswith("[") and ln.endswith("]"): + seccion = ln[1:-1] + continue + if "=" not in ln: + acta.anota(n, "SIN-TRADUCIR", ln, "línea sin `clave=valor` y fuera de toda sección") + continue + k, _, v = ln.partition("=") + k, v = k.strip(), v.strip() + + if k == "Description": + srv.descripcion = v + acta.anota(n, "traducido", ln) + elif k == "Type": + srv.tipo = v + srv.lineas["tipo"] = n + acta.anota(n, "traducido", ln) + elif k == "ExecStart": + pref = "" + while v and v[0] in _PREFIJOS: + pref += v[0] + v = v[1:].lstrip() + if pref: + acta.anota(n, "SIN-TRADUCIR", ln, + "prefijo `%s` en ExecStart (%s): el pivote no lo modela y quitarlo " + "cambia el arranque" % (pref, "; ".join(_PREFIJOS[c] for c in pref))) + arranques.append((n, v)) + elif k in ("ExecStartPre", "ExecStartPost", "ExecStop", "ExecStopPost", "ExecReload", + "ExecCondition"): + extras.append((n, k, v)) + elif k == "WorkingDirectory": + srv.cwd = v + srv.lineas["cwd"] = n + acta.anota(n, "traducido", ln) + elif k in ("User", "Group"): + campo = "usuario" if k == "User" else "grupo" + setattr(srv, campo, v) + srv.lineas[campo] = n + # Sin anotación: si el destino sabe bajar de privilegio esto es `traducido`, y si no es + # SIN-TRADUCIR. Lo sabe el ESCRITOR. Que el lector opine acá duplica la entrada y la + # contradice — y un acta que se contradice se deja de leer. + elif k == "Environment": + srv.entorno.update(_entorno(v)) + acta.anota(n, "traducido", ln) + elif k == "EnvironmentFile": + srv.entorno_ficheros.append(v) + srv.lineas["entorno_ficheros"] = n + acta.anota(n, "SIN-TRADUCIR", ln, + "el fichero de entorno NO está en esta máquina: su contenido no se puede " + "leer ni copiar, y el servicio arrancaría con medio entorno") + elif k == "Restart": + if v in _MATIZ: + acta.anota(n, "decision", ln, + f"`{v}` es un matiz de «cuándo cuenta como fallo» que ningún otro init " + "distingue; se traduce como «si-falla»") + elif v not in _REINICIO: + acta.anota(n, "SIN-TRADUCIR", ln, f"valor de Restart no reconocido: `{v}`") + else: + acta.anota(n, "traducido", ln) + srv.reinicio = _REINICIO.get(v, "si-falla") + srv.lineas["reinicio"] = n + elif k == "RestartSec": + srv.reinicio_ms = _seg(v) + acta.anota(n, "traducido", ln) + elif k == "After": + srv.tras += v.split() + acta.anota(n, "traducido", ln) + elif k in ("Requires", "BindsTo"): + srv.necesita += v.split() + acta.anota(n, "traducido", ln) + elif k == "WantedBy": + srv.arranque_auto = True + acta.anota(n, "traducido", ln) + elif k in ("Wants", "Before", "Conflicts", "PartOf", "RequiredBy", "Also", + "DefaultDependencies", "StartLimitIntervalSec", "StartLimitBurst"): + acta.anota(n, "no-aplica", ln, "orden/relación que el destino resuelve por el grafo") + elif k in ("StandardOutput", "StandardError", "SyslogIdentifier"): + acta.anota(n, "no-aplica", ln, "el destino captura la salida por su propio journal") + elif k in ("Documentation", "RemainAfterExit", "KillMode", "KillSignal", "TimeoutStopSec", + "TimeoutStartSec", "PIDFile", "Alias"): + acta.anota(n, "no-aplica", ln, f"`{k}` es específico de systemd") + elif seccion == "Service": + # Todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`, `CapabilityBoundingSet`…) + # cae acá. Callarlo sería entregar un servicio MENOS confinado que el original. + acta.anota(n, "SIN-TRADUCIR", ln, + f"`{k}` no se modela; si es confinamiento, el servicio traducido corre con " + "MÁS permisos que el original") + else: + acta.anota(n, "SIN-TRADUCIR", ln, f"`{k}` en [{seccion or 'sin sección'}] no reconocida") + + if not arranques: + acta.anota(0, "SIN-TRADUCIR", base, "el unit no tiene `ExecStart=`: no hay qué arrancar") + else: + srv.cmd = arranques[0][1] + srv.origen = arranques[0][0] + acta.anota(srv.origen, "traducido", f"ExecStart={srv.cmd}") + for n, v in arranques[1:]: + acta.anota(n, "SIN-TRADUCIR", f"ExecStart={v}", + "systemd admite varios ExecStart en `Type=oneshot`; el pivote modela UNO") + for n, k, v in extras: + acta.anota(n, "SIN-TRADUCIR", f"{k}={v}", + f"`{k}` es una fase del ciclo de vida aparte del arranque; hay que portarla a mano") + + for especificador in ("%i", "%I", "%n", "%N", "%h", "%H", "%f", "%p", "%t"): + if especificador in srv.cmd: + acta.anota(0, "SIN-TRADUCIR", srv.cmd, + f"el comando usa el especificador `{especificador}`, que systemd expande en " + "tiempo de arranque; resolverlo acá sería adivinar") + break + + sis.servicios.append(srv) + return sis diff --git a/scripts/mudanza/ids.py b/scripts/mudanza/ids.py new file mode 100644 index 00000000..2767cefe --- /dev/null +++ b/scripts/mudanza/ids.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""ids.py — identificadores DETERMINISTAS para lo que se genera (SDD 29). + +Derivar el id del nombre en vez de sortearlo es lo que vuelve **revisable** un fichero generado: dos +corridas del mismo censo producen la misma semilla byte a byte y se comparan con `diff`. Un id +aleatorio obliga a leer el fichero entero para saber si algo cambió. +""" +import hashlib + +CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # sin I, L, O, U + + +def ulid_determinista(sem, nombre): + """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real + —no hace falta— pero respeta el formato.""" + h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest() + n = int.from_bytes(h, "big") + out = [] + for _ in range(26): + out.append(CROCKFORD[n & 31]) + n >>= 5 + s = "".join(reversed(out)) + return "0" + s[1:] # el primer carácter de un ULID válido es <= '7' diff --git a/scripts/mudanza/modelo.py b/scripts/mudanza/modelo.py index 3efde8fb..a72c1933 100644 --- a/scripts/mudanza/modelo.py +++ b/scripts/mudanza/modelo.py @@ -77,3 +77,45 @@ class Config: formato_origen: str = "" sitios: list = field(default_factory=list) acta: Acta = field(default_factory=Acta) + + +# ── FAMILIA «servicio» ────────────────────────────────────────────────────────────────────────── +# ⚠ El pivote NO es uno solo: es uno POR FAMILIA. Un unit de systemd no es un sitio web, y meterlo a +# la fuerza en `Sitio`/`Ruta` sería justo el «parecerse» que el acta existe para evitar. Cada familia +# tiene su modelo, y el registro sólo empareja lectores con escritores de la MISMA familia — un par +# cruzado se rechaza ruidosamente en vez de producir basura plausible. + + +@dataclass +class Servicio: + """Un proceso supervisado. El denominador común de systemd, OpenRC, runit, s6 y arje. + + `usuario` se conserva aunque el destino no siempre sepa emitirlo: es el campo donde una elisión + silenciosa **escala privilegios** en vez de sólo perder una opción.""" + nombre: str = "" + descripcion: str = "" + tipo: str = "simple" # simple | forking | oneshot | notify | dbus + cmd: str = "" # la línea de arranque, tal cual + cwd: str = "" + usuario: str = "" + grupo: str = "" + entorno: dict = field(default_factory=dict) + entorno_ficheros: list = field(default_factory=list) # no se pueden leer al traducir + tras: list = field(default_factory=list) # orden (After=) + necesita: list = field(default_factory=list) # dependencia dura (Requires=) + reinicio: str = "no" # no | siempre | si-falla + reinicio_ms: int = 0 + arranque_auto: bool = False # ¿lo levanta el arranque? (WantedBy=) + plantilla: bool = False # `foo@.service`: no instanciable a ciegas + origen: int = 0 + # {campo: línea} — el lector anota DÓNDE vio cada cosa para que el escritor pueda señalar la + # línea real en vez de un 0. Un acta que dice «línea 0» obliga a buscar a mano lo que ya se sabía. + lineas: dict = field(default_factory=dict) + + +@dataclass +class Sistema: + """Lo que produce un lector de la familia «servicio» y consume su escritor.""" + formato_origen: str = "" + servicios: list = field(default_factory=list) + acta: Acta = field(default_factory=Acta) diff --git a/scripts/mudanza/traducir.py b/scripts/mudanza/traducir.py index 53d2ee90..1b4397e8 100755 --- a/scripts/mudanza/traducir.py +++ b/scripts/mudanza/traducir.py @@ -12,11 +12,16 @@ escritores**: un formato nuevo cuesta uno o dos plugins y estrena todos los pare 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 ──────────────────────────────────────────────────────────────────────────────── +── 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**, @@ -26,8 +31,9 @@ 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 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 @@ -62,35 +68,80 @@ def imprimir_acta(acta): 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): + hs = sorted(f for f in os.listdir(ruta) + if f.endswith((".service", ".conf")) and + os.path.isfile(os.path.join(ruta, f))) + if not hs: + sys.exit(f"✗ {ruta} no tiene ningún `*.service` ni `*.conf`") + 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") + 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(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}") + 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)") - 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) + 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}")