Files
takana/scripts/vigia-sonames.py
T
Sergio 04e2c89014 vigia-sonames: el flag va en inglés (--fail), regla 4 del CLAUDE.md
Código nuevo nace con la superficie de CLI en inglés. Nació con `--fallar` unas horas antes de
que la regla quedara escrita; se corrige ahora que no lo llama nadie todavía.
2026-09-03 03:45:50 +00:00

127 lines
6.3 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/hammer")
_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)
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)
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)
quiero = args or [p for p in perfiles if p.startswith("escritorio")]
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())