`vigia-sonames.py` corría por defecto SÓLO sobre los cinco perfiles de escritorio
(`p.startswith("escritorio")`). O sea que `base`, `cli` y `servidor` —el objetivo de la mudanza—
**nunca se miraron**. Apuntándolo a ellos a mano:
== servidor 147 nodos · 183 sonames provistos · 1 sin proveedor
FALTA libffi.so.8 ← lo piden: python3
`python3` no arrancaba en las tres imágenes, por el mecanismo EXACTO que `musl-shared` y
`zlib-shared` arreglaron dos días antes: la receta canónica de libffi es `--disable-shared`, ningún
artefacto del cierre publica ese soname, y dentro del lab el agujero lo tapa el rootfs de Alpine —
así que el build pasa y la imagen sale rota. `libffi-shared` ya existía en el corpus y ya estaba
declarada en otros dos perfiles; sólo faltaba en `base`.
servidor 148 nodos · 186 sonames · **0 sin proveedor** (base y cli, igual)
## Y el vigía pasa a mirar TODOS los perfiles
⚠ **Es la tercera vez en dos días que un guardián de este repo mide menos de lo que su resumen
afirma**: `static-audit.sh` sólo globeaba `recipes/` y no las 5 colas (3 mentiras ocultas); el bloque
de raíces sucias sólo corría al hidratar un perfil y no veía las 649 recetas fuera de toda imagen
(qdrant con 69 M de basura); y éste dejaba fuera medio sistema. **Un «0 huecos» sobre la mitad del
sistema se imprime igual que uno sobre todo él.**
⇒ Al leer un guardián, leer su SCOPE antes que su veredicto: el glob, el bucle, de dónde saca la
lista. Los ocho perfiles dan 0 sin proveedor, así que ampliar el alcance no mete ruido — sólo deja de
esconder.
160 lines
8.8 KiB
Python
Executable File
160 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# vigia-sonames.py — ¿qué `.so` pide una imagen que NINGÚN artefacto de su cierre provee?
|
|
#
|
|
# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
|
|
# El grafo de estado responde «¿está sellado?». No responde «¿arranca?». Y hay una fuga que pasa
|
|
# entera por debajo del grafo:
|
|
#
|
|
# un binario `link = "dynamic"` sale con `NEEDED libffi.so.8`; la receta canónica de libffi es
|
|
# `--disable-shared`; ningún artefacto del cierre publica ese SONAME; y el rootfs hidratado lo
|
|
# resuelve contra el **sysroot Alpine DEL LAB**.
|
|
#
|
|
# El artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina que tenga
|
|
# Alpine debajo. Es PEOR que una dep faltante, porque el lab no entra en `hash_inputs`: el store no
|
|
# puede ni notarlo. Es el mismo patrón que [[artefacto-vacio-envenena-cache]] — algo que se lee como
|
|
# presencia y no lo es.
|
|
#
|
|
# Se descubrió DOS VECES a mano y nunca hubo instrumento: la primera al armar la imagen de sway
|
|
# (zlib/expat/libffi, ver el comentario del perfil `escritorio-sway` en targets.toml), y la segunda
|
|
# el 2026-09-03 al verificar que mpv anduviera — donde resultó que KDE, GNOME y COSMIC tenían la
|
|
# MISMA fuga desde siempre, con mesa y wayland pidiendo expat y libffi. Este script es el guardián
|
|
# que sale de ese punto ciego: la regla del repo es que cada uno se convierte en uno.
|
|
#
|
|
# ══ POR QUÉ NO SE PUEDE LEER DE build-state.json ═══════════════════════════════════════════════
|
|
# Porque el JSON colapsa los nodos POR NOMBRE y hay nombres que viven en dos colas: su campo
|
|
# `perfiles` puede quedar vacío para una receta que sí está en la imagen (por la otra cola). La
|
|
# membresía honesta se identifica por PAR `(cola, nombre)` y de eso sabe yupana, que resuelve
|
|
# sibling-first igual que el sandbox. Misma lección que el bug de libdrm: QUÉ NODOS PUNTÚO es una
|
|
# decisión de vista; QUIÉN ME CONSUME —y a qué imagen pertenezco— es un hecho.
|
|
#
|
|
# ══ CÓMO LEER LA SALIDA, que tiene ruido legítimo ══════════════════════════════════════════════
|
|
# El cierre de un perfil incluye sus HERRAMIENTAS DE BUILD (python3, cmake, perl, go…), y la
|
|
# hidratación de la imagen NO las instala: va por lista explícita. Sus NEEDED aparecen igual acá y
|
|
# **no son un bug de la imagen**. Lo accionable es lo que pide un componente de RUNTIME — mesa,
|
|
# wayland, el compositor, una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa
|
|
# columna el informe no se puede triar.
|
|
#
|
|
# uso: python3 scripts/vigia-sonames.py [perfil...] [--fail]
|
|
# --fail → exit 1 si algo falta (para cron/CI)
|
|
import os, sys, glob, subprocess
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
|
|
import yupana
|
|
import targets as targets_mod
|
|
|
|
ROOT = yupana.ROOT
|
|
STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
|
|
HAMMER = str(ROOT / "target/release/takana")
|
|
|
|
_cache = {}
|
|
def artefacto(cola, nombre):
|
|
"""Directorio del artefacto de (cola,nombre) en el store, o None."""
|
|
if (cola, nombre) in _cache:
|
|
return _cache[(cola, nombre)]
|
|
receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
|
|
else "recipes/%s/%s.toml" % (cola, nombre))
|
|
d = None
|
|
if receta.exists():
|
|
r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
|
|
capture_output=True, text=True)
|
|
h = r.stdout.strip().replace("b3:", "")
|
|
if len(h) == 64:
|
|
cand = glob.glob(os.path.join(STORE, h + "-*"))
|
|
d = cand[0] if cand else None
|
|
_cache[(cola, nombre)] = d
|
|
return d
|
|
|
|
def elf(path):
|
|
"""(NEEDED[], SONAME[]) de un ELF, o None si no lo es."""
|
|
try:
|
|
out = subprocess.run(["readelf", "-d", path], capture_output=True,
|
|
text=True, timeout=30).stdout
|
|
except Exception:
|
|
return None
|
|
if "(NEEDED)" not in out and "(SONAME)" not in out:
|
|
return None
|
|
tag = lambda t: [l.split("[")[1].split("]")[0] for l in out.splitlines() if t in l]
|
|
return tag("(NEEDED)"), tag("(SONAME)")
|
|
|
|
def escanear(nodos):
|
|
provee, pide = set(), {}
|
|
for (cola, nombre) in sorted(nodos):
|
|
d = artefacto(cola, nombre)
|
|
if not d:
|
|
continue
|
|
for raiz, _, ficheros in os.walk(d):
|
|
for fn in ficheros:
|
|
p = os.path.join(raiz, fn)
|
|
# ⚠ `testdata/` NO ES CIERRE (2026-09-06). Go shipea su árbol de fuentes entero, y
|
|
# `usr/lib/go/src/debug/elf/testdata/` trae ELF de MUESTRA para los tests de su
|
|
# propio paquete `debug/elf`. Uno de ellos pide `libc.so.6` —glibc— y el vigía lo
|
|
# contaba como dep de ejecución del perfil sway: un binario que nadie ejecuta,
|
|
# pidiendo una libc que esta distro no usa. Era el último «hueco» del informe y era
|
|
# falso. Mismo criterio que el `libperl.so` de abajo: el ruido de un vigía es lo que
|
|
# enseña a ignorarlo.
|
|
if "/testdata/" in p:
|
|
continue
|
|
if os.path.islink(p):
|
|
# un symlink `libfoo.so.1 -> libfoo.so.1.2.3` TAMBIÉN provee ese nombre
|
|
if ".so" in fn:
|
|
provee.add(fn)
|
|
continue
|
|
if ".so" not in fn and not raiz.endswith(("/bin", "/sbin", "/libexec")):
|
|
continue
|
|
r = elf(p)
|
|
if not r:
|
|
continue
|
|
need, son = r
|
|
provee.update(son)
|
|
if son:
|
|
provee.add(fn)
|
|
elif ".so" in fn:
|
|
# ⚠ ELF COMPARTIDO **SIN SONAME**: provee por NOMBRE DE FICHERO (2026-09-06).
|
|
# Sin esta rama el vigía daba un hueco FALSO en los cinco perfiles:
|
|
#
|
|
# FALTA libperl.so ← lo piden: perl
|
|
#
|
|
# y `libperl.so` estaba ahí, 9,6 MB, en
|
|
# `usr/lib/perl5/5.40.2/x86_64-linux/CORE/`. Perl la construye con
|
|
# `-Duseshrplib` y NO le pone SONAME: el binario la encuentra por su
|
|
# `RUNPATH`, que apunta a ese CORE. O sea que el enlace resuelve por NOMBRE
|
|
# DE FICHERO, y eso es exactamente lo que hay que indexar.
|
|
#
|
|
# Importa arreglarlo aunque «sólo» sea un falso positivo: un vigía que grita
|
|
# en falso enseña a ignorarlo, y así es como `libstdc++.so.6` —que sí rompía
|
|
# el navegador— estuvo en esta misma salida sin que nadie lo mirara.
|
|
provee.add(fn)
|
|
for n in need:
|
|
pide.setdefault(n, set()).add(nombre)
|
|
return provee, pide
|
|
|
|
def main():
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
fallar = "--fail" in sys.argv
|
|
colas = yupana.cargar_colas()
|
|
perfiles = targets_mod.load()
|
|
memb = yupana.membresia(colas, perfiles)
|
|
# ⚠ **POR DEFECTO, TODOS LOS PERFILES.** Antes era `p.startswith("escritorio")`, y ese filtro
|
|
# dejaba fuera a `base`, `cli` y `servidor` — o sea al objetivo de la mudanza. El coste se midió
|
|
# el 2026-09-13 apuntándolo a mano: los tres tenían `FALTA libffi.so.8 ← lo piden: python3`, con
|
|
# `python3` INERTE en las tres imágenes por el mismo mecanismo que `musl-shared` y `zlib-shared`
|
|
# habían arreglado dos días antes. El vigía existía, el hueco estaba, y nadie lo miraba.
|
|
# Es la tercera vez en dos días que un guardián de este repo mide menos de lo que su resumen
|
|
# afirma (antes: `static-audit.sh` sin las colas, y el bloque de raíces sucias sólo sobre
|
|
# perfiles). Un «0 huecos» sobre la mitad del sistema se imprime igual que uno sobre todo él.
|
|
quiero = args or sorted(perfiles)
|
|
huecos = 0
|
|
for perfil in quiero:
|
|
nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
|
|
provee, pide = escanear(nodos)
|
|
# `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre.
|
|
faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"}
|
|
print("== %-20s %3d nodos · %4d sonames provistos · %2d sin proveedor"
|
|
% (perfil, len(nodos), len(provee), len(faltan)))
|
|
for s, quienes in sorted(faltan.items()):
|
|
print(" FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes))))
|
|
huecos += len(faltan)
|
|
return 1 if (fallar and huecos) else 0
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|