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 188Rust 55Shell 30Markdown 281211 Definiciones

⎇ 3d79cdf98b9085ee55e2dad8e91b96761772a680 scripts/targets.py Python · 334 líneas · 15.7 KB Historia Quién tocó qué crudo

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