#!/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 ` 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 [--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/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 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 [--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()