Files
takana/scripts/targets.py
T
Sergio 1b56b16402 SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil
Lo que el script de sesión lanza con `&` ahora está declarado en las recetas y
habilitado en el perfil. Ninguna receta movió su hash: 9/9 idénticos a los que
los grafos ya registraban.

EL HALLAZGO, y no lo buscaba: la comprobación inversa del resolutor rechazó
`arje-logind-compat` y `arje-polkit-compat` porque están en CERO perfiles — y
sin embargo qemu-desktop-image.sh los copia al rootfs a mano y el de COSMIC hace
`exit 1` si falta logind-compat. Dos binarios imprescindibles, presentes en la
imagen y ausentes del destino declarado: la misma forma del agujero de `foot`,
encontrada por una comprobación en vez de por una imagen inusable. Son raíces de
escritorio-gnome (los dos) y de escritorio-cosmic (sólo logind, verificado que
sus scripts no nombran polkit).

DOS COSAS QUE NO SON TRANSCRIPCIÓN:
- `dbus-daemon --fork` no se traduce tal cual: arje supervisa al HIJO DIRECTO y
  Type=forking no existe, así que un daemon que forkea y sale deja a arje viendo
  morir al padre con éxito y reencarnándolo para siempre. La card usa --nofork.
- `scope = system|session` decide DÓNDE va la card. Las de sesión necesitan
  XDG_RUNTIME_DIR y usuario logueado; en el genesis arrancarían antes de que
  exista ninguno. Y fuera de mirada NADIE entrega cards de sesión todavía, así
  que salen con AVISO: el hueco queda contado, no omitido.

Correcciones propias: la unicidad del label es DENTRO del perfil, no del corpus
(upower vive legítimamente en dos colas); la membresía se lee de los CINCO
grafos, no sólo el del corpus; una RAÍZ manda sobre el grafo, que es derivado y
lo regenera el cron; y la flag nace en inglés (`--services`) como manda la
regla 4, aunque `--lista` sea deuda vieja del mismo fichero.

`--selftest`: 7 casos, el primero es el CONTROL que tiene que pasar en verde.
2026-09-12 11:08:58 +00:00

