Dos piezas para mirar la distro sin QEMU, que es lo que hacía falta para cazar el puntero
invisible: ese defecto no lo ve ninguna métrica de clausura Y TAMPOCO una captura headless — el
cursor no está en el framebuffer que devuelve screencopy. Hay que mirar la ventana.
`hydrate-profile.py <perfil>`: proyecta el cierre ENTERO de un perfil de `targets.toml`. Los
`hydrate-*.sh` por escritorio traen la lista de raíces escrita a mano dentro del script, o sea DOS
fuentes de verdad: hoy mismo se añadió `adwaita-cursors` a cosmic y sway y esos scripts —que no la
conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
Acá el cierre sale de `yupana.membresia()`, la misma función que usan build-state y vigia-imagen.
Y trae la sonda que costó dos intentos: `hydrate` ENLAZA, y `linkat()` no cruza un punto de
MONTAJE aunque sea el mismo disco. El store y `work/out` son dos binds del mismo /dev/sdb ⇒
`st_dev` COINCIDE y el hardlink falla igual. La primera versión comparaba `st_dev` y no disparó:
salieron 174 «artefactos que no proyectaron» y un rootfs de 272 ficheros con pinta de problema de
recetas. Ahora la sonda es FUNCIONAL —se intenta un enlace de verdad— y el error nombra el montaje
del store.
`sway-nested.sh`: arranca `escritorio-sway` como ventana del compositor que ya tenés delante.
Evidencia de esta corrida, con el rootfs hidratado del perfil:
[wlr] [xcursor] Loaded cursor theme 'default' at size 24 (62 available cursors)
o sea el tema llamado literalmente `default` —el alias que fabrica `adwaita-cursors`— resolviendo
a los 62 cursores de Adwaita. Antes de hoy esa línea no existía.
Tres cosas que sólo salen corriéndolo:
· **Ningún perfil incluye `musl`.** Ni `base`. El cierre de escritorio-sway no trae UN `ld-musl` y
sway es `link=dynamic` con intérprete `/lib/ld-musl-x86_64.so.1`: la libc sale del bootstrap, no
de una receta del perfil. Por eso el arnés monta DOS overlays. No es un bug, pero no estaba
escrito en ningún lado y un rootfs de perfil solo no arranca.
· **`yambar` no va en `bar { status_command … }`**: es una barra completa de layer-shell, no un
productor de estado para swaybar. Puesta ahí, sway rechaza la config ENTERA y arranca pelado —
se lee como «el escritorio está roto» cuando lo único mal es una línea.
· **`grim` hay que apuntarlo al socket de NUESTRO sway.** Adentro `WAYLAND_DISPLAY` es el del HOST
(es lo que sway necesita para nestear), así que un `grim` a secas devuelve una captura perfecta…
del escritorio de al lado. La primera captura fue exactamente eso.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGyBj4QyeCZiwLMkP9GJp6
159 lines
8.0 KiB
Python
Executable File
159 lines
8.0 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 ═══════════════════════════════════════════════
|
|
# `hammer 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
|
|
HAMMER = str(ROOT / "target/release/hammer")
|
|
|
|
|
|
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
|
|
|
|
|
|
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 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 ""))
|
|
|
|
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"}
|
|
|
|
if __name__ == "__main__":
|
|
main()
|