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…
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())