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 1570Shell 203Rust 109Markdown 993728 Definiciones

⎇ 0cd3e62822cc6e6748517894fd65a9a738d0fa35 scripts/targets.py Python · 297 líneas · 14.0 KB Historia Quién tocó qué crudo

SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil 1b56b1640 · 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 --services cli    # los servicios que ESA imagen arranca, resueltos
11#       scripts/targets.py --selftest        # roturas a propósito + un control que TIENE que pasar
12#
13# ⚠ La FLAG va en inglés (regla 4 del CLAUDE.md: la superficie de CLI es contrato) aunque `--lista`
14# sea deuda vieja del mismo fichero. La CLAVE del TOML sí es `servicios`, en castellano, porque ahí
15# la convención local es esa (`paquetes`, `hereda`, `cola`, `descripcion`) y mezclar idiomas DENTRO
16# del manifiesto sería peor que la coherencia con el resto de la superficie.
17# Como módulo:  from targets import load  →  {perfil: {descripcion, cola, hereda, paquetes, raices}}
18#
19# `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
20# declarado, después las propias), porque hay imágenes donde el orden de hidratación importa
21# (mirada: libs antes que binarios). Duplicados: se queda la primera aparición.
22import json, sys, tomllib
23from pathlib import Path
24
25ROOT = Path(__file__).resolve().parent.parent
26DEFAULT = ROOT / "docs/state/targets.toml"
27
28
29def load(path=DEFAULT):
30    """Devuelve {perfil: {descripcion, cola, hereda, paquetes, raices}} con `raices` ya expandido."""
31    doc = tomllib.load(open(path, "rb"))
32    crudos = doc.get("perfil", {})
33    perfiles = {}
34    for n, p in crudos.items():
35        perfiles[n] = dict(
36            descripcion=p.get("descripcion", ""),
37            cola=p.get("cola", "corpus"),
38            hereda=list(p.get("hereda", [])),
39            paquetes=list(p.get("paquetes", [])),
40            # `servicios` = los LABELS que esta imagen arranca. Es el «enable» de la distro: el
41            # paquete declara QUÉ es el servicio (`[[service]]` en su receta) y el perfil decide SI
42            # arranca, igual que systemd parte [Service] de [Install]. Ver SDD 30 §3.
43            servicios=list(p.get("servicios", [])),
44        )
45
46    # Expansión de `hereda` con detección de ciclo (un manifiesto con un ciclo es un error del
47    # humano, no un caso a tolerar: sale ruidoso).
48    def expandir(n, camino):
49        if n in camino:
50            raise ValueError(f"ciclo en `hereda`: {' → '.join(camino + [n])}")
51        if n not in perfiles:
52            raise KeyError(f"perfil desconocido en `hereda`: {n}")
53        p = perfiles[n]
54        if "raices" in p:
55            return p["raices"]
56        vistos, raices = set(), []
57        vistos_s, svcs = set(), []
58        for h in p["hereda"] + [None]:
59            hp = perfiles[h] if h else None
60            for pkg in (expandir(h, camino + [n]) if h else p["paquetes"]):
61                if pkg not in vistos:
62                    vistos.add(pkg); raices.append(pkg)
63            # los servicios heredados viajan con el perfil padre (ya expandido por la llamada de
64            # arriba): una imagen que hereda `cli` arranca también lo que `cli` arranca.
65            for s in (hp["servicios_exp"] if hp else p["servicios"]):
66                if s not in vistos_s:
67                    vistos_s.add(s); svcs.append(s)
68        p["raices"] = raices
69        p["servicios_exp"] = svcs
70        return raices
71
72    for n in perfiles:
73        expandir(n, [])
74    return perfiles
75
76
77# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
78#
79# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
80# (`servicios = [...]`). Este resolutor cruza las dos mitades y, sobre todo, hace la comprobación
81# INVERSA — la que ninguna métrica hacía: un paquete del perfil que TRAE un demonio y que el perfil
82# no arranca. Esa es la versión servicios de la lección de `foot` que este manifiesto ya aprendió
83# caro: la métrica mide la clausura de lo DECLARADO y no puede ver lo que falta en la declaración.
84# Una imagen puede salir 121/121 sellada, con el demonio adentro, y sin que nada lo levante nunca.
85
86def declaraciones(recipes_dir=None):
87    """Escanea el corpus y devuelve {label: [(receta, servicio_dict), ...]}.
88
89    Devuelve la LISTA por label (no el primero) a propósito: dos recetas que declaren el mismo
90    label es un error que hay que ver, y quedarse con una lo escondería."""
91    recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
92    por_label = {}
93    for f in sorted(recipes_dir.rglob("*.toml")):
94        try:
95            doc = tomllib.load(open(f, "rb"))
96        except tomllib.TOMLDecodeError:
97            continue  # recetas rotas las reporta el grafo, no este resolutor
98        for s in doc.get("service", []):
99            lbl = s.get("label")
100            if lbl:
101                por_label.setdefault(lbl, []).append((doc.get("name", f.stem), s))
102    return por_label
103
104
105# Los grafos por COLA. `build-state.json` es sólo el corpus: las recetas de un escritorio viven en
106# `recipes/incoming-<x>/` y `build-state.py` sólo las carga con su flag, así que su membresía de
107# perfil está en el grafo de SU cola. Mirar sólo el del corpus daría "no puedo comprobar" para todo
108# GNOME/KDE/COSMIC — que es peor que no comprobar: parece una respuesta.
109GRAFOS = ["build-state.json", "build-state-gnome.json", "build-state-kde.json",
110          "build-state-cosmic.json", "build-state-wlr.json"]
111
112
113def _membresia():
114    """{receta: [perfiles]} fusionando TODOS los grafos de estado. `{}` si no hay ninguno (no es
115    fatal: degrada a no poder comprobar la pertenencia, y se dice)."""
116    memb = {}
117    for nombre in GRAFOS:
118        try:
119            d = json.load(open(ROOT / "docs/state" / nombre))
120        except (FileNotFoundError, json.JSONDecodeError):
121            continue
122        for k, v in d.get("nodes", {}).items():
123            # unión: una receta del corpus puede aparecer en varios grafos con perfiles distintos
124            ya = memb.setdefault(k, [])
125            for perf in v.get("perfiles", []):
126                if perf not in ya:
127                    ya.append(perf)
128    return memb
129
130
131def resolver_servicios(perfil, perfiles=None):
132    """Devuelve (habilitados, problemas) para un perfil.
133
134    habilitados: [(label, receta, exec)] en el orden declarado.
135    problemas:   lista de strings. Distingue ERROR (rompe la imagen) de AVISO (huele mal)."""
136    perfiles = perfiles or load()
137    p = perfiles[perfil]
138    decl = declaraciones()
139    memb = _membresia()
140    raices = set(p["raices"])
141
142    def en_el_perfil(receta):
143        """`True`/`False`/`None` (= no se puede saber).
144
145        Las RAÍCES mandan sobre el grafo, y el orden importa: `targets.toml` es la fuente de verdad
146        de lo que la imagen quiere y `build-state.json` es DERIVADO de él —lo regenera el cron cada
147        30 min—. Preguntarle primero al derivado haría que añadir una raíz se leyera como error
148        hasta la siguiente cosecha, que es castigar al que arregla el manifiesto."""
149        if receta in raices:
150            return True
151        perfs = memb.get(receta)
152        if perfs is None:
153            return None
154        return perfil in perfs
155
156    habilitados, problemas = [], []
157    for lbl in p["servicios_exp"]:
158        cands = decl.get(lbl, [])
159        if not cands:
160            problemas.append(f"ERROR  {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
161            continue
162        # La unicidad del label se juzga DENTRO del perfil, no en el corpus entero: el mismo
163        # servicio existe legítimamente en dos colas (p.ej. `upower` en incoming-gnome y en
164        # incoming-kde) y son la misma unidad en dos imágenes distintas. Exigir unicidad global
165        # convertiría esa convivencia normal en un error.
166        dentro = [(r, s) for r, s in cands if en_el_perfil(r) is True]
167        incierto = [(r, s) for r, s in cands if en_el_perfil(r) is None]
168        if not dentro and incierto:
169            quienes = ", ".join(r for r, _ in incierto)
170            problemas.append(f"AVISO  {lbl}: no puedo comprobar que `{quienes}` esté en el perfil "
171                             f"(sin build-state.json y no es raíz)")
172            receta, svc = incierto[0]
173        elif not dentro:
174            quienes = ", ".join(r for r, _ in cands)
175            problemas.append(
176                f"ERROR  {lbl}: lo declara(n) {quienes}, que NO pertenece(n) al perfil ⇒ el card "
177                f"apuntaría a un binario que la imagen no trae")
178            continue
179        elif len(dentro) > 1:
180            quienes = ", ".join(r for r, _ in dentro)
181            problemas.append(f"ERROR  {lbl}: lo declaran {len(dentro)} recetas DEL PERFIL ({quienes})")
182            continue
183        else:
184            receta, svc = dentro[0]
185        if svc.get("scope", "system") != "system":
186            problemas.append(
187                f"AVISO  {lbl}: es de SESIÓN (`{receta}`) — hoy lo lanza el script de sesión, no "
188                f"arje: fuera de mirada nadie entrega cards de sesión (SDD 30 §4c)")
189        habilitados.append((lbl, receta, svc.get("exec", "?")))
190
191    # La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
192    hab = {l for l, _, _ in habilitados}
193    for lbl, cands in sorted(decl.items()):
194        if lbl in hab:
195            continue
196        for receta, _ in cands:
197            if en_el_perfil(receta) is True:
198                problemas.append(
199                    f"AVISO  {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
200                    f"pero el perfil no lo arranca")
201    return habilitados, problemas
202
203
204def autotest():
205    """Roturas A PROPÓSITO + un control que TIENE que pasar.
206
207    Un guardián que nunca falló no se sabe si sirve, y uno que sólo se prueba con casos rotos puede
208    estar gritando siempre. Por eso el primer caso es el BUENO: si ese no pasa en verde, el resto de
209    los ✓ no prueban nada."""
210    global declaraciones, _membresia
211    orig_d, orig_m = declaraciones, _membresia
212    SVC = {"label": "sshd", "exec": "/bin/busybox"}
213    SES = {"label": "pipewire", "exec": "/usr/bin/pipewire", "scope": "session"}
214
215    def perfil(svcs, raices=("openssh",)):
216        return dict(descripcion="", cola="corpus", hereda=[], paquetes=list(raices),
217                    servicios=list(svcs), raices=list(raices), servicios_exp=list(svcs))
218
219    casos = [
220        # (nombre, perfil, declaraciones, membresía, ¿espera ERROR?)
221        ("CONTROL: declarado, habilitado y la receta en el perfil",
222         perfil(["sshd"]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
223        ("habilitado y NADIE lo declara",
224         perfil(["sshd"]), {}, {}, True),
225        ("dos recetas DEL PERFIL declaran el mismo label",
226         perfil(["sshd"], ("openssh", "dropbear")),
227         {"sshd": [("openssh", SVC), ("dropbear", SVC)]}, {}, True),
228        ("lo declara una receta que NO está en el perfil",
229         perfil(["sshd"], ()), {"sshd": [("openssh", SVC)]}, {"openssh": ["otro"]}, True),
230        ("mismo label en DOS COLAS, sólo una en el perfil ⇒ NO es colisión",
231         perfil(["sshd"]), {"sshd": [("openssh", SVC), ("openssh-kde", SVC)]},
232         {"openssh": ["prueba"], "openssh-kde": ["otro"]}, False),
233        ("servicio de SESIÓN ⇒ avisa pero no rompe",
234         perfil(["pipewire"], ("pipewire",)), {"pipewire": [("pipewire", SES)]}, {}, False),
235        ("la INVERSA: en la imagen, trae demonio, nadie lo arranca ⇒ AVISO",
236         perfil([]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
237    ]
238    todo_ok = True
239    try:
240        for nombre, p_, decl, memb, espera in casos:
241            declaraciones = lambda *a, **k: decl
242            _membresia = lambda: memb
243            _, prob = resolver_servicios("prueba", {"prueba": p_})
244            hay = any(x.startswith("ERROR") for x in prob)
245            ok = hay == espera
246            todo_ok &= ok
247            print(f"{'✓' if ok else '✗'} {nombre}")
248            for x in prob:
249                print(f"      {x}")
250    finally:
251        declaraciones, _membresia = orig_d, orig_m
252    print("TODAS OK" if todo_ok else "HAY FALLOS")
253    return todo_ok
254
255
256def main():
257    args = sys.argv[1:]
258    try:
259        perfiles = load()
260    except FileNotFoundError:
261        print(f"no existe {DEFAULT.relative_to(ROOT)}", file=sys.stderr); return 1
262    except (ValueError, KeyError) as e:
263        print(f"manifiesto inválido: {e}", file=sys.stderr); return 1
264
265    if not args or args[0] in ("--lista", "-l"):
266        for n, p in sorted(perfiles.items()):
267            print(f"{n:20} {len(p['raices']):3} raíces  [{p['cola']}]  {p['descripcion']}")
268        return 0
269    if args[0] == "--selftest":
270        return 0 if autotest() else 1
271    if args[0] in ("--services", "-s"):
272        if len(args) < 2 or args[1] not in perfiles:
273            print(f"uso: targets.py --services <perfil>  (hay: {', '.join(sorted(perfiles))})",
274                  file=sys.stderr)
275            return 1
276        habilitados, problemas = resolver_servicios(args[1], perfiles)
277        for lbl, receta, ex in habilitados:
278            print(f"{lbl:24} {receta:20} {ex}")
279        if not habilitados:
280            print("(el perfil no arranca ningún servicio)")
281        for pr in problemas:
282            print(pr, file=sys.stderr)
283        # Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
284        return 1 if any(x.startswith("ERROR") for x in problemas) else 0
285    if args[0] == "--json":
286        print(json.dumps(perfiles, indent=1, ensure_ascii=False)); return 0
287
288    n = args[0]
289    if n not in perfiles:
290        print(f"perfil desconocido: {n} (hay: {', '.join(sorted(perfiles))})", file=sys.stderr)
291        return 1
292    print(" ".join(perfiles[n]["raices"]))
293    return 0
294
295
296if __name__ == "__main__":
297    sys.exit(main())