298 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
# targets.py — lee `docs/state/targets.toml` (el manifiesto de objetivo) y expande un perfil a su
# lista de RAÍCES. Un solo parser para dos consumidores: los scripts de imagen (shell) y el grafo
# de estado (`build-state.py`), para que el set de paquetes de la distro no vuelva a vivir en tres
# strings de shell distintos.
#
# Uso: scripts/targets.py cli # raíces expandidas, una línea separada por espacios
# scripts/targets.py --lista # perfiles disponibles, con su descripción
# scripts/targets.py --json # todo el manifiesto expandido
# scripts/targets.py --services cli # los servicios que ESA imagen arranca, resueltos
# scripts/targets.py --selftest # roturas a propósito + un control que TIENE que pasar
#
# ⚠ La FLAG va en inglés (regla 4 del CLAUDE.md: la superficie de CLI es contrato) aunque `--lista`
# sea deuda vieja del mismo fichero. La CLAVE del TOML sí es `servicios`, en castellano, porque ahí
# la convención local es esa (`paquetes`, `hereda`, `cola`, `descripcion`) y mezclar idiomas DENTRO
# del manifiesto sería peor que la coherencia con el resto de la superficie.
# Como módulo: from targets import load → {perfil: {descripcion, cola, hereda, paquetes, raices}}
#
# `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
# declarado, después las propias), porque hay imágenes donde el orden de hidratación importa
# (mirada: libs antes que binarios). Duplicados: se queda la primera aparición.
import json, sys, tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT = ROOT / "docs/state/targets.toml"
def load(path=DEFAULT):
"""Devuelve {perfil: {descripcion, cola, hereda, paquetes, raices}} con `raices` ya expandido."""
doc = tomllib.load(open(path, "rb"))
crudos = doc.get("perfil", {})
perfiles = {}
for n, p in crudos.items():
perfiles[n] = dict(
descripcion=p.get("descripcion", ""),
cola=p.get("cola", "corpus"),
hereda=list(p.get("hereda", [])),
paquetes=list(p.get("paquetes", [])),
# `servicios` = los LABELS que esta imagen arranca. Es el «enable» de la distro: el
# paquete declara QUÉ es el servicio (`[[service]]` en su receta) y el perfil decide SI
# arranca, igual que systemd parte [Service] de [Install]. Ver SDD 30 §3.
servicios=list(p.get("servicios", [])),
)
# Expansión de `hereda` con detección de ciclo (un manifiesto con un ciclo es un error del
# humano, no un caso a tolerar: sale ruidoso).
def expandir(n, camino):
if n in camino:
raise ValueError(f"ciclo en `hereda`: {' → '.join(camino + [n])}")
if n not in perfiles:
raise KeyError(f"perfil desconocido en `hereda`: {n}")
p = perfiles[n]
if "raices" in p:
return p["raices"]
vistos, raices = set(), []
vistos_s, svcs = set(), []
for h in p["hereda"] + [None]:
hp = perfiles[h] if h else None
for pkg in (expandir(h, camino + [n]) if h else p["paquetes"]):
if pkg not in vistos:
vistos.add(pkg); raices.append(pkg)
# los servicios heredados viajan con el perfil padre (ya expandido por la llamada de
# arriba): una imagen que hereda `cli` arranca también lo que `cli` arranca.
for s in (hp["servicios_exp"] if hp else p["servicios"]):
if s not in vistos_s:
vistos_s.add(s); svcs.append(s)
p["raices"] = raices
p["servicios_exp"] = svcs
return raices
for n in perfiles:
expandir(n, [])
return perfiles
# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
#
# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
# (`servicios = [...]`). Este resolutor cruza las dos mitades y, sobre todo, hace la comprobación
# INVERSA — la que ninguna métrica hacía: un paquete del perfil que TRAE un demonio y que el perfil
# no arranca. Esa es la versión servicios de la lección de `foot` que este manifiesto ya aprendió
# caro: la métrica mide la clausura de lo DECLARADO y no puede ver lo que falta en la declaración.
# Una imagen puede salir 121/121 sellada, con el demonio adentro, y sin que nada lo levante nunca.
def declaraciones(recipes_dir=None):
"""Escanea el corpus y devuelve {label: [(receta, servicio_dict), ...]}.
Devuelve la LISTA por label (no el primero) a propósito: dos recetas que declaren el mismo
label es un error que hay que ver, y quedarse con una lo escondería."""
recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
por_label = {}
for f in sorted(recipes_dir.rglob("*.toml")):
try:
doc = tomllib.load(open(f, "rb"))
except tomllib.TOMLDecodeError:
continue # recetas rotas las reporta el grafo, no este resolutor
for s in doc.get("service", []):
lbl = s.get("label")
if lbl:
por_label.setdefault(lbl, []).append((doc.get("name", f.stem), s))
return por_label
# Los grafos por COLA. `build-state.json` es sólo el corpus: las recetas de un escritorio viven en
# `recipes/incoming-<x>/` y `build-state.py` sólo las carga con su flag, así que su membresía de
# perfil está en el grafo de SU cola. Mirar sólo el del corpus daría "no puedo comprobar" para todo
# GNOME/KDE/COSMIC — que es peor que no comprobar: parece una respuesta.
GRAFOS = ["build-state.json", "build-state-gnome.json", "build-state-kde.json",
"build-state-cosmic.json", "build-state-wlr.json"]
def _membresia():
"""{receta: [perfiles]} fusionando TODOS los grafos de estado. `{}` si no hay ninguno (no es
fatal: degrada a no poder comprobar la pertenencia, y se dice)."""
memb = {}
for nombre in GRAFOS:
try:
d = json.load(open(ROOT / "docs/state" / nombre))
except (FileNotFoundError, json.JSONDecodeError):
continue
for k, v in d.get("nodes", {}).items():
# unión: una receta del corpus puede aparecer en varios grafos con perfiles distintos
ya = memb.setdefault(k, [])
for perf in v.get("perfiles", []):
if perf not in ya:
ya.append(perf)
return memb
def resolver_servicios(perfil, perfiles=None):
"""Devuelve (habilitados, problemas) para un perfil.
habilitados: [(label, receta, exec)] en el orden declarado.
problemas: lista de strings. Distingue ERROR (rompe la imagen) de AVISO (huele mal)."""
perfiles = perfiles or load()
p = perfiles[perfil]
decl = declaraciones()
memb = _membresia()
raices = set(p["raices"])
def en_el_perfil(receta):
"""`True`/`False`/`None` (= no se puede saber).
Las RAÍCES mandan sobre el grafo, y el orden importa: `targets.toml` es la fuente de verdad
de lo que la imagen quiere y `build-state.json` es DERIVADO de él —lo regenera el cron cada
30 min—. Preguntarle primero al derivado haría que añadir una raíz se leyera como error
hasta la siguiente cosecha, que es castigar al que arregla el manifiesto."""
if receta in raices:
return True
perfs = memb.get(receta)
if perfs is None:
return None
return perfil in perfs
habilitados, problemas = [], []
for lbl in p["servicios_exp"]:
cands = decl.get(lbl, [])
if not cands:
problemas.append(f"ERROR {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
continue
# La unicidad del label se juzga DENTRO del perfil, no en el corpus entero: el mismo
# servicio existe legítimamente en dos colas (p.ej. `upower` en incoming-gnome y en
# incoming-kde) y son la misma unidad en dos imágenes distintas. Exigir unicidad global
# convertiría esa convivencia normal en un error.
dentro = [(r, s) for r, s in cands if en_el_perfil(r) is True]
incierto = [(r, s) for r, s in cands if en_el_perfil(r) is None]
if not dentro and incierto:
quienes = ", ".join(r for r, _ in incierto)
problemas.append(f"AVISO {lbl}: no puedo comprobar que `{quienes}` esté en el perfil "
f"(sin build-state.json y no es raíz)")
receta, svc = incierto[0]
elif not dentro:
quienes = ", ".join(r for r, _ in cands)
problemas.append(
f"ERROR {lbl}: lo declara(n) {quienes}, que NO pertenece(n) al perfil ⇒ el card "
f"apuntaría a un binario que la imagen no trae")
continue
elif len(dentro) > 1:
quienes = ", ".join(r for r, _ in dentro)
problemas.append(f"ERROR {lbl}: lo declaran {len(dentro)} recetas DEL PERFIL ({quienes})")
continue
else:
receta, svc = dentro[0]
if svc.get("scope", "system") != "system":
problemas.append(
f"AVISO {lbl}: es de SESIÓN (`{receta}`) — hoy lo lanza el script de sesión, no "
f"arje: fuera de mirada nadie entrega cards de sesión (SDD 30 §4c)")
habilitados.append((lbl, receta, svc.get("exec", "?")))
# La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
hab = {l for l, _, _ in habilitados}
for lbl, cands in sorted(decl.items()):
if lbl in hab:
continue
for receta, _ in cands:
if en_el_perfil(receta) is True:
problemas.append(
f"AVISO {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
f"pero el perfil no lo arranca")
return habilitados, problemas
def autotest():
"""Roturas A PROPÓSITO + un control que TIENE que pasar.
Un guardián que nunca falló no se sabe si sirve, y uno que sólo se prueba con casos rotos puede
estar gritando siempre. Por eso el primer caso es el BUENO: si ese no pasa en verde, el resto de
los ✓ no prueban nada."""
global declaraciones, _membresia
orig_d, orig_m = declaraciones, _membresia
SVC = {"label": "sshd", "exec": "/bin/busybox"}
SES = {"label": "pipewire", "exec": "/usr/bin/pipewire", "scope": "session"}
def perfil(svcs, raices=("openssh",)):
return dict(descripcion="", cola="corpus", hereda=[], paquetes=list(raices),
servicios=list(svcs), raices=list(raices), servicios_exp=list(svcs))
casos = [
# (nombre, perfil, declaraciones, membresía, ¿espera ERROR?)
("CONTROL: declarado, habilitado y la receta en el perfil",
perfil(["sshd"]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
("habilitado y NADIE lo declara",
perfil(["sshd"]), {}, {}, True),
("dos recetas DEL PERFIL declaran el mismo label",
perfil(["sshd"], ("openssh", "dropbear")),
{"sshd": [("openssh", SVC), ("dropbear", SVC)]}, {}, True),
("lo declara una receta que NO está en el perfil",
perfil(["sshd"], ()), {"sshd": [("openssh", SVC)]}, {"openssh": ["otro"]}, True),
("mismo label en DOS COLAS, sólo una en el perfil ⇒ NO es colisión",
perfil(["sshd"]), {"sshd": [("openssh", SVC), ("openssh-kde", SVC)]},
{"openssh": ["prueba"], "openssh-kde": ["otro"]}, False),
("servicio de SESIÓN ⇒ avisa pero no rompe",
perfil(["pipewire"], ("pipewire",)), {"pipewire": [("pipewire", SES)]}, {}, False),
("la INVERSA: en la imagen, trae demonio, nadie lo arranca ⇒ AVISO",
perfil([]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
]
todo_ok = True
try:
for nombre, p_, decl, memb, espera in casos:
declaraciones = lambda *a, **k: decl
_membresia = lambda: memb
_, prob = resolver_servicios("prueba", {"prueba": p_})
hay = any(x.startswith("ERROR") for x in prob)
ok = hay == espera
todo_ok &= ok
print(f"{'✓' if ok else '✗'} {nombre}")
for x in prob:
print(f" {x}")
finally:
declaraciones, _membresia = orig_d, orig_m
print("TODAS OK" if todo_ok else "HAY FALLOS")
return todo_ok
def main():
args = sys.argv[1:]
try:
perfiles = load()
except FileNotFoundError:
print(f"no existe {DEFAULT.relative_to(ROOT)}", file=sys.stderr); return 1
except (ValueError, KeyError) as e:
print(f"manifiesto inválido: {e}", file=sys.stderr); return 1
if not args or args[0] in ("--lista", "-l"):
for n, p in sorted(perfiles.items()):
print(f"{n:20} {len(p['raices']):3} raíces [{p['cola']}] {p['descripcion']}")
return 0
if args[0] == "--selftest":
return 0 if autotest() else 1
if args[0] in ("--services", "-s"):
if len(args) < 2 or args[1] not in perfiles:
print(f"uso: targets.py --services <perfil> (hay: {', '.join(sorted(perfiles))})",
file=sys.stderr)
return 1
habilitados, problemas = resolver_servicios(args[1], perfiles)
for lbl, receta, ex in habilitados:
print(f"{lbl:24} {receta:20} {ex}")
if not habilitados:
print("(el perfil no arranca ningún servicio)")
for pr in problemas:
print(pr, file=sys.stderr)
# Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
return 1 if any(x.startswith("ERROR") for x in problemas) else 0
if args[0] == "--json":
print(json.dumps(perfiles, indent=1, ensure_ascii=False)); return 0
n = args[0]
if n not in perfiles:
print(f"perfil desconocido: {n} (hay: {', '.join(sorted(perfiles))})", file=sys.stderr)
return 1
print(" ".join(perfiles[n]["raices"]))
return 0
if __name__ == "__main__":
sys.exit(main())