tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 4190c7b43e5bd323a9b166340cfa16cd324ca2e4 scripts/hydrate-profile.py Python · 183 líneas · 9.8 KB Historia Quién tocó qué crudo

takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4 476168bb0 · Sergio ·

1#!/usr/bin/env python3
2# hydrate-profile.py — proyecta al FHS el cierre ENTERO de un perfil de `targets.toml`.
3#
4# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════
5# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del
6# propio script. Desde que `docs/state/targets.toml` es la declaración de qué lleva cada imagen,
7# eso son DOS fuentes de verdad para lo mismo, y divergen sin que nada lo diga: el 2026-09-04 se
8# añadió `adwaita-cursors` a las raíces de cosmic y sway, y los scripts de hidratación —que no la
9# conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
10# La métrica y la imagen dirían cosas distintas, que es exactamente el modo de fallo que
11# `vigia-imagen.py` existe para cazar.
12#
13# Acá el cierre sale de donde tiene que salir: `yupana.membresia()` sobre `targets.toml`, la misma
14# función que usan `build-state.py` y `vigia-imagen.py`. Si el perfil cambia, esto cambia solo.
15#
16# ══ EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ═══════════════════════════════════════════════
17# `takana hash <receta>` da el ArtifactHash de la receta de HOY sin construir. Con dos artefactos
18# del mismo paquete conviviendo en el store —lo normal tras cualquier re-hash— el más reciente
19# puede ser el viejo. La fecha miente; el hash no.
20#
21# ══ UN NODO SIN ARTEFACTO NO SE SALTEA EN SILENCIO ═════════════════════════════════════════════
22# Se listan todos y se sale con código ≠ 0 salvo `--allow-missing`. Un rootfs al que le faltan
23# tres paquetes arranca y falla tres capas más abajo (`CLAUDE.md` regla 3, y la cicatriz de sway
24# llegando a 121/121 sin emulador de terminal).
25#
26#   uso:  scripts/hydrate-profile.py <perfil> [--into DIR] [--store DIR]
27#                                    [--link static|dynamic] [--keep] [--allow-missing]
28#
29#   La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano.
30import os, sys, glob, shutil, subprocess
31
32sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33import yupana
34import targets as targets_mod
35
36ROOT = yupana.ROOT
37HAMMER = str(ROOT / "target/release/takana")
38
39
40def opcion(nombre, defecto=None):
41    """`--flag valor`. Devuelve `defecto` si no está."""
42    if nombre in sys.argv:
43        i = sys.argv.index(nombre)
44        if i + 1 < len(sys.argv):
45            return sys.argv[i + 1]
46        sys.exit("falta el valor de %s" % nombre)
47    return defecto
48
49
50def main():
51    libres = [a for i, a in enumerate(sys.argv[1:], 1)
52              if not a.startswith("--") and not (sys.argv[i - 1].startswith("--")
53                                                 and sys.argv[i - 1] in FLAGS_CON_VALOR)]
54    if not libres:
55        sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR]")
56    perfil = libres[0]
57    store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store")))
58    # Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende
59    # de cómo esté montado el store en cada máquina, no del perfil (ver la sonda EXDEV de abajo).
60    base_into = os.environ.get("HAMMER_ROOTFS_DIR", str(ROOT / "work/out"))
61    into = opcion("--into", os.path.join(base_into, perfil.replace("escritorio-", "") + "-rootfs"))
62    link = opcion("--link", "static")
63    keep = "--keep" in sys.argv
64    permitir_faltantes = "--allow-missing" in sys.argv
65
66    perfiles = targets_mod.load()
67    if perfil not in perfiles:
68        sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles))))
69    colas = yupana.cargar_colas()
70    memb = yupana.membresia(colas, perfiles)
71    nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted")
72    if not nodos:
73        sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil)
74
75    print("== %s: %d nodos en el cierre → %s" % (perfil, len(nodos), into))
76
77    # ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ──────
78    artefactos, faltan = [], []
79    for cola, nombre in nodos:
80        receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
81                         else "recipes/%s/%s.toml" % (cola, nombre))
82        if not receta.exists():
83            faltan.append((nombre, "sin receta"))
84            continue
85        r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
86                           capture_output=True, text=True)
87        h = r.stdout.strip().replace("b3:", "")
88        if len(h) != 64:
89            faltan.append((nombre, "hash no computable"))
90            continue
91        cand = glob.glob(os.path.join(store, h + "-*"))
92        if not cand:
93            faltan.append((nombre, "no sellado (%s)" % h[:12]))
94            continue
95        artefactos.append((nombre, h, cand[0]))
96
97    if faltan:
98        print("\n   ⚠ %d nodo(s) SIN artefacto en el store:" % len(faltan))
99        for nombre, motivo in faltan:
100            print("     %-28s %s" % (nombre, motivo))
101        if not permitir_faltantes:
102            sys.exit("\nabortado sin tocar %s — con `--allow-missing` se hidrata lo que hay" % into)
103        print("   → se continúa por `--allow-missing`: el rootfs sale INCOMPLETO\n")
104
105    if os.path.isdir(into) and not keep:
106        shutil.rmtree(into)
107    os.makedirs(into, exist_ok=True)
108
109    # ── EXDEV: `hydrate` proyecta por HARDLINK, y `linkat()` NO cruza un punto de MONTAJE ───────
110    # aunque los dos lados sean el mismo filesystem y el mismo disco. Acá el store y `work/out` son
111    # dos bind mounts del MISMO /dev/sdb: `st_dev` coincide y el hardlink falla igual — por eso la
112    # sonda es FUNCIONAL (se intenta un enlace de verdad) y no una comparación de `st_dev`, que fue
113    # el primer intento y no disparó. Sin esto salen 174 «artefactos que no proyectaron» y un rootfs
114    # de 272 ficheros que parece un problema de recetas.
115    sonda_src = os.path.join(store, ".hydrate-profile-probe")
116    sonda_dst = os.path.join(into, ".hydrate-profile-probe")
117    try:
118        with open(sonda_src, "w"):
119            pass
120        os.link(sonda_src, sonda_dst)
121        os.unlink(sonda_dst)
122    except OSError as e:
123        mp = subprocess.run(["findmnt", "-n", "-T", store, "-o", "TARGET"],
124                            capture_output=True, text=True).stdout.strip()
125        sys.exit("EXDEV: no se puede enlazar del store a `%s` (%s).\n"
126                 "       `hydrate` ENLAZA, no copia, y un hardlink no cruza un montaje.\n"
127                 "       el store está montado en: %s\n"
128                 "       elegí un `--into` bajo ESE montaje (o fijá HAMMER_ROOTFS_DIR)."
129                 % (into, e.strerror, mp or "?"))
130    finally:
131        if os.path.exists(sonda_src):
132            os.unlink(sonda_src)
133
134    fallidos = []
135    for i, (nombre, h, _d) in enumerate(artefactos, 1):
136        r = subprocess.run([HAMMER, "--store", store, "hydrate", h,
137                            "--into", into, "--link", link],
138                           capture_output=True, text=True)
139        if r.returncode != 0:
140            fallidos.append((nombre, (r.stderr or r.stdout).strip().splitlines()[-1:] or [""]))
141        if i % 25 == 0 or i == len(artefactos):
142            print("   %d/%d" % (i, len(artefactos)))
143
144    if fallidos:
145        print("\n   ✗ %d artefacto(s) no proyectaron:" % len(fallidos))
146        for nombre, msg in fallidos:
147            print("     %-28s %s" % (nombre, msg[0] if msg else ""))
148
149    # ── quién ensucia la RAÍZ del rootfs ────────────────────────────────────────────────────────
150    # Se mira por ARTEFACTO y no sobre el árbol fundido, porque en el árbol fundido el nombre del
151    # culpable ya se perdió: 945 ficheros sueltos en `/` no se parecen a «una fase install con el
152    # directorio de destino vacío», que es lo que son. Cazado el 2026-09-04 mirando un rootfs de
153    # verdad, no una métrica: `perl` instala sus 945 páginas nroff en `/` porque su Configure eligió
154    # `man1dir=' '`. Ver el comentario en `recipes/perl.toml`.
155    sucios = []
156    for nombre, _h, d in artefactos:
157        extra = sorted(e for e in os.listdir(d) if e not in FHS_RAIZ)
158        if extra:
159            sucios.append((nombre, extra))
160    if sucios:
161        print("\n   ⚠ entradas FUERA del FHS en la raíz del rootfs:")
162        for nombre, extra in sorted(sucios, key=lambda x: -len(x[1])):
163            muestra = ", ".join(extra[:3]) + (", …" if len(extra) > 3 else "")
164            print("     %-28s %4d  (%s)" % (nombre, len(extra), muestra))
165
166    n_fich = sum(len(f) for _, _, f in os.walk(into))
167    du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split()
168    print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?"))
169    sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0)
170
171
172FLAGS_CON_VALOR = {"--into", "--store", "--link"}
173
174# Lo que un artefacto PUEDE poner en la raíz. Todo lo demás es una fase `install` que se equivocó de
175# destino, y en un rootfs fundido eso se lee como si fuera parte del sistema.
176FHS_RAIZ = {"bin", "sbin", "lib", "lib64", "usr", "etc", "var", "run", "opt", "srv",
177            "home", "root", "boot", "dev", "proc", "sys", "tmp", "mnt", "media",
178            "ente",      # el manifiesto de atestación del producto (hammer bootstrap)
179            "linuxrc",   # el enlace de busybox, que va en la raíz por contrato del kernel
180            ".hammer"}   # metadatos del store: los pone hammer al sellar, no la receta
181
182if __name__ == "__main__":
183    main()