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
This commit is contained in:
Sergio
2026-09-11 19:15:21 +00:00
co-authored by Claude Opus 5
parent 822d0b8949
commit fefa92ec5a
11 changed files with 559 additions and 45 deletions
+43
View File
@@ -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
+1 -16
View File
@@ -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."""
+23 -7
View File
@@ -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())}
+2 -2
View File
@@ -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
+151
View File
@@ -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"
+1 -1
View File
@@ -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}.",
+2 -2
View File
@@ -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
+203
View File
@@ -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
+23
View File
@@ -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'
+42
View File
@@ -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)
+68 -17
View File
@@ -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}")