Files
takana/scripts/mudanza/formatos/arje.py
T
SergioandClaude Opus 5 d0e3aadb12 declarar: /proc pasa a ser un lector del centro — y había DOS emisores de tarjetas, mal los dos
`declarar.py` tenía su propio molde de card de arje y `formatos/arje.py` el suyo: el N×M que el
pivote existe para evitar, adentro de mi propio código. Y no quedó en teoría — CADA COPIA TENÍA UN
CAMPO MAL DE LA RAÍZ, Y NINGUNO DE LOS DOS EL MISMO:

    campo         declarar.py            arje.py        semilla REAL del producto
    provides      ["Spawn","Journal"] ✓  [] ✗           ["Spawn","Journal"]
    supervision   Restart{…} ✗           "OneShot" ✓    "OneShot"

Las consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni dónde
escribir, y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.

Ahora hay un lector `proc` (el censo es un formato de origen, igual que systemd u OpenRC) y el
escritor `arje` es el ÚNICO que emite tarjetas; `declarar.py` queda con lo suyo, el perfil. Tres
lectores en la familia: systemd y openrc leen LO DECLARADO —que es lo que miente, `rc-status` daba
`stopped` para cinco servicios vivos— y `proc` lee LO QUE CORRE, que es donde aparecen los 15
`no-declarado`.

Dos cosas más, las dos sobre no mentir:

· **Una semilla vacía parecería un éxito.** Si ningún servicio tiene `decision = "muda"`, el lector
  lo DICE y sale ≠0 en vez de emitir cero tarjetas en silencio. Mismo modo de fallo que un artefacto
  vacío en el store.
· **El acta se ahogaba en su propio ruido.** Anotaba el `envp` vacío una vez POR SERVICIO: 26 líneas
  idénticas que tapaban los dos hallazgos reales (`shuma-daemon` corre como `sergio`; los que no
  tienen cmdline). La limitación es del lector y vale para todos ⇒ una entrada nombrando a los 26.
  Un acta donde casi todo es la misma línea se deja de leer, y entonces no queda ningún acta. Y al
  revés: las entradas que SÍ son por servicio ahora lo nombran (`gitea: cwd=/var/lib/gitea`).

Controles: la raíz generada coincide campo por campo con la del producto; dos corridas dan el fichero
byte a byte idéntico; 26 tarjetas con 26 ids únicos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-12 00:31:09 +00:00

168 lines
9.4 KiB
Python

"""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, desde):
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"{srv.nombre}: cwd={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"{srv.nombre}: 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"{srv.nombre}: 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": desde, "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, etiqueta="mudanza"):
acta = sis.acta
hijas, vistos = [], {}
for srv in sis.servicios:
# Dos servicios con el mismo nombre ⇒ el mismo id determinista ⇒ una semilla con dos cards
# que se llaman igual. Pasa de verdad: `/etc/init.d` de gioser guarda copias `*.bak-FECHA`
# junto a los servicios vivos, y son scripts de OpenRC perfectamente válidos.
if srv.nombre in vistos:
acta.anota(srv.origen, "SIN-TRADUCIR", srv.nombre,
f"nombre repetido (ya salió en la línea {vistos[srv.nombre]}): produciría DOS "
"tarjetas con el mismo id. No se emite la segunda — decidí cuál vale y borrá "
"la otra (suelen ser copias `*.bak` en el mismo directorio)")
continue
vistos[srv.nombre] = srv.origen
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, sis.formato_origen or "?")
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)
hijas.sort(key=lambda t: t["label"]) # orden estable ⇒ `diff` útil entre corridas
# ⚠ La raíz se calca de la semilla REAL del producto (`STAGE1_SEED_CARD`), y el calco importa:
# cuando esto vivía en dos sitios, cada copia tenía un campo mal y ninguno de los dos igual.
# `provides` debe anunciar Spawn y Journal —si no, las hijas no tienen quién las lance ni dónde
# escribir— y la supervisión de una card `Virtual` es `OneShot`: no hay proceso que reiniciar,
# así que un `Restart` acá le pide a arje que respawnee algo que nunca corrió.
raiz = {
"flow": {"input": [], "output": []},
"genesis": hijas,
"id": ulid_determinista(SEM + "-raiz", etiqueta),
"label": etiqueta,
"lifecycle": "daemon",
"lineage": None,
"payload": "Virtual",
"permissions": {"filesystem": "read-write", "ipc": {"allow": []},
"networking": "full", "processes": True},
"priority": "normal",
"provides": ["Spawn", "Journal"],
"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"