From 556fcad5fa15fa0a7250cf47ced1abb92d533c43 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 10:59:59 +0000 Subject: [PATCH] =?UTF-8?q?SDD=2030=20=C2=A74a:=20el=20perfil=20ya=20sabe?= =?UTF-8?q?=20HABILITAR=20=E2=80=94=20y=20avisa=20del=20demonio=20que=20na?= =?UTF-8?q?die=20arranca?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paquetes` decía qué se INSTALA; no había dónde decir qué se LEVANTA. El sshd del producto arrancaba porque su Card estaba escrita a mano en una constante de Rust, no porque nadie lo hubiera declarado. `servicios = [...]` por perfil (se hereda como `paquetes`, mismo orden y dedup) + `targets.py --servicios `, que cruza label → receta → exec contra los `[[service]]` del corpus y la membresía de perfil de build-state.json. Lo que vale más es la comprobación INVERSA: avisa de los paquetes que están en la imagen, TRAEN un demonio y el perfil no arranca. Es la versión servicios de la lección de `foot` —la métrica mide la clausura de lo DECLARADO y no ve lo que falta en la declaración— y no es hipotética: antes de escribir `servicios = ["sshd"]` el resolutor ya avisaba «openssh está en la imagen y TRAE este servicio, pero el perfil no lo arranca». Probado con roturas A PROPÓSITO, 5/5, y con un control que TIENE que pasar: habilitado sin declarar (ERROR) · dos recetas con el mismo label (ERROR) · lo declara una receta que no está en el perfil (ERROR: el card apuntaría a un binario ausente y arje lo encarnaría con ENOENT en cada backoff) · la inversa (AVISO, no rompe el cron) · el caso bueno (rc=0). Los 8 perfiles siguen expandiendo igual y los llamadores de shell no cambian. --- docs/state/targets.toml | 14 +++++ scripts/targets.py | 115 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/docs/state/targets.toml b/docs/state/targets.toml index a3b68e5a..06195c68 100644 --- a/docs/state/targets.toml +++ b/docs/state/targets.toml @@ -1005,3 +1005,17 @@ paquetes = [ # suyo por configuración, pero nada más lo hace. "logrotate", ] + +# ── QUÉ ARRANCA (el «enable»; SDD 30 §3) ──────────────────────────────────────────────────────── +# `paquetes` dice qué se INSTALA; esto dice qué se LEVANTA. Son dos hechos distintos y hasta hoy la +# distro sólo sabía escribir el primero: el `sshd` de la imagen del producto arrancaba porque su +# Card estaba escrita a mano en una constante de Rust (`takana_bootstrap::SSHD_SERVICE_CARD`), no +# porque alguien lo hubiera declarado en ningún sitio. +# +# Cada entrada es el `label` de un `[[service]]` declarado por una receta DEL PERFIL. +# `scripts/targets.py --servicios servidor` resuelve label → receta → exec, y además hace la +# comprobación INVERSA: avisa de los paquetes de la imagen que TRAEN un demonio que nadie arranca. +# Es la versión servicios de la lección de `foot` — la métrica mide la clausura de lo declarado y no +# puede ver lo que falta en la declaración. Comprobado antes de escribir esta línea: el resolutor +# avisaba «`openssh` está en la imagen y TRAE este servicio, pero el perfil no lo arranca». +servicios = ["sshd"] diff --git a/scripts/targets.py b/scripts/targets.py index 2139e130..57ea812c 100755 --- a/scripts/targets.py +++ b/scripts/targets.py @@ -7,6 +7,7 @@ # 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 --servicios cli # los servicios que ESA imagen arranca, resueltos # 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 @@ -30,6 +31,10 @@ def load(path=DEFAULT): 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 @@ -43,11 +48,19 @@ def load(path=DEFAULT): 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: @@ -55,6 +68,94 @@ def load(path=DEFAULT): 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 + + +def _membresia(): + """{receta: [perfiles]} desde build-state.json. `{}` si no está (no es fatal: degrada a no + poder comprobar la pertenencia, y se dice).""" + try: + d = json.load(open(ROOT / "docs/state/build-state.json")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return {k: v.get("perfiles", []) for k, v in d.get("nodes", {}).items()} + + +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"]) + + 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 + if len(cands) > 1: + quienes = ", ".join(r for r, _ in cands) + problemas.append(f"ERROR {lbl}: lo declaran {len(cands)} recetas ({quienes})") + continue + receta, svc = cands[0] + habilitados.append((lbl, receta, svc.get("exec", "?"))) + # La receta que lo trae tiene que estar EN la imagen. Si no, el card apuntaría a un binario + # que el rootfs no contiene: arje lo encarnaría con ENOENT en cada reintento del backoff. + perfs = memb.get(receta) + if perfs is None: + if receta not in raices: + problemas.append( + f"AVISO {lbl}: no puedo comprobar que `{receta}` esté en el perfil " + f"(sin build-state.json y no es raíz)") + elif perfil not in perfs: + problemas.append( + f"ERROR {lbl}: lo declara `{receta}`, que NO pertenece al perfil " + f"(está en: {', '.join(perfs) or 'ningún perfil'})") + + # 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: + en_perfil = perfil in memb.get(receta, []) if memb else receta in raices + if en_perfil: + 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 main(): args = sys.argv[1:] try: @@ -68,6 +169,20 @@ def main(): 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] in ("--servicios", "-s"): + if len(args) < 2 or args[1] not in perfiles: + print(f"uso: targets.py --servicios (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