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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user