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…
⎇ fa027e246e93b8f6b42f49fe85690071dad08f6a
scripts/targets.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds
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())