tawasuyu

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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 282a4ea0e22a7618960605bc02fe3f57a3f0be75 scripts/vigia-sonames.py Python · 192 lines · 11.3 KB History Who touched what raw

exiv2 + un falso verde del vigía de sonames 2e4cfcaa6 · Sergio ·

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
53
54# ⚠ **UN VIGÍA QUE ESCANEA CERO ARTEFACTOS DECÍA «0 SIN PROVEEDOR» — o sea, limpio.**
55# Medido el 2026-10-20: invocado como `STORE=/store …` (la variable que usa `build-state.py`) este
56# script caía a su default `<repo>/store`, que **está VACÍO en un clon** —el store de verdad está en
57# `/store`—, no encontraba un solo artefacto, y reportaba los DIEZ perfiles con «0 sonames provistos
58# · 0 sin proveedor». Un guardián que no puede fallar es peor que no tenerlo: tapa justo lo que
59# vigila. Con la variable correcta el mismo comando da 2192 sonames en KDE y 0 sin proveedor.
60#
61# Dos cosas, entonces:
62#   1. **Se aceptan los tres nombres.** El repo está partido tras el renombre: 21 scripts usan
63#      `HAMMER_STORE` y `build-state.py` usa `STORE`. Unificarlos es su propia unidad de trabajo;
64#      mientras eso no pase, que el vigía entienda los dos (más `TAKANA_STORE`) evita el falso verde.
65#   2. **Se ABORTA si el store no tiene artefactos.** Cero artefactos nunca es un resultado
66#      legítimo para este vigía: es una invocación mal hecha, y debe verse como error y no como
67#      éxito.
68STORE = (os.environ.get("HAMMER_STORE") or os.environ.get("TAKANA_STORE")
69         or os.environ.get("STORE") or str(ROOT / "store"))
70_n_art = len(glob.glob(os.path.join(STORE, "*-*")))
71if _n_art == 0:
72    sys.exit("vigia-sonames: el store '%s' no tiene ningún artefacto.\n"
73             "  Con un store vacío este vigía diría «0 sin proveedor» en TODOS los perfiles, que se\n"
74             "  lee como limpio y no lo es. Indicá el store de verdad:\n"
75             "      HAMMER_STORE=/store python3 scripts/vigia-sonames.py --imagen" % STORE)
76HAMMER = str(ROOT / "target/release/takana")
77
78_cache = {}
79def artefacto(cola, nombre):
80    """Directorio del artefacto de (cola,nombre) en el store, o None."""
81    if (cola, nombre) in _cache:
82        return _cache[(cola, nombre)]
83    receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
84                     else "recipes/%s/%s.toml" % (cola, nombre))
85    d = None
86    if receta.exists():
87        r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
88                           capture_output=True, text=True)
89        h = r.stdout.strip().replace("b3:", "")
90        if len(h) == 64:
91            cand = glob.glob(os.path.join(STORE, h + "-*"))
92            d = cand[0] if cand else None
93    _cache[(cola, nombre)] = d
94    return d
95
96def elf(path):
97    """(NEEDED[], SONAME[]) de un ELF, o None si no lo es."""
98    try:
99        out = subprocess.run(["readelf", "-d", path], capture_output=True,
100                             text=True, timeout=30).stdout
101    except Exception:
102        return None
103    if "(NEEDED)" not in out and "(SONAME)" not in out:
104        return None
105    tag = lambda t: [l.split("[")[1].split("]")[0] for l in out.splitlines() if t in l]
106    return tag("(NEEDED)"), tag("(SONAME)")
107
108def escanear(nodos):
109    provee, pide = set(), {}
110    for (cola, nombre) in sorted(nodos):
111        d = artefacto(cola, nombre)
112        if not d:
113            continue
114        for raiz, _, ficheros in os.walk(d):
115            for fn in ficheros:
116                p = os.path.join(raiz, fn)
117                # ⚠ `testdata/` NO ES CIERRE (2026-09-06). Go shipea su árbol de fuentes entero, y
118                # `usr/lib/go/src/debug/elf/testdata/` trae ELF de MUESTRA para los tests de su
119                # propio paquete `debug/elf`. Uno de ellos pide `libc.so.6` —glibc— y el vigía lo
120                # contaba como dep de ejecución del perfil sway: un binario que nadie ejecuta,
121                # pidiendo una libc que esta distro no usa. Era el último «hueco» del informe y era
122                # falso. Mismo criterio que el `libperl.so` de abajo: el ruido de un vigía es lo que
123                # enseña a ignorarlo.
124                if "/testdata/" in p:
125                    continue
126                if os.path.islink(p):
127                    # un symlink `libfoo.so.1 -> libfoo.so.1.2.3` TAMBIÉN provee ese nombre
128                    if ".so" in fn:
129                        provee.add(fn)
130                    continue
131                if ".so" not in fn and not raiz.endswith(("/bin", "/sbin", "/libexec")):
132                    continue
133                r = elf(p)
134                if not r:
135                    continue
136                need, son = r
137                provee.update(son)
138                if son:
139                    provee.add(fn)
140                elif ".so" in fn:
141                    # ⚠ ELF COMPARTIDO **SIN SONAME**: provee por NOMBRE DE FICHERO (2026-09-06).
142                    # Sin esta rama el vigía daba un hueco FALSO en los cinco perfiles:
143                    #
144                    #     FALTA libperl.so   ← lo piden: perl
145                    #
146                    # y `libperl.so` estaba ahí, 9,6 MB, en
147                    # `usr/lib/perl5/5.40.2/x86_64-linux/CORE/`. Perl la construye con
148                    # `-Duseshrplib` y NO le pone SONAME: el binario la encuentra por su
149                    # `RUNPATH`, que apunta a ese CORE. O sea que el enlace resuelve por NOMBRE
150                    # DE FICHERO, y eso es exactamente lo que hay que indexar.
151                    #
152                    # Importa arreglarlo aunque «sólo» sea un falso positivo: un vigía que grita
153                    # en falso enseña a ignorarlo, y así es como `libstdc++.so.6` —que sí rompía
154                    # el navegador— estuvo en esta misma salida sin que nadie lo mirara.
155                    provee.add(fn)
156                for n in need:
157                    pide.setdefault(n, set()).add(nombre)
158    return provee, pide
159
160def main():
161    args = [a for a in sys.argv[1:] if not a.startswith("--")]
162    fallar = "--fail" in sys.argv
163    colas = yupana.cargar_colas()
164    perfiles = targets_mod.load()
165    solo_imagen = "--imagen" in sys.argv
166    memb = (yupana.membresia_imagen(colas, perfiles) if solo_imagen
167            else yupana.membresia(colas, perfiles))
168    # ⚠ **POR DEFECTO, TODOS LOS PERFILES.** Antes era `p.startswith("escritorio")`, y ese filtro
169    # dejaba fuera a `base`, `cli` y `servidor` — o sea al objetivo de la mudanza. El coste se midió
170    # el 2026-09-13 apuntándolo a mano: los tres tenían `FALTA libffi.so.8 ← lo piden: python3`, con
171    # `python3` INERTE en las tres imágenes por el mismo mecanismo que `musl-shared` y `zlib-shared`
172    # habían arreglado dos días antes. El vigía existía, el hueco estaba, y nadie lo miraba.
173    #   Es la tercera vez en dos días que un guardián de este repo mide menos de lo que su resumen
174    # afirma (antes: `static-audit.sh` sin las colas, y el bloque de raíces sucias sólo sobre
175    # perfiles). Un «0 huecos» sobre la mitad del sistema se imprime igual que uno sobre todo él.
176    quiero = args or sorted(perfiles)
177    huecos = 0
178    for perfil in quiero:
179        nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
180        provee, pide = escanear(nodos)
181        # `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre.
182        faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"}
183        print("== %-20s %3d nodos (%s) · %4d sonames provistos · %2d sin proveedor"
184              % (perfil, len(nodos), "imagen" if solo_imagen else "build",
185                 len(provee), len(faltan)))
186        for s, quienes in sorted(faltan.items()):
187            print("   FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes))))
188        huecos += len(faltan)
189    return 1 if (fallar and huecos) else 0
190
191if __name__ == "__main__":
192    sys.exit(main())