`HAMMER` estaba cableado a `ROOT/target/release/takana`, así que el guión no corre en una caja
INSTALADA, donde el binario es `/usr/bin/takana` y `target/` ni existe:
FileNotFoundError: [Errno 2] … '/opt/takana/target/release/takana'
Es la misma deuda que este frente ya pagó dos veces —`respaldo-storagebox.sh` y el «unhashable 875»
con el lab perfecto—: el instrumental asume que el hub es un árbol de desarrollo. Ahora busca en
`$TAKANA`, `$HAMMER`, el árbol de desarrollo y el `PATH`, en ese orden.
Salió al ir a actualizar la caja de producción a la imagen nueva, que es exactamente el caso de uso
para el que no servía.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
348 lines
19 KiB
Python
Executable File
348 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# hydrate-profile.py — proyecta al FHS el cierre ENTERO de un perfil de `targets.toml`.
|
|
#
|
|
# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════
|
|
# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del
|
|
# propio script. Desde que `docs/state/targets.toml` es la declaración de qué lleva cada imagen,
|
|
# eso son DOS fuentes de verdad para lo mismo, y divergen sin que nada lo diga: el 2026-09-04 se
|
|
# añadió `adwaita-cursors` a las raíces de cosmic y sway, y los scripts de hidratación —que no la
|
|
# conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
|
|
# La métrica y la imagen dirían cosas distintas, que es exactamente el modo de fallo que
|
|
# `vigia-imagen.py` existe para cazar.
|
|
#
|
|
# Acá el cierre sale de donde tiene que salir: `yupana.membresia()` sobre `targets.toml`, la misma
|
|
# función que usan `build-state.py` y `vigia-imagen.py`. Si el perfil cambia, esto cambia solo.
|
|
#
|
|
# ══ EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ═══════════════════════════════════════════════
|
|
# `takana hash <receta>` da el ArtifactHash de la receta de HOY sin construir. Con dos artefactos
|
|
# del mismo paquete conviviendo en el store —lo normal tras cualquier re-hash— el más reciente
|
|
# puede ser el viejo. La fecha miente; el hash no.
|
|
#
|
|
# ══ UN NODO SIN ARTEFACTO NO SE SALTEA EN SILENCIO ═════════════════════════════════════════════
|
|
# Se listan todos y se sale con código ≠ 0 salvo `--allow-missing`. Un rootfs al que le faltan
|
|
# tres paquetes arranca y falla tres capas más abajo (`CLAUDE.md` regla 3, y la cicatriz de sway
|
|
# llegando a 121/121 sin emulador de terminal).
|
|
#
|
|
# uso: scripts/hydrate-profile.py <perfil> [--into DIR] [--store DIR]
|
|
# [--link static|dynamic] [--keep] [--allow-missing]
|
|
#
|
|
# La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano.
|
|
import os, sys, glob, shutil, subprocess
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import yupana
|
|
import targets as targets_mod
|
|
|
|
ROOT = yupana.ROOT
|
|
|
|
def _binario_takana():
|
|
"""El binario, buscado en los TRES sitios donde puede estar — no sólo en el árbol de desarrollo.
|
|
|
|
Estaba cableado a `ROOT/target/release/takana` y por eso este script no corría en una caja
|
|
INSTALADA, donde el binario es `/usr/bin/takana` y `target/` ni existe. Es la misma deuda que el
|
|
frente del servidor ya pagó en `respaldo-storagebox.sh` y en los grafos («unhashable 875» con el
|
|
lab perfecto): el instrumental asume que el hub es un árbol de desarrollo. Falla al invocar, con
|
|
un `FileNotFoundError` que nombra una ruta que en esa máquina nunca va a existir.
|
|
"""
|
|
for c in (os.environ.get("TAKANA"), os.environ.get("HAMMER"), str(ROOT / "target/release/takana")):
|
|
if c and os.path.exists(c):
|
|
return c
|
|
return shutil.which("takana") or shutil.which("hammer") or str(ROOT / "target/release/takana")
|
|
|
|
HAMMER = _binario_takana()
|
|
|
|
|
|
def opcion(nombre, defecto=None):
|
|
"""`--flag valor`. Devuelve `defecto` si no está."""
|
|
if nombre in sys.argv:
|
|
i = sys.argv.index(nombre)
|
|
if i + 1 < len(sys.argv):
|
|
return sys.argv[i + 1]
|
|
sys.exit("falta el valor de %s" % nombre)
|
|
return defecto
|
|
|
|
|
|
# Lo que SÍ puede aparecer en la raíz de ciertos artefactos, con el motivo al lado. Sin esta tabla
|
|
# el barrido canta tres cosas cada vez y dos son correctas ⇒ se deja de leer, que es como se muere un
|
|
# guardián. La clave es el nombre de receta y el valor `(motivo, patrón de lo permitido)`.
|
|
RAIZ_POR_CONTRATO = {
|
|
"seed-zig": ("el toolchain zig ES el artefacto: su directorio versionado va en la raíz",
|
|
r"^zig-"),
|
|
"product-rootfs": ("es un ROOTFS, no un paquete: `store` y `ente` son suyos por diseño",
|
|
r"^(store|ente)$"),
|
|
"stage1-rootfs": ("es un ROOTFS, no un paquete: `ente` es suyo por diseño",
|
|
r"^(store|ente)$"),
|
|
}
|
|
|
|
# Suciedad REAL, medida, y cuyo arreglo está DECIDIDO en contra por ahora. Se imprime como contexto y
|
|
# NO hace fallar: si fallara siempre, un ofensor NUEVO se perdería entre el ruido de uno viejo.
|
|
RAIZ_DEUDA_DECIDIDA = {
|
|
"perl": "945 páginas nroff en `/` porque su Configure eligió man1dir=' '. El arreglo es UNA "
|
|
"línea y cuesta 305 rebuilds ⇒ va con el próximo bump. Ver recipes/perl.toml.",
|
|
}
|
|
|
|
|
|
def auditar_raices(store):
|
|
"""`--auditar-raices`: la misma pregunta que el bloque «quién ensucia la RAÍZ», pero sobre el
|
|
STORE ENTERO en vez de sobre el cierre de un perfil, y sin hidratar nada.
|
|
|
|
⚠ **POR QUÉ HACÍA FALTA**: la comprobación de abajo sólo ve los artefactos que un perfil
|
|
proyecta, y hoy hay **649 recetas que no alcanza ninguna imagen** (catálogo, no distro). Una fase
|
|
`install` que se equivoca de destino en una de ésas es invisible hasta el día que alguien la
|
|
declare en un perfil — y entonces ensucia la raíz del rootfs sin que se sepa quién fue.
|
|
|
|
Lo destapó `qdrant` (2026-09-11): selló con **69 M** de cabeceras de protobuf bajo `/src`, porque
|
|
el sandbox exporta `DESTDIR=/out` GLOBAL y el `make install` ANIDADO de `protobuf-src` lo hereda.
|
|
No está en ningún perfil ⇒ ningún guardián lo habría visto. Barrido el store entero tras
|
|
arreglarlo: los únicos que quedan son `perl` (deuda medida y decidida, ver `recipes/perl.toml`) y
|
|
los tres rootfs/seed, que ponen ahí lo suyo por contrato."""
|
|
import re as _re
|
|
import json as _json
|
|
|
|
# ⚠ **SÓLO EL ARTEFACTO VIGENTE DE CADA RECETA.** El store guarda TODOS los sellados históricos
|
|
# (bzip2 tenía dos al arreglarlo), así que auditarlos todos acusa a recetas YA SANAS por un
|
|
# sellado viejo — el mismo sobre-reporte que `static-audit.sh` tuvo que quitarse de encima
|
|
# («auditábamos el más reciente por mtime»). Un guardián que grita sobre lo ya arreglado se
|
|
# empieza a ignorar.
|
|
# El hash vigente no se recalcula con `takana hash` receta a receta (1150 procesos): sale de
|
|
# los grafos de estado, que es donde el repo YA lo tiene escrito, y cubre a la vez el corpus y
|
|
# las cinco colas.
|
|
vigentes = set()
|
|
for g in glob.glob(str(ROOT / "docs/state/build-state*.json")):
|
|
try:
|
|
for nodo in _json.load(open(g)).get("nodes", {}).values():
|
|
h = nodo.get("hash")
|
|
if h:
|
|
vigentes.add(h[3:] if h.startswith("b3:") else h)
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
filas = []
|
|
mirados = 0
|
|
for d in sorted(glob.glob(os.path.join(store, "*"))):
|
|
m = _re.match(r"^([0-9a-f]{64})-(.+)$", os.path.basename(d))
|
|
if not m or not os.path.isdir(d) or (vigentes and m.group(1) not in vigentes):
|
|
continue
|
|
mirados += 1
|
|
try:
|
|
extra = sorted(e for e in os.listdir(d) if e not in FHS_RAIZ)
|
|
except OSError:
|
|
continue
|
|
if extra:
|
|
filas.append((len(extra), m.group(2), extra))
|
|
# ── SEGUNDA PREGUNTA, LA MISMA FAMILIA: symlinks que apuntan a una raíz que NO ES EL FHS ─────
|
|
# Un `install` se puede equivocar de destino de dos maneras. Una deja ficheros donde no van (lo
|
|
# de arriba). La otra deja **enlaces** con la ruta del SANDBOX horneada dentro:
|
|
#
|
|
# usr/bin/bzcmp -> /out/usr/bin/bzdiff
|
|
#
|
|
# El Makefile de bzip2 crea sus symlinks con `ln -s $(PREFIX)/bin/bzdiff`, y como bzip2 no
|
|
# soporta `DESTDIR` la receta pasa `PREFIX=/out/usr` ⇒ el `/out` queda DENTRO del destino. Al
|
|
# hidratar, esos cuatro enlaces apuntan a un directorio que en el sistema real no existe.
|
|
#
|
|
# ⚠ Nada lo delata: el artefacto tiene contenido, los binarios de verdad corren, el audit de
|
|
# enlace estático pasa y la receta REPRODUCE. Cinco verdes sobre cuatro comandos rotos.
|
|
#
|
|
# **La regla es independiente del perfil, y por eso se puede aplicar al store entero**: un enlace
|
|
# a otro ARTEFACTO es normal y correcto (`kinfocenter -> /usr/bin/systemsettings`: los dos están
|
|
# en `escritorio-kde` y en el rootfs fundido resuelve). Lo que NUNCA puede estar bien es un
|
|
# destino absoluto cuya PRIMERA componente no sea del FHS — `/out`, `/src`, `/tmp`: ésas son
|
|
# rutas del lab, no del sistema que se instala.
|
|
malos_enlaces = []
|
|
for d in sorted(glob.glob(os.path.join(store, "*"))):
|
|
m = _re.match(r"^([0-9a-f]{64})-(.+)$", os.path.basename(d))
|
|
if not m or not os.path.isdir(d) or (vigentes and m.group(1) not in vigentes):
|
|
continue
|
|
for root, dirs, files in os.walk(d):
|
|
for e in files + dirs:
|
|
p_ = os.path.join(root, e)
|
|
if not os.path.islink(p_):
|
|
continue
|
|
t = os.readlink(p_)
|
|
if not t.startswith("/"):
|
|
continue # relativo: siempre correcto en un CAS
|
|
raiz = t.lstrip("/").split("/", 1)[0]
|
|
if raiz not in FHS_RAIZ:
|
|
malos_enlaces.append((m.group(2), os.path.relpath(p_, d), t))
|
|
|
|
# ⚠ **UN BARRIDO QUE NO MIRÓ NADA TIENE QUE DECIRLO.** Filtrar por hash vigente significa que un
|
|
# `--store` que no case con los grafos (otra máquina, otro checkout, un store de prueba) deja
|
|
# CERO artefactos auditados — y sin esta guarda eso se imprimiría como «✓ 0 ofensores», que es
|
|
# una respuesta falsa con forma de respuesta. Es la regla de siempre: un ausente falla ruidoso.
|
|
if vigentes and mirados == 0:
|
|
print("!! 0 artefactos auditados: ninguno de los %d hashes vigentes está en `%s`.\n"
|
|
" ¿`--store` apunta a otro sitio, o los grafos están sin regenerar?"
|
|
% (len(vigentes), store))
|
|
return 2
|
|
|
|
print("== raíces sucias en el STORE ENTERO ==")
|
|
nuevos = []
|
|
for n, nombre, extra in sorted(filas, reverse=True):
|
|
muestra = ", ".join(extra[:3]) + (", …" if n > 3 else "")
|
|
contrato = RAIZ_POR_CONTRATO.get(nombre)
|
|
if contrato and all(_re.match(contrato[1], e) for e in extra):
|
|
print(" ⊘ %-22s %4d (%s) — %s" % (nombre, n, muestra, contrato[0]))
|
|
continue
|
|
if nombre in RAIZ_DEUDA_DECIDIDA:
|
|
print(" • %-22s %4d (%s)" % (nombre, n, muestra))
|
|
print(" DEUDA DECIDIDA: %s" % RAIZ_DEUDA_DECIDIDA[nombre])
|
|
continue
|
|
print(" ✗ %-22s %4d (%s)" % (nombre, n, muestra))
|
|
nuevos.append(nombre)
|
|
print("\n== symlinks con una raíz que NO es del FHS (ruta del lab horneada) ==")
|
|
if not malos_enlaces:
|
|
print(" ✓ ninguno")
|
|
else:
|
|
for nombre, origen, destino in malos_enlaces:
|
|
print(" ✗ %-18s %s -> %s" % (nombre, origen, destino))
|
|
if nombre not in nuevos:
|
|
nuevos.append(nombre)
|
|
|
|
if not nuevos:
|
|
print("\n ✓ 0 ofensores NUEVOS sobre %d artefactos." % len(filas)
|
|
if filas else "\n ✓ ningún artefacto pone nada fuera del FHS en su raíz")
|
|
return 0
|
|
print("\n ✗ %d ofensor(es) NUEVO(s): %s\n"
|
|
" Un `install` que se equivoca de destino se lee, en el rootfs fundido, como si fuera\n"
|
|
" parte del sistema." % (len(nuevos), ", ".join(nuevos)))
|
|
return 1
|
|
|
|
|
|
def main():
|
|
libres = [a for i, a in enumerate(sys.argv[1:], 1)
|
|
if not a.startswith("--") and not (sys.argv[i - 1].startswith("--")
|
|
and sys.argv[i - 1] in FLAGS_CON_VALOR)]
|
|
if "--auditar-raices" in sys.argv:
|
|
sys.exit(auditar_raices(opcion("--store",
|
|
os.environ.get("HAMMER_STORE", str(ROOT / "store")))))
|
|
if not libres:
|
|
sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR]")
|
|
perfil = libres[0]
|
|
store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store")))
|
|
# Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende
|
|
# de cómo esté montado el store en cada máquina, no del perfil (ver la sonda EXDEV de abajo).
|
|
base_into = os.environ.get("HAMMER_ROOTFS_DIR", str(ROOT / "work/out"))
|
|
into = opcion("--into", os.path.join(base_into, perfil.replace("escritorio-", "") + "-rootfs"))
|
|
link = opcion("--link", "static")
|
|
keep = "--keep" in sys.argv
|
|
permitir_faltantes = "--allow-missing" in sys.argv
|
|
|
|
perfiles = targets_mod.load()
|
|
if perfil not in perfiles:
|
|
sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles))))
|
|
colas = yupana.cargar_colas()
|
|
memb = yupana.membresia(colas, perfiles)
|
|
nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted")
|
|
if not nodos:
|
|
sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil)
|
|
|
|
print("== %s: %d nodos en el cierre → %s" % (perfil, len(nodos), into))
|
|
|
|
# ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ──────
|
|
artefactos, faltan = [], []
|
|
for cola, nombre in nodos:
|
|
receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
|
|
else "recipes/%s/%s.toml" % (cola, nombre))
|
|
if not receta.exists():
|
|
faltan.append((nombre, "sin receta"))
|
|
continue
|
|
r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
|
|
capture_output=True, text=True)
|
|
h = r.stdout.strip().replace("b3:", "")
|
|
if len(h) != 64:
|
|
faltan.append((nombre, "hash no computable"))
|
|
continue
|
|
cand = glob.glob(os.path.join(store, h + "-*"))
|
|
if not cand:
|
|
faltan.append((nombre, "no sellado (%s)" % h[:12]))
|
|
continue
|
|
artefactos.append((nombre, h, cand[0]))
|
|
|
|
if faltan:
|
|
print("\n ⚠ %d nodo(s) SIN artefacto en el store:" % len(faltan))
|
|
for nombre, motivo in faltan:
|
|
print(" %-28s %s" % (nombre, motivo))
|
|
if not permitir_faltantes:
|
|
sys.exit("\nabortado sin tocar %s — con `--allow-missing` se hidrata lo que hay" % into)
|
|
print(" → se continúa por `--allow-missing`: el rootfs sale INCOMPLETO\n")
|
|
|
|
if os.path.isdir(into) and not keep:
|
|
shutil.rmtree(into)
|
|
os.makedirs(into, exist_ok=True)
|
|
|
|
# ── EXDEV: `hydrate` proyecta por HARDLINK, y `linkat()` NO cruza un punto de MONTAJE ───────
|
|
# aunque los dos lados sean el mismo filesystem y el mismo disco. Acá el store y `work/out` son
|
|
# dos bind mounts del MISMO /dev/sdb: `st_dev` coincide y el hardlink falla igual — por eso la
|
|
# sonda es FUNCIONAL (se intenta un enlace de verdad) y no una comparación de `st_dev`, que fue
|
|
# el primer intento y no disparó. Sin esto salen 174 «artefactos que no proyectaron» y un rootfs
|
|
# de 272 ficheros que parece un problema de recetas.
|
|
sonda_src = os.path.join(store, ".hydrate-profile-probe")
|
|
sonda_dst = os.path.join(into, ".hydrate-profile-probe")
|
|
try:
|
|
with open(sonda_src, "w"):
|
|
pass
|
|
os.link(sonda_src, sonda_dst)
|
|
os.unlink(sonda_dst)
|
|
except OSError as e:
|
|
mp = subprocess.run(["findmnt", "-n", "-T", store, "-o", "TARGET"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
sys.exit("EXDEV: no se puede enlazar del store a `%s` (%s).\n"
|
|
" `hydrate` ENLAZA, no copia, y un hardlink no cruza un montaje.\n"
|
|
" el store está montado en: %s\n"
|
|
" elegí un `--into` bajo ESE montaje (o fijá HAMMER_ROOTFS_DIR)."
|
|
% (into, e.strerror, mp or "?"))
|
|
finally:
|
|
if os.path.exists(sonda_src):
|
|
os.unlink(sonda_src)
|
|
|
|
fallidos = []
|
|
for i, (nombre, h, _d) in enumerate(artefactos, 1):
|
|
r = subprocess.run([HAMMER, "--store", store, "hydrate", h,
|
|
"--into", into, "--link", link],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
fallidos.append((nombre, (r.stderr or r.stdout).strip().splitlines()[-1:] or [""]))
|
|
if i % 25 == 0 or i == len(artefactos):
|
|
print(" %d/%d" % (i, len(artefactos)))
|
|
|
|
if fallidos:
|
|
print("\n ✗ %d artefacto(s) no proyectaron:" % len(fallidos))
|
|
for nombre, msg in fallidos:
|
|
print(" %-28s %s" % (nombre, msg[0] if msg else ""))
|
|
|
|
# ── quién ensucia la RAÍZ del rootfs ────────────────────────────────────────────────────────
|
|
# Se mira por ARTEFACTO y no sobre el árbol fundido, porque en el árbol fundido el nombre del
|
|
# culpable ya se perdió: 945 ficheros sueltos en `/` no se parecen a «una fase install con el
|
|
# directorio de destino vacío», que es lo que son. Cazado el 2026-09-04 mirando un rootfs de
|
|
# verdad, no una métrica: `perl` instala sus 945 páginas nroff en `/` porque su Configure eligió
|
|
# `man1dir=' '`. Ver el comentario en `recipes/perl.toml`.
|
|
sucios = []
|
|
for nombre, _h, d in artefactos:
|
|
extra = sorted(e for e in os.listdir(d) if e not in FHS_RAIZ)
|
|
if extra:
|
|
sucios.append((nombre, extra))
|
|
if sucios:
|
|
print("\n ⚠ entradas FUERA del FHS en la raíz del rootfs:")
|
|
for nombre, extra in sorted(sucios, key=lambda x: -len(x[1])):
|
|
muestra = ", ".join(extra[:3]) + (", …" if len(extra) > 3 else "")
|
|
print(" %-28s %4d (%s)" % (nombre, len(extra), muestra))
|
|
|
|
n_fich = sum(len(f) for _, _, f in os.walk(into))
|
|
du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split()
|
|
print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?"))
|
|
sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0)
|
|
|
|
|
|
FLAGS_CON_VALOR = {"--into", "--store", "--link"}
|
|
|
|
# Lo que un artefacto PUEDE poner en la raíz. Todo lo demás es una fase `install` que se equivocó de
|
|
# destino, y en un rootfs fundido eso se lee como si fuera parte del sistema.
|
|
FHS_RAIZ = {"bin", "sbin", "lib", "lib64", "usr", "etc", "var", "run", "opt", "srv",
|
|
"home", "root", "boot", "dev", "proc", "sys", "tmp", "mnt", "media",
|
|
"ente", # el manifiesto de atestación del producto (hammer bootstrap)
|
|
"linuxrc", # el enlace de busybox, que va en la raíz por contrato del kernel
|
|
".hammer"} # metadatos del store: los pone hammer al sellar, no la receta
|
|
|
|
if __name__ == "__main__":
|
|
main()
|