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…
⎇ 6adfadb0fb470278995731e93f8419fe31d3b3db
scripts/vigia-sonames.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# vigia-sonames.py — ¿qué `.so` pide una imagen que NINGÚN artefacto de su cierre provee?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# El grafo de estado responde «¿está sellado?». No responde «¿arranca?». Y hay una fuga que pasa
6# entera por debajo del grafo:
7#
8# un binario `link = "dynamic"` sale con `NEEDED libffi.so.8`; la receta canónica de libffi es
9# `--disable-shared`; ningún artefacto del cierre publica ese SONAME; y el rootfs hidratado lo
10# resuelve contra el **sysroot Alpine DEL LAB**.
11#
12# El artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina que tenga
13# Alpine debajo. Es PEOR que una dep faltante, porque el lab no entra en `hash_inputs`: el store no
14# puede ni notarlo. Es el mismo patrón que [[artefacto-vacio-envenena-cache]] — algo que se lee como
15# presencia y no lo es.
16#
17# Se descubrió DOS VECES a mano y nunca hubo instrumento: la primera al armar la imagen de sway
18# (zlib/expat/libffi, ver el comentario del perfil `escritorio-sway` en targets.toml), y la segunda
19# el 2026-09-03 al verificar que mpv anduviera — donde resultó que KDE, GNOME y COSMIC tenían la
20# MISMA fuga desde siempre, con mesa y wayland pidiendo expat y libffi. Este script es el guardián
21# que sale de ese punto ciego: la regla del repo es que cada uno se convierte en uno.
22#
23# ══ POR QUÉ NO SE PUEDE LEER DE build-state.json ═══════════════════════════════════════════════
24# Porque el JSON colapsa los nodos POR NOMBRE y hay nombres que viven en dos colas: su campo
25# `perfiles` puede quedar vacío para una receta que sí está en la imagen (por la otra cola). La
26# membresía honesta se identifica por PAR `(cola, nombre)` y de eso sabe yupana, que resuelve
27# sibling-first igual que el sandbox. Misma lección que el bug de libdrm: QUÉ NODOS PUNTÚO es una
28# decisión de vista; QUIÉN ME CONSUME —y a qué imagen pertenezco— es un hecho.
29#
30# ══ CÓMO LEER LA SALIDA, que tiene ruido legítimo ══════════════════════════════════════════════
31# El cierre de BUILD de un perfil incluye sus herramientas (python3, cmake, perl, go…) y sus NEEDED
32# aparecen acá. Lo accionable es lo que pide un componente de RUNTIME — mesa, wayland, el compositor,
33# una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa columna no se puede triar.
34#
35# ⚠ **Y este comentario decía algo FALSO hasta el 2026-09-22**: «la hidratación de la imagen NO las
36# instala: va por lista explícita». La lista explícita es la del OTRO linaje —`takana bootstrap
37# product`, con sus `STAGE1_COMPONENTS`/`USERLAND_COMPONENTS`—; `hydrate-profile.py` proyectaba el
38# cierre entero, herramientas incluidas. O sea que el ruido que este vigía enseñaba a ignorar era, en
39# la imagen del perfil, contenido real: 1.527 MiB de `llvm21` adentro de `servidor`. Ahora existe
40# `[solo-build]` en `targets.toml` y con `--imagen` se mide lo que de verdad se proyecta.
41#
42# uso: python3 scripts/vigia-sonames.py [perfil...] [--fail] [--imagen]
43# --fail → exit 1 si algo falta (para cron/CI)
44# --imagen → mide el cierre de IMAGEN (sin las herramientas de `[solo-build]`), que es lo
45# que `hydrate-profile.py` proyecta de verdad. Sin la flag mide el de build, que
46# es la vista histórica de este vigía.
47import os, sys, glob, subprocess
48sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
49import yupana
50import targets as targets_mod
51
52ROOT = yupana.ROOT
53STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
54HAMMER = str(ROOT / "target/release/takana")
55
56_cache = {}
57def artefacto(cola, nombre):
58 """Directorio del artefacto de (cola,nombre) en el store, o None."""
59 if (cola, nombre) in _cache:
60 return _cache[(cola, nombre)]
61 receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
62 else "recipes/%s/%s.toml" % (cola, nombre))
63 d = None
64 if receta.exists():
65 r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
66 capture_output=True, text=True)
67 h = r.stdout.strip().replace("b3:", "")
68 if len(h) == 64:
69 cand = glob.glob(os.path.join(STORE, h + "-*"))
70 d = cand[0] if cand else None
71 _cache[(cola, nombre)] = d
72 return d
73
74def elf(path):
75 """(NEEDED[], SONAME[]) de un ELF, o None si no lo es."""
76 try:
77 out = subprocess.run(["readelf", "-d", path], capture_output=True,
78 text=True, timeout=30).stdout
79 except Exception:
80 return None
81 if "(NEEDED)" not in out and "(SONAME)" not in out:
82 return None
83 tag = lambda t: [l.split("[")[1].split("]")[0] for l in out.splitlines() if t in l]
84 return tag("(NEEDED)"), tag("(SONAME)")
85
86def escanear(nodos):
87 provee, pide = set(), {}
88 for (cola, nombre) in sorted(nodos):
89 d = artefacto(cola, nombre)
90 if not d:
91 continue
92 for raiz, _, ficheros in os.walk(d):
93 for fn in ficheros:
94 p = os.path.join(raiz, fn)
95 # ⚠ `testdata/` NO ES CIERRE (2026-09-06). Go shipea su árbol de fuentes entero, y
96 # `usr/lib/go/src/debug/elf/testdata/` trae ELF de MUESTRA para los tests de su
97 # propio paquete `debug/elf`. Uno de ellos pide `libc.so.6` —glibc— y el vigía lo
98 # contaba como dep de ejecución del perfil sway: un binario que nadie ejecuta,
99 # pidiendo una libc que esta distro no usa. Era el último «hueco» del informe y era
100 # falso. Mismo criterio que el `libperl.so` de abajo: el ruido de un vigía es lo que
101 # enseña a ignorarlo.
102 if "/testdata/" in p:
103 continue
104 if os.path.islink(p):
105 # un symlink `libfoo.so.1 -> libfoo.so.1.2.3` TAMBIÉN provee ese nombre
106 if ".so" in fn:
107 provee.add(fn)
108 continue
109 if ".so" not in fn and not raiz.endswith(("/bin", "/sbin", "/libexec")):
110 continue
111 r = elf(p)
112 if not r:
113 continue
114 need, son = r
115 provee.update(son)
116 if son:
117 provee.add(fn)
118 elif ".so" in fn:
119 # ⚠ ELF COMPARTIDO **SIN SONAME**: provee por NOMBRE DE FICHERO (2026-09-06).
120 # Sin esta rama el vigía daba un hueco FALSO en los cinco perfiles:
121 #
122 # FALTA libperl.so ← lo piden: perl
123 #
124 # y `libperl.so` estaba ahí, 9,6 MB, en
125 # `usr/lib/perl5/5.40.2/x86_64-linux/CORE/`. Perl la construye con
126 # `-Duseshrplib` y NO le pone SONAME: el binario la encuentra por su
127 # `RUNPATH`, que apunta a ese CORE. O sea que el enlace resuelve por NOMBRE
128 # DE FICHERO, y eso es exactamente lo que hay que indexar.
129 #
130 # Importa arreglarlo aunque «sólo» sea un falso positivo: un vigía que grita
131 # en falso enseña a ignorarlo, y así es como `libstdc++.so.6` —que sí rompía
132 # el navegador— estuvo en esta misma salida sin que nadie lo mirara.
133 provee.add(fn)
134 for n in need:
135 pide.setdefault(n, set()).add(nombre)
136 return provee, pide
137
138def main():
139 args = [a for a in sys.argv[1:] if not a.startswith("--")]
140 fallar = "--fail" in sys.argv
141 colas = yupana.cargar_colas()
142 perfiles = targets_mod.load()
143 solo_imagen = "--imagen" in sys.argv
144 memb = (yupana.membresia_imagen(colas, perfiles) if solo_imagen
145 else yupana.membresia(colas, perfiles))
146 # ⚠ **POR DEFECTO, TODOS LOS PERFILES.** Antes era `p.startswith("escritorio")`, y ese filtro
147 # dejaba fuera a `base`, `cli` y `servidor` — o sea al objetivo de la mudanza. El coste se midió
148 # el 2026-09-13 apuntándolo a mano: los tres tenían `FALTA libffi.so.8 ← lo piden: python3`, con
149 # `python3` INERTE en las tres imágenes por el mismo mecanismo que `musl-shared` y `zlib-shared`
150 # habían arreglado dos días antes. El vigía existía, el hueco estaba, y nadie lo miraba.
151 # Es la tercera vez en dos días que un guardián de este repo mide menos de lo que su resumen
152 # afirma (antes: `static-audit.sh` sin las colas, y el bloque de raíces sucias sólo sobre
153 # perfiles). Un «0 huecos» sobre la mitad del sistema se imprime igual que uno sobre todo él.
154 quiero = args or sorted(perfiles)
155 huecos = 0
156 for perfil in quiero:
157 nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
158 provee, pide = escanear(nodos)
159 # `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre.
160 faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"}
161 print("== %-20s %3d nodos (%s) · %4d sonames provistos · %2d sin proveedor"
162 % (perfil, len(nodos), "imagen" if solo_imagen else "build",
163 len(provee), len(faltan)))
164 for s, quienes in sorted(faltan.items()):
165 print(" FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes))))
166 huecos += len(faltan)
167 return 1 if (fallar and huecos) else 0
168
169if __name__ == "__main__":
170 sys.exit(main())