Files
takana/scripts/mudanza/formatos/systemd.py
T
SergioandClaude Opus 5 fefa92ec5a 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
2026-09-11 19:15:21 +00:00

204 lines
9.4 KiB
Python

"""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