gioser

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 1385Shell 122Rust 77Markdown 682241 Definitions

⎇ 0b5dae781220a3d9f727d0988964f921d0add15c scripts/targets.py Python · 198 lines · 8.9 KB History Who touched what raw

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

1#!/usr/bin/env python3
2# targets.py — lee `docs/state/targets.toml` (el manifiesto de objetivo) y expande un perfil a su
3# lista de RAÍCES. Un solo parser para dos consumidores: los scripts de imagen (shell) y el grafo
4# de estado (`build-state.py`), para que el set de paquetes de la distro no vuelva a vivir en tres
5# strings de shell distintos.
6#
7# Uso:  scripts/targets.py cli               # raíces expandidas, una línea separada por espacios
8#       scripts/targets.py --lista           # perfiles disponibles, con su descripción
9#       scripts/targets.py --json            # todo el manifiesto expandido
10#       scripts/targets.py --servicios cli   # los servicios que ESA imagen arranca, resueltos
11# Como módulo:  from targets import load  →  {perfil: {descripcion, cola, hereda, paquetes, raices}}
12#
13# `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
14# declarado, después las propias), porque hay imágenes donde el orden de hidratación importa
15# (mirada: libs antes que binarios). Duplicados: se queda la primera aparición.
16import json, sys, tomllib
17from pathlib import Path
18
19ROOT = Path(__file__).resolve().parent.parent
20DEFAULT = ROOT / "docs/state/targets.toml"
21
22
23def load(path=DEFAULT):
24    """Devuelve {perfil: {descripcion, cola, hereda, paquetes, raices}} con `raices` ya expandido."""
25    doc = tomllib.load(open(path, "rb"))
26    crudos = doc.get("perfil", {})
27    perfiles = {}
28    for n, p in crudos.items():
29        perfiles[n] = dict(
30            descripcion=p.get("descripcion", ""),
31            cola=p.get("cola", "corpus"),
32            hereda=list(p.get("hereda", [])),
33            paquetes=list(p.get("paquetes", [])),
34            # `servicios` = los LABELS que esta imagen arranca. Es el «enable» de la distro: el
35            # paquete declara QUÉ es el servicio (`[[service]]` en su receta) y el perfil decide SI
36            # arranca, igual que systemd parte [Service] de [Install]. Ver SDD 30 §3.
37            servicios=list(p.get("servicios", [])),
38        )
39
40    # Expansión de `hereda` con detección de ciclo (un manifiesto con un ciclo es un error del
41    # humano, no un caso a tolerar: sale ruidoso).
42    def expandir(n, camino):
43        if n in camino:
44            raise ValueError(f"ciclo en `hereda`: {' → '.join(camino + [n])}")
45        if n not in perfiles:
46            raise KeyError(f"perfil desconocido en `hereda`: {n}")
47        p = perfiles[n]
48        if "raices" in p:
49            return p["raices"]
50        vistos, raices = set(), []
51        vistos_s, svcs = set(), []
52        for h in p["hereda"] + [None]:
53            hp = perfiles[h] if h else None
54            for pkg in (expandir(h, camino + [n]) if h else p["paquetes"]):
55                if pkg not in vistos:
56                    vistos.add(pkg); raices.append(pkg)
57            # los servicios heredados viajan con el perfil padre (ya expandido por la llamada de
58            # arriba): una imagen que hereda `cli` arranca también lo que `cli` arranca.
59            for s in (hp["servicios_exp"] if hp else p["servicios"]):
60                if s not in vistos_s:
61                    vistos_s.add(s); svcs.append(s)
62        p["raices"] = raices
63        p["servicios_exp"] = svcs
64        return raices
65
66    for n in perfiles:
67        expandir(n, [])
68    return perfiles
69
70
71# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
72#
73# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
74# (`servicios = [...]`). Este resolutor cruza las dos mitades y, sobre todo, hace la comprobación
75# INVERSA — la que ninguna métrica hacía: un paquete del perfil que TRAE un demonio y que el perfil
76# no arranca. Esa es la versión servicios de la lección de `foot` que este manifiesto ya aprendió
77# caro: la métrica mide la clausura de lo DECLARADO y no puede ver lo que falta en la declaración.
78# Una imagen puede salir 121/121 sellada, con el demonio adentro, y sin que nada lo levante nunca.
79
80def declaraciones(recipes_dir=None):
81    """Escanea el corpus y devuelve {label: [(receta, servicio_dict), ...]}.
82
83    Devuelve la LISTA por label (no el primero) a propósito: dos recetas que declaren el mismo
84    label es un error que hay que ver, y quedarse con una lo escondería."""
85    recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
86    por_label = {}
87    for f in sorted(recipes_dir.rglob("*.toml")):
88        try:
89            doc = tomllib.load(open(f, "rb"))
90        except tomllib.TOMLDecodeError:
91            continue  # recetas rotas las reporta el grafo, no este resolutor
92        for s in doc.get("service", []):
93            lbl = s.get("label")
94            if lbl:
95                por_label.setdefault(lbl, []).append((doc.get("name", f.stem), s))
96    return por_label
97
98
99def _membresia():
100    """{receta: [perfiles]} desde build-state.json. `{}` si no está (no es fatal: degrada a no
101    poder comprobar la pertenencia, y se dice)."""
102    try:
103        d = json.load(open(ROOT / "docs/state/build-state.json"))
104    except (FileNotFoundError, json.JSONDecodeError):
105        return {}
106    return {k: v.get("perfiles", []) for k, v in d.get("nodes", {}).items()}
107
108
109def resolver_servicios(perfil, perfiles=None):
110    """Devuelve (habilitados, problemas) para un perfil.
111
112    habilitados: [(label, receta, exec)] en el orden declarado.
113    problemas:   lista de strings. Distingue ERROR (rompe la imagen) de AVISO (huele mal)."""
114    perfiles = perfiles or load()
115    p = perfiles[perfil]
116    decl = declaraciones()
117    memb = _membresia()
118    raices = set(p["raices"])
119
120    habilitados, problemas = [], []
121    for lbl in p["servicios_exp"]:
122        cands = decl.get(lbl, [])
123        if not cands:
124            problemas.append(f"ERROR  {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
125            continue
126        if len(cands) > 1:
127            quienes = ", ".join(r for r, _ in cands)
128            problemas.append(f"ERROR  {lbl}: lo declaran {len(cands)} recetas ({quienes})")
129            continue
130        receta, svc = cands[0]
131        habilitados.append((lbl, receta, svc.get("exec", "?")))
132        # La receta que lo trae tiene que estar EN la imagen. Si no, el card apuntaría a un binario
133        # que el rootfs no contiene: arje lo encarnaría con ENOENT en cada reintento del backoff.
134        perfs = memb.get(receta)
135        if perfs is None:
136            if receta not in raices:
137                problemas.append(
138                    f"AVISO  {lbl}: no puedo comprobar que `{receta}` esté en el perfil "
139                    f"(sin build-state.json y no es raíz)")
140        elif perfil not in perfs:
141            problemas.append(
142                f"ERROR  {lbl}: lo declara `{receta}`, que NO pertenece al perfil "
143                f"(está en: {', '.join(perfs) or 'ningún perfil'})")
144
145    # La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
146    hab = {l for l, _, _ in habilitados}
147    for lbl, cands in sorted(decl.items()):
148        if lbl in hab:
149            continue
150        for receta, _ in cands:
151            en_perfil = perfil in memb.get(receta, []) if memb else receta in raices
152            if en_perfil:
153                problemas.append(
154                    f"AVISO  {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
155                    f"pero el perfil no lo arranca")
156    return habilitados, problemas
157
158
159def main():
160    args = sys.argv[1:]
161    try:
162        perfiles = load()
163    except FileNotFoundError:
164        print(f"no existe {DEFAULT.relative_to(ROOT)}", file=sys.stderr); return 1
165    except (ValueError, KeyError) as e:
166        print(f"manifiesto inválido: {e}", file=sys.stderr); return 1
167
168    if not args or args[0] in ("--lista", "-l"):
169        for n, p in sorted(perfiles.items()):
170            print(f"{n:20} {len(p['raices']):3} raíces  [{p['cola']}]  {p['descripcion']}")
171        return 0
172    if args[0] in ("--servicios", "-s"):
173        if len(args) < 2 or args[1] not in perfiles:
174            print(f"uso: targets.py --servicios <perfil>  (hay: {', '.join(sorted(perfiles))})",
175                  file=sys.stderr)
176            return 1
177        habilitados, problemas = resolver_servicios(args[1], perfiles)
178        for lbl, receta, ex in habilitados:
179            print(f"{lbl:24} {receta:20} {ex}")
180        if not habilitados:
181            print("(el perfil no arranca ningún servicio)")
182        for pr in problemas:
183            print(pr, file=sys.stderr)
184        # Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
185        return 1 if any(x.startswith("ERROR") for x in problemas) else 0
186    if args[0] == "--json":
187        print(json.dumps(perfiles, indent=1, ensure_ascii=False)); return 0
188
189    n = args[0]
190    if n not in perfiles:
191        print(f"perfil desconocido: {n} (hay: {', '.join(sorted(perfiles))})", file=sys.stderr)
192        return 1
193    print(" ".join(perfiles[n]["raices"]))
194    return 0
195
196
197if __name__ == "__main__":
198    sys.exit(main())