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 1182Shell 87Rust 63Markdown 541592 Definiciones

⎇ 551a103e2dd119ec47b28d831074a214c8a080d2 scripts/targets.py Python · 442 líneas · 21.3 KB Historia Quién tocó qué crudo

paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds 0c795025b · 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#         ⚠ 2026-09-22: la salida pasó de ser `{perfil: {...}}` a `{"perfil": {...}, "solo-build":
11#         [...]}`. Se comprobó que NADIE la consumía (grep en todo el árbol) antes de cambiarla.
12#       scripts/targets.py --tools           # las herramientas que NO viajan en ninguna imagen
13#       scripts/targets.py --services cli    # los servicios que ESA imagen arranca, resueltos
14#       scripts/targets.py --selftest        # roturas a propósito + un control que TIENE que pasar
15#       scripts/targets.py --service-paths gnome  # rutas de receta de los servicios de SISTEMA
16#
17# ⚠ La FLAG va en inglés (regla 4 del CLAUDE.md: la superficie de CLI es contrato) aunque `--lista`
18# sea deuda vieja del mismo fichero. La CLAVE del TOML sí es `servicios`, en castellano, porque ahí
19# la convención local es esa (`paquetes`, `hereda`, `cola`, `descripcion`) y mezclar idiomas DENTRO
20# del manifiesto sería peor que la coherencia con el resto de la superficie.
21# Como módulo:  from targets import load  →  {perfil: {descripcion, cola, hereda, paquetes, raices}}
22#
23# `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
24# declarado, después las propias), porque hay imágenes donde el orden de hidratación importa
25# (mirada: libs antes que binarios). Duplicados: se queda la primera aparición.
26import json, sys, tomllib
27from pathlib import Path
28
29ROOT = Path(__file__).resolve().parent.parent
30DEFAULT = ROOT / "docs/state/targets.toml"
31
32
33def load(path=DEFAULT):
34    """Devuelve {perfil: {descripcion, cola, hereda, paquetes, raices}} con `raices` ya expandido."""
35    doc = tomllib.load(open(path, "rb"))
36    crudos = doc.get("perfil", {})
37    perfiles = {}
38    for n, p in crudos.items():
39        perfiles[n] = dict(
40            descripcion=p.get("descripcion", ""),
41            cola=p.get("cola", "corpus"),
42            hereda=list(p.get("hereda", [])),
43            paquetes=list(p.get("paquetes", [])),
44            # `servicios` = los LABELS que esta imagen arranca. Es el «enable» de la distro: el
45            # paquete declara QUÉ es el servicio (`[[service]]` en su receta) y el perfil decide SI
46            # arranca, igual que systemd parte [Service] de [Install]. Ver SDD 30 §3.
47            servicios=list(p.get("servicios", [])),
48        )
49
50    # Expansión de `hereda` con detección de ciclo (un manifiesto con un ciclo es un error del
51    # humano, no un caso a tolerar: sale ruidoso).
52    def expandir(n, camino):
53        if n in camino:
54            raise ValueError(f"ciclo en `hereda`: {' → '.join(camino + [n])}")
55        if n not in perfiles:
56            raise KeyError(f"perfil desconocido en `hereda`: {n}")
57        p = perfiles[n]
58        if "raices" in p:
59            return p["raices"]
60        vistos, raices = set(), []
61        vistos_s, svcs = set(), []
62        for h in p["hereda"] + [None]:
63            hp = perfiles[h] if h else None
64            for pkg in (expandir(h, camino + [n]) if h else p["paquetes"]):
65                if pkg not in vistos:
66                    vistos.add(pkg); raices.append(pkg)
67            # los servicios heredados viajan con el perfil padre (ya expandido por la llamada de
68            # arriba): una imagen que hereda `cli` arranca también lo que `cli` arranca.
69            for s in (hp["servicios_exp"] if hp else p["servicios"]):
70                if s not in vistos_s:
71                    vistos_s.add(s); svcs.append(s)
72        p["raices"] = raices
73        p["servicios_exp"] = svcs
74        return raices
75
76    for n in perfiles:
77        expandir(n, [])
78    return perfiles
79
80
81def solo_build(path=DEFAULT):
82    """Los paquetes declarados `[solo-build]`: herramientas de construcción que NO viajan en ninguna
83    imagen (el porqué largo, y los dos vigías que validan la lista, están en el propio `targets.toml`).
84
85    Se lee de acá y NO de la receta a propósito: en la receta el campo entraría en `hash_inputs` y
86    marcar 44 herramientas costaría 44 reconstrucciones más las de sus consumidores. «No viaja en una
87    imagen» es una propiedad de la imagen.
88
89    ⚠ No valida que los nombres existan: una herramienta que no está en el corpus es una entrada
90    INERTE, no un error — y la lista tiene que poder nombrar lo que todavía no se importó. Quien
91    quiera esa comprobación es `build-state.py`, que ya conoce las colas."""
92    doc = tomllib.load(open(path, "rb"))
93    return list(doc.get("solo-build", {}).get("paquetes", []))
94
95
96# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
97#
98# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
99# (`servicios = [...]`). Este resolutor cruza las dos mitades y, sobre todo, hace la comprobación
100# INVERSA — la que ninguna métrica hacía: un paquete del perfil que TRAE un demonio y que el perfil
101# no arranca. Esa es la versión servicios de la lección de `foot` que este manifiesto ya aprendió
102# caro: la métrica mide la clausura de lo DECLARADO y no puede ver lo que falta en la declaración.
103# Una imagen puede salir 121/121 sellada, con el demonio adentro, y sin que nada lo levante nunca.
104
105def declaraciones(recipes_dir=None):
106    """Escanea el corpus y devuelve {label: [(receta, servicio_dict), ...]}.
107
108    Devuelve la LISTA por label (no el primero) a propósito: dos recetas que declaren el mismo
109    label es un error que hay que ver, y quedarse con una lo escondería."""
110    recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
111    por_label = {}
112    for f in sorted(recipes_dir.rglob("*.toml")):
113        try:
114            doc = tomllib.load(open(f, "rb"))
115        except tomllib.TOMLDecodeError:
116            continue  # recetas rotas las reporta el grafo, no este resolutor
117        for s in doc.get("service", []):
118            lbl = s.get("label")
119            if lbl:
120                por_label.setdefault(lbl, []).append((doc.get("name", f.stem), s))
121    return por_label
122
123
124# Los grafos por COLA. `build-state.json` es sólo el corpus: las recetas de un escritorio viven en
125# `recipes/incoming-<x>/` y `build-state.py` sólo las carga con su flag, así que su membresía de
126# perfil está en el grafo de SU cola. Mirar sólo el del corpus daría "no puedo comprobar" para todo
127# GNOME/KDE/COSMIC — que es peor que no comprobar: parece una respuesta.
128GRAFOS = ["build-state.json", "build-state-gnome.json", "build-state-kde.json",
129          "build-state-cosmic.json", "build-state-wlr.json"]
130
131
132def _ruta_de(nombre, recipes_dir=None):
133    """La ruta de la receta que se llama `nombre`. Se busca por el campo `name`, no por el nombre de
134    fichero: son casi siempre iguales, pero el que manda es el campo."""
135    recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
136    for f in sorted(recipes_dir.rglob("*.toml")):
137        try:
138            if tomllib.load(open(f, "rb")).get("name") == nombre:
139                return str(f.relative_to(ROOT))
140        except tomllib.TOMLDecodeError:
141            continue
142    return None
143
144
145def _membresia():
146    """{receta: [perfiles]} fusionando TODOS los grafos de estado. `{}` si no hay ninguno (no es
147    fatal: degrada a no poder comprobar la pertenencia, y se dice)."""
148    memb = {}
149    for nombre in GRAFOS:
150        try:
151            d = json.load(open(ROOT / "docs/state" / nombre))
152        except (FileNotFoundError, json.JSONDecodeError):
153            continue
154        for k, v in d.get("nodes", {}).items():
155            # unión: una receta del corpus puede aparecer en varios grafos con perfiles distintos
156            ya = memb.setdefault(k, [])
157            for perf in v.get("perfiles", []):
158                if perf not in ya:
159                    ya.append(perf)
160    return memb
161
162
163def resolver_servicios(perfil, perfiles=None):
164    """Devuelve (habilitados, problemas) para un perfil.
165
166    habilitados: [(label, receta, exec)] en el orden declarado.
167    problemas:   lista de strings. Distingue ERROR (rompe la imagen) de AVISO (huele mal)."""
168    perfiles = perfiles or load()
169    p = perfiles[perfil]
170    decl = declaraciones()
171    memb = _membresia()
172    raices = set(p["raices"])
173
174    def en_el_perfil(receta):
175        """`True`/`False`/`None` (= no se puede saber).
176
177        Las RAÍCES mandan sobre el grafo, y el orden importa: `targets.toml` es la fuente de verdad
178        de lo que la imagen quiere y `build-state.json` es DERIVADO de él —lo regenera el cron cada
179        30 min—. Preguntarle primero al derivado haría que añadir una raíz se leyera como error
180        hasta la siguiente cosecha, que es castigar al que arregla el manifiesto."""
181        if receta in raices:
182            return True
183        perfs = memb.get(receta)
184        if perfs is None:
185            return None
186        return perfil in perfs
187
188    habilitados, problemas = [], []
189    for lbl in p["servicios_exp"]:
190        cands = decl.get(lbl, [])
191        if not cands:
192            problemas.append(f"ERROR  {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
193            continue
194        # La unicidad del label se juzga DENTRO del perfil, no en el corpus entero: el mismo
195        # servicio existe legítimamente en dos colas (p.ej. `upower` en incoming-gnome y en
196        # incoming-kde) y son la misma unidad en dos imágenes distintas. Exigir unicidad global
197        # convertiría esa convivencia normal en un error.
198        dentro = [(r, s) for r, s in cands if en_el_perfil(r) is True]
199        incierto = [(r, s) for r, s in cands if en_el_perfil(r) is None]
200        if not dentro and incierto:
201            quienes = ", ".join(r for r, _ in incierto)
202            problemas.append(f"AVISO  {lbl}: no puedo comprobar que `{quienes}` esté en el perfil "
203                             f"(sin build-state.json y no es raíz)")
204            receta, svc = incierto[0]
205        elif not dentro:
206            quienes = ", ".join(r for r, _ in cands)
207            problemas.append(
208                f"ERROR  {lbl}: lo declara(n) {quienes}, que NO pertenece(n) al perfil ⇒ el card "
209                f"apuntaría a un binario que la imagen no trae")
210            continue
211        elif len(dentro) > 1:
212            quienes = ", ".join(r for r, _ in dentro)
213            problemas.append(f"ERROR  {lbl}: lo declaran {len(dentro)} recetas DEL PERFIL ({quienes})")
214            continue
215        else:
216            receta, svc = dentro[0]
217        if svc.get("scope", "system") != "system":
218            problemas.append(
219                f"AVISO  {lbl}: es de SESIÓN (`{receta}`) — hoy lo lanza el script de sesión, no "
220                f"arje: fuera de mirada nadie entrega cards de sesión (SDD 30 §4c)")
221        habilitados.append((lbl, receta, svc.get("exec", "?")))
222
223    # La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
224    hab = {l for l, _, _ in habilitados}
225    for lbl, cands in sorted(decl.items()):
226        if lbl in hab:
227            continue
228        for receta, _ in cands:
229            if en_el_perfil(receta) is True:
230                problemas.append(
231                    f"AVISO  {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
232                    f"pero el perfil no lo arranca")
233    return habilitados, problemas
234
235
236def autotest():
237    """Roturas A PROPÓSITO + un control que TIENE que pasar.
238
239    Un guardián que nunca falló no se sabe si sirve, y uno que sólo se prueba con casos rotos puede
240    estar gritando siempre. Por eso el primer caso es el BUENO: si ese no pasa en verde, el resto de
241    los ✓ no prueban nada."""
242    global declaraciones, _membresia
243    orig_d, orig_m = declaraciones, _membresia
244    SVC = {"label": "sshd", "exec": "/bin/busybox"}
245    SES = {"label": "pipewire", "exec": "/usr/bin/pipewire", "scope": "session"}
246
247    def perfil(svcs, raices=("openssh",)):
248        return dict(descripcion="", cola="corpus", hereda=[], paquetes=list(raices),
249                    servicios=list(svcs), raices=list(raices), servicios_exp=list(svcs))
250
251    casos = [
252        # (nombre, perfil, declaraciones, membresía, ¿espera ERROR?)
253        ("CONTROL: declarado, habilitado y la receta en el perfil",
254         perfil(["sshd"]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
255        ("habilitado y NADIE lo declara",
256         perfil(["sshd"]), {}, {}, True),
257        ("dos recetas DEL PERFIL declaran el mismo label",
258         perfil(["sshd"], ("openssh", "dropbear")),
259         {"sshd": [("openssh", SVC), ("dropbear", SVC)]}, {}, True),
260        ("lo declara una receta que NO está en el perfil",
261         perfil(["sshd"], ()), {"sshd": [("openssh", SVC)]}, {"openssh": ["otro"]}, True),
262        ("mismo label en DOS COLAS, sólo una en el perfil ⇒ NO es colisión",
263         perfil(["sshd"]), {"sshd": [("openssh", SVC), ("openssh-kde", SVC)]},
264         {"openssh": ["prueba"], "openssh-kde": ["otro"]}, False),
265        ("servicio de SESIÓN ⇒ avisa pero no rompe",
266         perfil(["pipewire"], ("pipewire",)), {"pipewire": [("pipewire", SES)]}, {}, False),
267        ("la INVERSA: en la imagen, trae demonio, nadie lo arranca ⇒ AVISO",
268         perfil([]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
269    ]
270    todo_ok = True
271    try:
272        for nombre, p_, decl, memb, espera in casos:
273            declaraciones = lambda *a, **k: decl
274            _membresia = lambda: memb
275            _, prob = resolver_servicios("prueba", {"prueba": p_})
276            hay = any(x.startswith("ERROR") for x in prob)
277            ok = hay == espera
278            todo_ok &= ok
279            print(f"{'✓' if ok else '✗'} {nombre}")
280            for x in prob:
281                print(f"      {x}")
282    finally:
283        declaraciones, _membresia = orig_d, orig_m
284    print("TODAS OK" if todo_ok else "HAY FALLOS")
285    return todo_ok
286
287
288def autotest_imagen():
289    """Roturas a propósito sobre la PODA de `[solo-build]` (la vista de imagen de `yupana`).
290
291    Va acá y no en yupana porque acá está el arnés —y porque la poda es una propiedad del
292    MANIFIESTO, no del grafo—. `import` perezoso: yupana importa este módulo arriba del todo.
293
294    El primer caso es el CONTROL, por la misma razón de siempre: un guardián que sólo se prueba con
295    casos rotos puede estar gritando siempre."""
296    import yupana
297
298    # grafo de juguete:  app → lib → libhonda ;  app → cmake → libsolodelbuild
299    colas = {"corpus": {"app": ["lib", "cmake"], "lib": ["libhonda"], "libhonda": [],
300                        "cmake": ["libsolodelbuild"], "libsolodelbuild": [], "go": []}}
301    def perfil(raices):
302        return {"p": dict(cola="corpus", raices=list(raices), paquetes=list(raices),
303                          hereda=[], descripcion="", servicios=[], servicios_exp=[])}
304    def cierre(raices, podar):
305        m = yupana.membresia(colas, perfil(raices), podar=podar)
306        return {n for (q, n), ps in m.items() if "p" in ps}
307
308    casos = [
309        ("CONTROL: sin poda entra todo, como siempre",
310         cierre(["app"], None), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
311        ("podada una herramienta: no entra ELLA ni lo que sólo ella arrastra",
312         cierre(["app"], {"cmake"}), {"app", "lib", "libhonda"}),
313        ("y lo que llega por OTRO camino se queda (la poda es por nodo, no por arista)",
314         cierre(["app"], {"libhonda"}), {"app", "lib", "cmake", "libsolodelbuild"}),
315        ("LA VÁLVULA: declarada como raíz, la herramienta viaja igual",
316         cierre(["app", "cmake"], {"cmake"}), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
317        ("podar algo que nadie alcanza no cambia nada",
318         cierre(["app"], {"go"}), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
319    ]
320    todo_ok = True
321    for nombre, obtenido, esperado in casos:
322        ok = obtenido == esperado
323        todo_ok &= ok
324        print(f"{'✓' if ok else '✗'} {nombre}")
325        if not ok:
326            print(f"      esperaba {sorted(esperado)}\n      obtuvo   {sorted(obtenido)}")
327    # Y el caso que el juguete no puede probar: que la lista REAL no nombre algo que además es raíz
328    # de TODOS los perfiles (sería una entrada que no poda nada y engaña al que la lee).
329    perfiles = load()
330    inutiles = [n for n in solo_build()
331                if perfiles and all(n in p["raices"] for p in perfiles.values())]
332    ok = not inutiles
333    todo_ok &= ok
334    print(f"{'✓' if ok else '✗'} ninguna entrada de `[solo-build]` es raíz de TODOS los perfiles"
335          + ("" if ok else f" — inerte: {', '.join(inutiles)}"))
336    return todo_ok
337
338
339def main():
340    args = sys.argv[1:]
341    try:
342        perfiles = load()
343    except FileNotFoundError:
344        print(f"no existe {DEFAULT.relative_to(ROOT)}", file=sys.stderr); return 1
345    except (ValueError, KeyError) as e:
346        print(f"manifiesto inválido: {e}", file=sys.stderr); return 1
347
348    if not args or args[0] in ("--lista", "-l"):
349        for n, p in sorted(perfiles.items()):
350            print(f"{n:20} {len(p['raices']):3} raíces  [{p['cola']}]  {p['descripcion']}")
351        return 0
352    if args[0] == "--selftest":
353        ok = autotest()
354        print()
355        ok &= autotest_imagen()
356        return 0 if ok else 1
357    if args[0] == "--tools":
358        # La superficie va en INGLÉS (regla 4): esto lo consumen los scripts de imagen.
359        for n in solo_build():
360            print(n)
361        return 0
362    if args[0] == "--service-paths":
363        # Las RUTAS de receta de los servicios de SISTEMA habilitados, para que el ensamblado de la
364        # imagen se las pase a `takana service cards`. Sólo `system`: una Card de sesión en el
365        # `genesis` arrancaría antes de que exista XDG_RUNTIME_DIR o un usuario (SDD 30 §4c).
366        if len(args) < 2 or args[1] not in perfiles:
367            print(f"uso: targets.py --service-paths <perfil>", file=sys.stderr)
368            return 1
369        decl = declaraciones()
370        memb = _membresia()
371        raices = set(perfiles[args[1]]["raices"])
372        vistas = []
373        for lbl in perfiles[args[1]]["servicios_exp"]:
374            for receta, svc in decl.get(lbl, []):
375                if svc.get("scope", "system") != "system":
376                    continue
377                if not (receta in raices or args[1] in memb.get(receta, [])):
378                    continue
379                ruta = _ruta_de(receta)
380                if ruta and ruta not in vistas:
381                    vistas.append(ruta)
382        for r in vistas:
383            print(r)
384        return 0
385    if args[0] == "--user-paths":
386        # Las RUTAS de receta de los paquetes DEL PERFIL que declaran `[[user]]`, para que el
387        # ensamblado se las pase a `takana users --merge <rootfs>`. La pregunta NO es la misma que
388        # la de `--service-paths`: una cuenta hace falta porque el paquete está INSTALADO, no porque
389        # el perfil arranque su servicio. `postgres` instalado y sin levantar necesita su usuario
390        # igual — sus ficheros ya son suyos.
391        if len(args) < 2 or args[1] not in perfiles:
392            print("uso: targets.py --user-paths <perfil>", file=sys.stderr)
393            return 1
394        memb = _membresia()
395        raices = set(perfiles[args[1]]["raices"])
396        vistas = []
397        for f in sorted((ROOT / "recipes").rglob("*.toml")):
398            try:
399                doc = tomllib.load(open(f, "rb"))
400            except tomllib.TOMLDecodeError:
401                continue
402            if not doc.get("user"):
403                continue
404            receta = doc.get("name", f.stem)
405            if not (receta in raices or args[1] in memb.get(receta, [])):
406                continue
407            r = str(f.relative_to(ROOT))
408            if r not in vistas:
409                vistas.append(r)
410        for r in vistas:
411            print(r)
412        return 0
413    if args[0] in ("--services", "-s"):
414        if len(args) < 2 or args[1] not in perfiles:
415            print(f"uso: targets.py --services <perfil>  (hay: {', '.join(sorted(perfiles))})",
416                  file=sys.stderr)
417            return 1
418        habilitados, problemas = resolver_servicios(args[1], perfiles)
419        for lbl, receta, ex in habilitados:
420            print(f"{lbl:24} {receta:20} {ex}")
421        if not habilitados:
422            print("(el perfil no arranca ningún servicio)")
423        for pr in problemas:
424            print(pr, file=sys.stderr)
425        # Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
426        return 1 if any(x.startswith("ERROR") for x in problemas) else 0
427    if args[0] == "--json":
428        # `solo-build` va al lado de los perfiles y no dentro de cada uno: es UNA lista global, y
429        # repetirla por perfil invitaría a que divergieran.
430        print(json.dumps({"perfil": perfiles, "solo-build": solo_build()},
431                         indent=1, ensure_ascii=False)); return 0
432
433    n = args[0]
434    if n not in perfiles:
435        print(f"perfil desconocido: {n} (hay: {', '.join(sorted(perfiles))})", file=sys.stderr)
436        return 1
437    print(" ".join(perfiles[n]["raices"]))
438    return 0
439
440
441if __name__ == "__main__":
442    sys.exit(main())