tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

SDD 30 §4a: el perfil ya sabe HABILITAR — y avisa del demonio que nadie arranca

`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 <perfil>`, 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.

556fcad5fa15fa0a7250cf47ced1abb92d533c43 · por Sergio · (hace 1 mes) · padre 1445f2f91 · ver el árbol en este commit

Delta semántico · 6

scripts/targets.py

2 archivos · +129 −0

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