#!/usr/bin/env python3 # hydrate-profile.py — proyecta al FHS el cierre de IMAGEN de un perfil de `targets.toml`. # # ⚠ Decía «el cierre ENTERO», y eso era literal y era el bug: proyectaba `build ∪ runtime`, o sea la # cadena de compilación adentro de cada imagen (`llvm21`, `go`, `python3`, `perl`, `cmake`…). Desde el # 2026-09-22 proyecta `yupana.membresia_imagen()`, que descuenta las herramientas declaradas en # `[solo-build]` de `targets.toml` — salvo las que un perfil declare como raíz, donde gana el perfil. # # ══ 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 ` 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] # [--closure image|build] # --closure build → proyecta el cierre ENTERO (con herramientas), como antes del # 2026-09-22. Es el CONTROL para poder medir el antes y el después. # # 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 [--into DIR] [--version ETIQUETA]") 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() # ⚠ **EL CIERRE DE IMAGEN, NO EL DE BUILD** (2026-09-22). Esto proyectaba `membresia()`, que es # `build ∪ runtime`, así que el rootfs salía con la cadena de compilación adentro: `llvm21` # (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`, `wasi-sdk`, `nodejs`… — el # 44 % de los bytes de `servidor`. Nadie lo había medido porque el cierre de build es la # respuesta correcta a OTRA pregunta («qué hay que construir»), y acá se usaba para ésta. # La lista de lo que no viaja está declarada en `[solo-build]` de `targets.toml`, con sus dos # vigías. No toca ninguna receta ⇒ CERO reconstrucciones. # `--closure build` proyecta el cierre VIEJO (con herramientas). No es una opción de uso: es el # control del experimento — sin poder medir el antes, «−X MiB» es una afirmación sin testigo. cierre = opcion("--closure", "image") if cierre not in ("image", "build"): sys.exit("--closure: `image` (lo que viaja) o `build` (el cierre entero, para comparar)") memb = (yupana.membresia_imagen(colas, perfiles) if cierre == "image" else 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) # Se imprimen los DOS números y la diferencia. Un recorte que no se ve en la salida es un recorte # que alguien va a diagnosticar como «faltan paquetes» dentro de seis meses. memb_build = yupana.membresia(colas, perfiles) n_build = sum(1 for n, ps in memb_build.items() if perfil in ps and n[0] != "wanted") print("== %s: %d nodos en el cierre de %s → %s" % (perfil, len(nodos), "IMAGEN" if cierre == "image" else "BUILD", into)) if cierre == "image": print(" (%d en el cierre de build; %d herramientas de `[solo-build]` no viajan)" % (n_build, n_build - len(nodos))) # ── 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)) # ── LA IMAGEN DECLARA DE QUÉ CLAUSURA SALIÓ (SDD 33 §4, 2026-10-10) ──────────────────────── # El §4 decide rearmar los medios por el CIERRE y no por el calendario, y su comparación es # «h ≠ el hash de la imagen publicada». **Esa segunda mitad no se podía hacer: ninguna imagen # declaraba nada** — medido, ni un `/etc/os-release` se escribía; los aciertos de `grep # os-release` en el repo eran todos LECTURAS para detectar el anfitrión. Sin esto, el cron del # §4 vuelve a ser un calendario. # # Va ACÁ, y no en cada script de imagen, por el mismo motivo que el bit de privilegio de abajo: # por este guion pasan TODAS las imágenes de perfil, y un paso que hay que acordarse de invocar # se olvida. # # ⚠ **El digest se calcula con la MISMA función que `cierre-perfiles.py`** (`digest_de`), sobre # lo que ESTE script acaba de proyectar. Dos implementaciones «equivalentes» divergirían sin que # nada lo dijera, y entonces el §4 diría «rearmá» por una diferencia de código y no de # contenido. Se importa por ruta porque el fichero lleva guion en el nombre y no es importable # como módulo. # # ⚠ **NADA DERIVADO DEL RELOJ entra en este fichero.** Un rootfs hidratado tiene que ser # reproducible: una fecha, o un nombre tipo `takana 2026.10`, cambiaría los bytes de la imagen # cada mes sin que la clausura se mueva — justo la clase de mentira que el SDD 33 §1 señala # («publicar sólo el nombre es la forma barata de que el sitio empiece a mentir»). El nombre # legible es una ETIQUETA y entra sólo si alguien la pasa con `--version`. import importlib.util as _ilu _spec = _ilu.spec_from_file_location("cierre_perfiles", str(ROOT / "scripts/cierre-perfiles.py")) _cp = _ilu.module_from_spec(_spec) _spec.loader.exec_module(_cp) # `artefactos` trae (nombre, hash, dir) y `nodos` las parejas (cola, nombre) en el mismo orden # de construcción; se recupera la cola para que la clave sea la misma que usa la vigía. cola_de = {n: q for q, n in nodos} items = [(cola_de[nombre], nombre, h) for nombre, h, _d in artefactos] version = opcion("--version", "") lineas = ['NAME="takana"', 'ID=takana', 'PRETTY_NAME="takana · %s"' % perfil, 'HOME_URL="https://takana-os.net"', 'TAKANA_PERFIL=%s' % perfil, 'TAKANA_NODOS=%d' % len(items)] if version: # Etiqueta, no identidad: va sólo si se pide, para no meter el reloj en el rootfs. lineas.insert(2, 'VERSION="%s"' % version) lineas.insert(3, 'VERSION_ID=%s' % version) if faltan: # ⚠ Un rootfs al que le faltan artefactos NO declara digest. Declararlo sería lo peor de # los dos mundos: un número con pinta de identidad sobre una imagen que no es la clausura # que dice ser, y el §4 compararía contra una mentira. Se declara el HUECO. lineas.append('TAKANA_CIERRE=incompleto') lineas.append('TAKANA_CIERRE_FALTAN=%d' % len(faltan)) else: lineas.append('TAKANA_CIERRE=%s' % _cp.digest_de(items)) if cierre != "image": # El cierre de build es el control del experimento, no una imagen publicable: que el # fichero lo diga, o alguien comparará un digest de peras con uno de manzanas. lineas.append('TAKANA_CIERRE_VISTA=build') os.makedirs(os.path.join(into, "etc"), exist_ok=True) with open(os.path.join(into, "etc", "os-release"), "w", encoding="utf-8") as fh: fh.write("\n".join(lineas) + "\n") print("\n== identidad → %s/etc/os-release" % into) for l in lineas: if l.startswith("TAKANA_") or l.startswith("VERSION"): print(" %s" % l) # ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, respondido el 2026-09-22) ────────────────────────── # Se aplica ACÁ y no en la receta porque no puede vivir en el artefacto: `of_tree` sólo hashea el # bit de EJECUCIÓN (⇒ el hash no lo cubriría) y la hidratación es por HARDLINK (⇒ el `chmod` # viajaría al artefacto que comparten todas las imágenes). `takana setuid --apply` rompe el # enlace duro del fichero afectado y deja el store intacto; verifica en las dos direcciones —lo # declarado está, y nada sin declarar tiene el bit— y sale ≠0 si algo no cuadra. # # Va en este script, que es por donde pasan TODAS las imágenes de perfil, y no en cada script de # imagen: un paso de seguridad que hay que acordarse de invocar se olvida. Y se imprime siempre, # incluso cuando no hay declaraciones: «0 declaraciones» es una respuesta, y el silencio no. rutas_recetas = [str(ROOT / ("recipes/%s.toml" % n if q == "corpus" else "recipes/%s/%s.toml" % (q, n))) for q, n in nodos] print("\n== bit de privilegio (`[[setuid]]`) → %s" % into) r = subprocess.run([HAMMER, "--store", store, "setuid", *rutas_recetas, "--apply", into], capture_output=True, text=True) print((r.stdout or "").rstrip() or " (sin salida)") if r.returncode != 0: # No se aborta el rootfs ya hidratado —borrarlo sería peor— pero se sale ≠0: un rootfs con el # privilegio mal puesto NO puede pasar por bueno en un script de imagen que encadena. print((r.stderr or "").rstrip()) print(" ✗ el bit de privilegio no quedó bien; el rootfs está hidratado pero NO es apto.") salida_setuid = 1 else: salida_setuid = 0 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 salida_setuid or (faltan and not permitir_faltantes)) else 0) FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure", "--version"} # 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()