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…
SDD 30 4c: GNOME con los demonios supervisados por arje — y el control arregló colord
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())