> 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: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
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}")