paso 4: el ensamblado de imagen toma sus herramientas del STORE — y estaba roto en las 3 máquinas
La disyuntiva del paso 4 («receta de grub o retirar la rama BIOS») se cierra de un lado por medición: Hetzner Cloud ARRANCA POR BIOS (medido en gioser, SDD 28 §3.1) ⇒ la imagen del servidor de producción es justamente la que necesita esa rama, así que no se retira. Y al mirar apareció algo más grande: los scripts tomaban sfdisk/mke2fs/cpio/gzip/mformat/xorriso del PATH del host, y en el hub, en su anfitrión y en el worker NO EXISTE NINGUNO — el pipeline de imagen estaba roto en las tres máquinas, en silencio, hasta que alguien lo corriera. Seis de las siete son NUESTRAS y están selladas (util-linux, e2fsprogs, libarchive/bsdcpio, gzip, mtools, xorriso, más dosfstools), estáticas musl, y corren tal cual en el host. - `scripts/lib/herramientas-desde-store.sh`: resolvedor único, por HASH VIGENTE (iso-image.sh resolvía xorriso por el primer glob y install-image-efi.sh mtools por fecha: con dos artefactos conviviendo el más reciente puede ser el viejo). El host queda como caída RUIDOSA: un binario sin hash se anuncia en el log. El store se detecta, porque el ./store del clon está vacío en el hub. - `scripts/vigia-ensamblado.sh`: 8 selladas · 0 del host · 2 ausentes (grub-mkimage, grub-bios-setup). - Cableados install-image.sh, install-image-efi.sh, iso-image.sh y metal-usb-sdboot.sh. - `sonda_hardlink`: el staging enlaza en duro desde el rootfs y el EXDEV llegaba como una PARED de `cp: Invalid cross-device link` sin decir la causa. Sonda funcional (st_dev no sirve: store y work/out son bind-mounts del mismo dispositivo) que nombra los dos montajes. PROBADO de punta a punta: una imagen EFI armada EN EL HUB, que antes era imposible — rc=0, 297M sparse, tabla GPT verificada con el sfdisk sellado y la ESP con las dos rutas del kernel. Queda UNA receta: el gestor de arranque BIOS (grub, o limine que cubre BIOS y UEFI de una). Y queda anotado que metal-usb-sdboot.sh arranca con un systemd-bootx64.efi sacado de un rootfs de Arch del home del usuario: otro binario ajeno sin hash en el camino del metal.
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
7 qillqakuna · +385 −43
- docs/plan-que-sigue-tras-busybox.md +94 −0
- scripts/install-image-efi.sh +34 −14
- scripts/install-image.sh +27 −6
- scripts/iso-image.sh +19 −11
- scripts/lib/herramientas-desde-store.sh +107 −0
- scripts/metal-usb-sdboot.sh +29 −12
- scripts/vigia-ensamblado.sh +75 −0
diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index 02950e5b..acdbc0db 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -588,3 +588,97 @@ El detalle está en `docs/plan-botar-busybox.md` §7. En corto, y todo con `rc`
**De 23 applets de trabajo real quedan 3**, y dos son el ADR de `getty`. El paso 3 del plan decía
«23 → 11»; medido, es **23 → 3**.
+
+---
+
+# Paso 4 — HECHO: las herramientas del ensamblado salen del store; el gestor de arranque es el único hueco (2026-09-22)
+
+El paso 4 estaba planteado como una disyuntiva: «o `grub` entra al corpus como receta, o la rama BIOS
+se retira». **Medir la cerró de un lado y abrió algo más grande del otro.**
+
+## La rama BIOS no se puede retirar, y está medido en el repo
+
+> **Hetzner Cloud arranca por BIOS** — «medido en gioser: no existe `/sys/firmware/efi`; GPT + GRUB»
+> (SDD 28 §3.1).
+
+O sea que la imagen del **servidor de producción** es justamente la que necesita la rama BIOS. La
+disyuntiva se resuelve por el otro lado: el gestor de arranque tiene que entrar al corpus.
+
+## Lo que apareció al mirar: el pipeline de imagen estaba roto en las TRES máquinas
+
+Los scripts tomaban sus herramientas del `PATH` del host. Medido en el hub, en su anfitrión (`raiz`) y
+en el worker `dev.gioser.net`:
+
+```
+grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE
+```
+
+**Ninguna imagen se podía armar en ninguna de las tres**, y sólo se veía al intentarlo, con un
+`falta cpio` que se lee como un descuido de la máquina. Y lo absurdo: **seis de las siete son
+nuestras y están selladas** — `util-linux` (sfdisk), `e2fsprogs` (mke2fs), `libarchive` (bsdcpio),
+`gzip`, `mtools`, `xorriso`, más `dosfstools`. Todas estáticas musl; **corren tal cual en el host**,
+comprobado ejecutándolas.
+
+## Lo hecho
+
+**`scripts/lib/herramientas-desde-store.sh`** — resolvedor único: la herramienta sale del artefacto
+sellado **por hash vigente**, con el host como CAÍDA ruidosa (un binario del host no tiene hash ⇒ la
+imagen deja de ser reproducible por ahí, y eso se dice en el log) y abortando con las dos rutas
+nombradas si no está en ninguno.
+
+⚠ **Por hash, no por fecha ni por el primer glob**: `iso-image.sh` resolvía xorriso con
+`ls store/*-xorriso/…|head -1` y `install-image-efi.sh` mtools con `ls -dt …|head -1`. Con dos
+artefactos del mismo paquete conviviendo —lo normal tras cualquier re-hash— el más reciente puede ser
+el viejo. Es la lección que `hydrate-profile.py` y `libs-compartidas-desde-store.sh` ya habían pagado.
+Y el store por defecto se **detecta**: en este hub el `./store` del clon está vacío y el de verdad es
+`/store`, así que un default mudo manda a diagnosticar al lugar equivocado.
+
+Cableados: `install-image.sh`, `install-image-efi.sh`, `iso-image.sh`, `metal-usb-sdboot.sh`.
+
+**`scripts/vigia-ensamblado.sh`** — el guardián: de dónde sale cada herramienta.
+
+```
+✓ sfdisk util-linux · ✓ mke2fs e2fsprogs · ✓ bsdcpio libarchive · ✓ gzip · ✓ mformat/mcopy mtools
+✓ xorriso · ✓ mkfs.vfat dosfstools
+✗ grub (no hay receta) — grub-mkimage AUSENTE, grub-bios-setup AUSENTE
+== resumen: 8 sellada(s) · 0 del host (sin hash) · 2 ausente(s)
+```
+
+## La prueba: una imagen EFI armada EN EL HUB, que antes era imposible
+
+```
+ROOTFS=<product-rootfs> KERNEL=<linux-metal/bzImage> STAGE=/store/.efi-stage \
+ sh scripts/install-image-efi.sh ⇒ rc=0
+==> imagen EFI creada: 297M en disco (sparse), 754M virtuales
+```
+
+Todas las herramientas del store (con su hash en el log), tabla GPT verificada con el `sfdisk`
+sellado (ESP 48M + root 512M + store 128M + estado 64M) y la ESP con las dos rutas del kernel más el
+initramfs, verificada por el propio script con `mdir`.
+
+⚠ **Y el primer intento falló con EXDEV**, que es el otro arreglo de este paso: el staging **enlaza en
+duro** desde el rootfs y un enlace no cruza un montaje aunque sea el mismo disco
+([[hardlink-cruza-mount-exdev]]). El fallo llegaba como una **pared de `cp: Invalid cross-device
+link`** —cientos de líneas— sin que ninguna dijera la causa. Ahora hay `sonda_hardlink`, FUNCIONAL
+(intenta un enlace de verdad, porque `store` y `work/out` son bind-mounts del mismo dispositivo y
+comparar `st_dev` no dispara), y dice qué montaje es cada uno:
+
+```
+✗ EXDEV: no se puede ENLAZAR de '/store/…-product-rootfs' a '/work/sergio/work/.efi-stage'.
+ origen está en: /store destino está en: /work/sergio
+ Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs.
+```
+
+Es la misma sonda que `hydrate-profile.py` ya tenía; `install-image.sh` sólo tenía el comentario.
+
+## Lo que queda, y es UNA receta
+
+- **El gestor de arranque BIOS.** Dos salidas, ninguna gratis: **`grub`** (lo que los scripts ya
+ hablan) o **`limine`**, que cubre BIOS **y** UEFI de una y es mucho más chico. Es un build de
+ granja, no un cambio de script, y por eso no entra en este paso.
+- **`metal-usb-sdboot.sh` arranca con un `systemd-bootx64.efi` sacado de un rootfs de Arch del home
+ del usuario** — otro binario ajeno, sin hash y fuera del store, en el camino del metal. Anotado y
+ no tocado: esa ruta se usa para bringup con menú, y cambiarla por EFI-stub (que ya existe) es su
+ propia decisión.
+- Para cualquier objetivo **UEFI la cadena ya es soberana y no lleva gestor**: el bzImage *es* el
+ binario EFI (ADR 0018), y ahora además se puede armar acá.
diff --git a/scripts/install-image-efi.sh b/scripts/install-image-efi.sh
index 3641e34b..7763f502 100755
--- a/scripts/install-image-efi.sh
+++ b/scripts/install-image-efi.sh
@@ -60,7 +60,11 @@ ESP_MB="${ESP_MB:-64}"
ROOT_SIZE="${ROOT_SIZE:-2048}"
STORE_SIZE="${STORE_SIZE:-512}"
STATE_SIZE="${STATE_SIZE:-512}"
-MTOOLS="${MTOOLS:-$(ls -dt store/*-mtools/usr/bin 2>/dev/null | head -1)}"
+# ⚠ Las herramientas del ensamblado salen del STORE, por HASH VIGENTE, no del PATH del host ni del
+# artefacto más reciente por fecha (2026-09-22). En este hub, en su anfitrión y en el worker NO existe
+# ninguna de `cpio mformat xorriso grub-mkimage`: el pipeline estaba roto en las tres máquinas y sólo
+# se veía al correrlo. Ver `scripts/lib/herramientas-desde-store.sh`.
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
[ -n "${ROOTFS:-}" ] && [ -d "$ROOTFS" ] || { echo "no existe ROOTFS: '${ROOTFS:-}' (set ROOTFS=<dir product-rootfs>)" >&2; exit 1; }
# ⚠ Esto decía `[ -e "$ROOTFS/sbin/init" ]` y era un chequeo ROTO para todo rootfs sano: `/sbin/init`
@@ -80,8 +84,21 @@ fi
[ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel EFI-stub (build recipes/linux-metal.toml o set KERNEL=)" >&2; exit 1; }
BUSYBOX="${BUSYBOX:-$ROOTFS/bin/busybox}"
[ -r "$BUSYBOX" ] || { echo "no encuentro busybox estático en $BUSYBOX (set BUSYBOX=)" >&2; exit 1; }
-[ -n "${MTOOLS:-}" ] && [ -x "$MTOOLS/mformat" ] || { echo "no encuentro mtools de hammer (build recipes/mtools.toml o set MTOOLS=)" >&2; exit 1; }
-for t in sfdisk mke2fs cpio gzip; do command -v "$t" >/dev/null 2>&1 || { echo "falta $t" >&2; exit 1; }; done
+echo "==> herramientas del ensamblado"
+MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1
+MMD=$(herramienta mtools usr/bin/mmd mmd) || exit 1
+MCOPY=$(herramienta mtools usr/bin/mcopy mcopy) || exit 1
+MDIR=$(herramienta mtools usr/bin/mdir mdir) || exit 1
+SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
+MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1
+CPIO=$(herramienta libarchive usr/bin/bsdcpio cpio) || exit 1
+GZIP=$(herramienta gzip bin/gzip gzip) || exit 1
+# `MTOOLS=` sigue existiendo como override manual (un directorio con los binarios), que es como lo
+# documenta la cabecera; si se pasa, gana sobre el store.
+if [ -n "${MTOOLS:-}" ]; then
+ MFORMAT="${MTOOLS}/mformat"; MMD="${MTOOLS}/mmd"; MCOPY="${MTOOLS}/mcopy"; MDIR="${MTOOLS}/mdir"
+ [ -x "$MFORMAT" ] || { echo "MTOOLS=$MTOOLS no tiene mformat" >&2; exit 1; }
+fi
unshare -r true 2>/dev/null || { echo "se requiere 'unshare -r' (ficheros root-owned sin sudo)" >&2; exit 1; }
STAGE="${STAGE:-work/.install-efi-stage}"
@@ -152,11 +169,14 @@ log "HAMMER-EFI-DISK-PIVOT-OK: switch_root → /sbin/init"
exec switch_root /newroot /sbin/init
INIT
chmod +x "$IRD_TREE/init"
-( cd "$IRD_TREE" && find . -print0 | cpio --null -o -H newc --owner=root:root 2>/dev/null | gzip -1 ) > "$IRD"
+# `bsdcpio` de libarchive acepta el mismo `--null -o -H newc --owner=root:root` que el GNU cpio
+# (comprobado 2026-09-22 contra el artefacto sellado: mismo `newc`, dueños root).
+( cd "$IRD_TREE" && find . -print0 | "$CPIO" --null -o -H newc --owner=root:root 2>/dev/null | "$GZIP" -1 ) > "$IRD"
echo " initramfs: $(du -h "$IRD" | cut -f1)"
# --- 2) árbol root: hardlinks del rootfs SIN store/estado (van en sus particiones dedicadas) -----------
echo "==> staging root (hardlinks del rootfs, sin /store ni /var/lib/hammer)"
+sonda_hardlink "$ROOTFS" "$STAGE" || exit 1
cp -al "$ROOTFS" "$ROOT_TREE"
rm -rf "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer"
mkdir -p "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer"
@@ -265,15 +285,15 @@ if [ "$_need_mb" -gt "$_esp_usable_mb" ]; then
fi
rm -f "$ESP_IMG"; truncate -s "$(( ESP_MB * 1024 * 1024 ))" "$ESP_IMG"
-"$MTOOLS/mformat" -F -v HAMMER -i "$ESP_IMG" ::
-"$MTOOLS/mmd" -i "$ESP_IMG" ::/EFI ::/EFI/BOOT ::/EFI/takana
-"$MTOOLS/mcopy" -i "$ESP_IMG" "$KERNEL" ::/EFI/takana/takanax64.efi
-"$MTOOLS/mcopy" -i "$ESP_IMG" "$KERNEL" ::/EFI/BOOT/BOOTX64.EFI
-"$MTOOLS/mcopy" -i "$ESP_IMG" "$IRD" ::/initramfs.cpio.gz
+"$MFORMAT" -F -v HAMMER -i "$ESP_IMG" ::
+"$MMD" -i "$ESP_IMG" ::/EFI ::/EFI/BOOT ::/EFI/takana
+"$MCOPY" -i "$ESP_IMG" "$KERNEL" ::/EFI/takana/takanax64.efi
+"$MCOPY" -i "$ESP_IMG" "$KERNEL" ::/EFI/BOOT/BOOTX64.EFI
+"$MCOPY" -i "$ESP_IMG" "$IRD" ::/initramfs.cpio.gz
# Verificar las DOS rutas: que una exista no dice nada de la otra, y el modo de fallo que esto
# previene es precisamente quedarse con una sola sin enterarse.
for _p in ::/EFI/takana/takanax64.efi ::/EFI/BOOT/BOOTX64.EFI ::/initramfs.cpio.gz; do
- "$MTOOLS/mdir" -i "$ESP_IMG" "$_p" >/dev/null 2>&1 || { echo "install-image-efi: falta $_p en la ESP" >&2; exit 1; }
+ "$MDIR" -i "$ESP_IMG" "$_p" >/dev/null 2>&1 || { echo "install-image-efi: falta $_p en la ESP" >&2; exit 1; }
done
echo " ESP poblada y verificada: las dos rutas del kernel + el initramfs"
@@ -292,7 +312,7 @@ ESP_GUID="C12A7328-F81F-11D2-BA4B-00A0C93EC93B" # GPT "EFI System Partition"
echo "==> imagen: $IMG (GPT UEFI; ESP ${ESP_MB}M + root ${ROOT_SIZE}M + store ${STORE_SIZE}M + estado ${STATE_SIZE}M)"
rm -f "$IMG"; truncate -s "$(( TOTAL_SECT * 512 ))" "$IMG"
-sfdisk --quiet "$IMG" >/dev/null <<EOF
+"$SFDISK" --quiet "$IMG" >/dev/null <<EOF
label: gpt
start=$START1, size=$ESP_SECT, type=$ESP_GUID, name="EFI System Partition"
start=$START2, size=$ROOT_SECT, type=linux, name="hammer-root"
@@ -303,9 +323,9 @@ EOF
# --- 5) poblar las 3 ext4 (root-owned vía unshare -r) y empalmar ESP + ext4 en sus offsets ----------
echo "==> mke2fs -d + empalme (ESP con mtools ya lista; ext4 bajo unshare -r)"
unshare -r sh -eu <<EOF
-mke2fs -q -t ext4 -L hammer-root -E root_owner=0:0 -d "$ROOT_TREE" "$ROOT_IMG" ${ROOT_SIZE}M
-mke2fs -q -t ext4 -L hammer-store -E root_owner=0:0 -d "$ROOTFS/store" "$STORE_IMG" ${STORE_SIZE}M
-mke2fs -q -t ext4 -L hammer-state -E root_owner=0:0 -d "$ROOTFS/var/lib/hammer" "$STATE_IMG" ${STATE_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-root -E root_owner=0:0 -d "$ROOT_TREE" "$ROOT_IMG" ${ROOT_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-store -E root_owner=0:0 -d "$ROOTFS/store" "$STORE_IMG" ${STORE_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-state -E root_owner=0:0 -d "$ROOTFS/var/lib/hammer" "$STATE_IMG" ${STATE_SIZE}M
EOF
dd if="$ESP_IMG" of="$IMG" bs=512 seek=$START1 conv=sparse,notrunc status=none
dd if="$ROOT_IMG" of="$IMG" bs=512 seek=$START2 conv=sparse,notrunc status=none
diff --git a/scripts/install-image.sh b/scripts/install-image.sh
index 7bb2307f..46a10fd4 100755
--- a/scripts/install-image.sh
+++ b/scripts/install-image.sh
@@ -57,8 +57,28 @@ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
[ -e "$ROOTFS/sbin/init" ] || [ -L "$ROOTFS/sbin/init" ] || { echo "ROOTFS sin /sbin/init (arje-zero)" >&2; exit 1; }
[ -n "$KERNEL" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel a embeber (set KERNEL=<bzImage>)" >&2; exit 1; }
[ -d "$GRUB_LIB" ] && [ -r "$GRUB_LIB/boot.img" ] || { echo "faltan los módulos GRUB i386-pc en $GRUB_LIB" >&2; exit 1; }
-for t in sfdisk mke2fs grub-mkimage grub-bios-setup; do
- command -v "$t" >/dev/null 2>&1 || { echo "falta $t" >&2; exit 1; }
+# ⚠ Las herramientas del ensamblado salen del STORE por HASH VIGENTE (2026-09-22). Medido: en este
+# hub, en su anfitrión y en el worker NO existe `grub-mkimage` ni `grub-bios-setup`, así que ESTA RAMA
+# no se podía correr en ninguna de las tres máquinas — y sólo se veía al intentarlo.
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
+echo "==> herramientas del ensamblado"
+SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
+MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1
+# GRUB es el ÚNICO que no puede salir del store: no hay `recipes/grub.toml`. Y no se puede retirar la
+# rama BIOS «porque ya tenemos EFI-stub»: **Hetzner Cloud arranca por BIOS** —medido en gioser, no
+# existe `/sys/firmware/efi` (SDD 28 §3.1)—, o sea que la imagen del servidor de producción es
+# justamente la que lo necesita. Mientras no haya receta, esta rama depende de un binario ajeno sin
+# hash y el aviso tiene que ser explícito, no un `falta grub-mkimage` que se lee como un olvido.
+for t in grub-mkimage grub-bios-setup; do
+ command -v "$t" >/dev/null 2>&1 || {
+ echo " ✗ falta $t, y NO hay receta de grub en el corpus." >&2
+ echo " Esta rama (GPT+BIOS) es la que necesita Hetzner Cloud, que arranca por BIOS." >&2
+ echo " Alternativas, ninguna gratis: instalar grub en la máquina que ensambla, o escribir" >&2
+ echo " la receta del gestor de arranque (grub, o limine que cubre BIOS y UEFI de una)." >&2
+ echo " Para un objetivo UEFI la rama soberana ya existe: scripts/install-image-efi.sh." >&2
+ exit 1
+ }
+ echo " ⚠ ${t} ← $(command -v "$t") DEL HOST, sin hash: el gestor de arranque no es nuestro" >&2
done
unshare -r true 2>/dev/null || { echo "se requiere 'unshare -r' (ficheros root-owned sin sudo)" >&2; exit 1; }
@@ -77,6 +97,7 @@ GRUB_STAGE="$STAGE/grub"
# --- árbol root: hardlinks del rootfs SIN store/estado + /boot (kernel + GRUB) + wrapper /sbin/init ---
echo "==> staging root: rootfs (hardlinks) + /boot/bzImage + /boot/grub + wrapper /sbin/init"
+sonda_hardlink "$ROOTFS" "$STAGE" || exit 1
cp -al "$ROOTFS" "$ROOT_TREE"
rm -rf "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer"
mkdir -p "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer" "$ROOT_TREE/boot/grub/i386-pc"
@@ -206,7 +227,7 @@ else
rm -f "$IMG"
truncate -s "$TOTAL_BYTES" "$IMG"
fi
-sfdisk --quiet "$IMG" >/dev/null <<EOF
+"$SFDISK" --quiet "$IMG" >/dev/null <<EOF
label: gpt
start=$START1, size=$BIOS_SECT, type=$BIOS_GUID, name="bios-boot"
start=$START2, size=$ESP_SECT, type=uefi, name="hammer-esp"
@@ -218,9 +239,9 @@ EOF
# --- poblar las 3 particiones ext4 (root-owned vía unshare -r) y empalmar en su offset ---
echo "==> mke2fs + empalme de las particiones ext4 (unshare -r, sin sudo)"
unshare -r sh -eu <<EOF
-mke2fs -q -t ext4 -L hammer-root -E root_owner=0:0 -d "$ROOT_TREE" "$ROOT_IMG" ${ROOT_SIZE}M
-mke2fs -q -t ext4 -L hammer-store -E root_owner=0:0 -d "$ROOTFS/store" "$STORE_IMG" ${STORE_SIZE}M
-mke2fs -q -t ext4 -L hammer-state -E root_owner=0:0 -d "$ROOTFS/var/lib/hammer" "$STATE_IMG" ${STATE_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-root -E root_owner=0:0 -d "$ROOT_TREE" "$ROOT_IMG" ${ROOT_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-store -E root_owner=0:0 -d "$ROOTFS/store" "$STORE_IMG" ${STORE_SIZE}M
+"$MKE2FS" -q -t ext4 -L hammer-state -E root_owner=0:0 -d "$ROOTFS/var/lib/hammer" "$STATE_IMG" ${STATE_SIZE}M
dd if="$ROOT_IMG" of="$IMG" bs=512 seek=$START3 conv=sparse,notrunc status=none
dd if="$STATE_IMG" of="$IMG" bs=512 seek=$START4 conv=sparse,notrunc status=none
dd if="$STORE_IMG" of="$IMG" bs=512 seek=$START5 conv=sparse,notrunc status=none
diff --git a/scripts/iso-image.sh b/scripts/iso-image.sh
index 3de3ecb0..0ebd701a 100755
--- a/scripts/iso-image.sh
+++ b/scripts/iso-image.sh
@@ -37,19 +37,27 @@ KERNEL="${KERNEL:-$(ls store/*-linux/boot/bzImage 2>/dev/null | head -1)}"
GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
GRUB_EFI_LIB="${GRUB_EFI_LIB:-/usr/lib/grub/x86_64-efi}"
CMDLINE="${CMDLINE:-console=ttyS0 rdinit=/init}"
-# xorriso: preferimos el construido por takana (dogfooding de recipes/xorriso.toml); fallback al host.
-XORRISO="${XORRISO:-$(ls store/*-xorriso/usr/bin/xorriso 2>/dev/null | head -1)}"
-[ -n "$XORRISO" ] && [ -x "$XORRISO" ] || XORRISO="$(command -v xorriso 2>/dev/null || true)"
-# mtools (mformat/mcopy): para poblar la ESP FAT del arranque EFI. Preferimos el de takana (dogfooding).
-MTOOLS_DIR="${MTOOLS_DIR:-$(ls -d store/*-mtools/usr/bin 2>/dev/null | head -1)}"
+# ⚠ Las herramientas salen del STORE por HASH VIGENTE, no por el primer glob ni por fecha
+# (2026-09-22): con dos artefactos del mismo paquete conviviendo, el más reciente puede ser el viejo.
+# Ver `scripts/lib/herramientas-desde-store.sh`.
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
[ -d "$ROOTFS" ] || { echo "no existe ROOTFS: $ROOTFS" >&2; exit 1; }
[ -e "$ROOTFS/sbin/init" ] || { echo "ROOTFS sin /sbin/init" >&2; exit 1; }
[ -n "$KERNEL" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel (set KERNEL=<bzImage>)" >&2; exit 1; }
-[ -n "$XORRISO" ] || { echo "no encuentro xorriso (build recipes/xorriso.toml)" >&2; exit 1; }
-[ -d "$GRUB_LIB" ] && [ -r "$GRUB_LIB/cdboot.img" ] || { echo "faltan módulos GRUB i386-pc en $GRUB_LIB" >&2; exit 1; }
-command -v grub-mkimage >/dev/null 2>&1 || { echo "falta grub-mkimage" >&2; exit 1; }
-command -v cpio >/dev/null 2>&1 || { echo "falta cpio" >&2; exit 1; }
+echo "==> herramientas del ensamblado"
+XORRISO="${XORRISO:-$(herramienta xorriso usr/bin/xorriso xorriso)}" || exit 1
+CPIO=$(herramienta libarchive usr/bin/bsdcpio cpio) || exit 1
+GZIP=$(herramienta gzip bin/gzip gzip) || exit 1
+MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1
+MCOPY=$(herramienta mtools usr/bin/mcopy mcopy) || exit 1
+MMD=$(herramienta mtools usr/bin/mmd mmd) || exit 1
+# GRUB es el único que NO puede salir del store: no hay `recipes/grub.toml`. El ISO arranca por El
+# Torito (BIOS), así que sin él no hay ISO. La rama UEFI de este mismo script y `metal-iso.sh
+# EFI_STUB=1` no lo necesitan — ahí el bzImage ES el binario EFI (ADR 0018).
+[ -d "$GRUB_LIB" ] && [ -r "$GRUB_LIB/cdboot.img" ] || { echo "faltan módulos GRUB i386-pc en $GRUB_LIB — y NO hay receta de grub en el corpus (ver scripts/vigia-ensamblado.sh)" >&2; exit 1; }
+command -v grub-mkimage >/dev/null 2>&1 || { echo "falta grub-mkimage, y NO hay receta de grub: el ISO BIOS depende de un binario del host sin hash" >&2; exit 1; }
+echo " ⚠ grub-mkimage ← $(command -v grub-mkimage) DEL HOST, sin hash: el gestor de arranque no es nuestro" >&2
echo "==> xorriso: $XORRISO ($("$XORRISO" -version 2>&1 | grep -m1 'xorriso version' || echo '?'))"
@@ -138,7 +146,7 @@ if [ "${INSTALLER:-0}" = 1 ]; then
fi
echo "==> empaquetando initramfs.cpio.gz"
-( cd "$RFS/root" && find . -print0 | cpio --null -o -H newc -R 0:0 2>/dev/null | gzip -1 ) > "$ISO_ROOT/boot/initramfs.cpio.gz"
+( cd "$RFS/root" && find . -print0 | "$CPIO" --null -o -H newc -R 0:0 2>/dev/null | "$GZIP" -1 ) > "$ISO_ROOT/boot/initramfs.cpio.gz"
# --- kernel + módulos GRUB + grub.cfg en el árbol del ISO ---
cp "$KERNEL" "$ISO_ROOT/boot/bzImage"; chmod 0644 "$ISO_ROOT/boot/bzImage"
@@ -167,7 +175,7 @@ grub-mkimage -O i386-pc-eltorito -p '/boot/grub' -o "$ISO_ROOT/boot/grub/i386-pc
# EFI_STUB; aquí GRUB EFI es el cargador (espeja la rama BIOS). xorriso añade un 2º El Torito (EFI). ---
EFI_ARGS=""
if [ "${EFI:-0}" = 1 ]; then
- MFORMAT="$MTOOLS_DIR/mformat"; MCOPY="$MTOOLS_DIR/mcopy"; MMD="$MTOOLS_DIR/mmd"
+ [ -n "${MTOOLS_DIR:-}" ] && { MFORMAT="$MTOOLS_DIR/mformat"; MCOPY="$MTOOLS_DIR/mcopy"; MMD="$MTOOLS_DIR/mmd"; }
[ -x "$MFORMAT" ] && [ -x "$MCOPY" ] || MFORMAT="$(command -v mformat 2>/dev/null || true)"
[ -n "$MFORMAT" ] && [ -x "$MFORMAT" ] || { echo "no encuentro mtools (build recipes/mtools.toml)" >&2; exit 1; }
[ -x "$MCOPY" ] || { MCOPY="$(command -v mcopy)"; MMD="$(command -v mmd)"; }
diff --git a/scripts/lib/herramientas-desde-store.sh b/scripts/lib/herramientas-desde-store.sh
new file mode 100644
index 00000000..5cbc3c2d
--- /dev/null
+++ b/scripts/lib/herramientas-desde-store.sh
@@ -0,0 +1,107 @@
+# herramientas-desde-store.sh — las herramientas que ENSAMBLAN una imagen salen del STORE, no del host.
+#
+# ══ POR QUÉ EXISTE (medido el 2026-09-22) ══════════════════════════════════════════════════════
+# Armar una imagen necesita `sfdisk mke2fs cpio gzip mformat mcopy xorriso` y, en la rama BIOS,
+# `grub-mkimage`/`grub-bios-setup`. Los scripts los tomaban del `PATH` del host — y **en este hub, en
+# su anfitrión y en el worker no existe NINGUNO de ellos**:
+#
+# grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE
+#
+# O sea que el pipeline de imagen estaba roto en las tres máquinas que tenemos, en silencio, hasta que
+# alguien lo corriera. Y lo absurdo es que **seis de las siete son nuestras**: `util-linux` (sfdisk),
+# `e2fsprogs` (mke2fs), `libarchive` (bsdcpio), `gzip`, `mtools`, `xorriso` y `dosfstools` están
+# SELLADAS en el store, son estáticas musl y corren tal cual en el host. Lo único que no tenemos es
+# el gestor de arranque de la rama BIOS (ver `vigia-ensamblado.sh`).
+#
+# ── EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ─────────────────────────────────────────────────
+# `iso-image.sh` resolvía `xorriso` con `ls store/*-xorriso/...|head -1` y `install-image-efi.sh`
+# mtools con `ls -dt ...|head -1`. Con dos artefactos del mismo paquete conviviendo —lo normal tras
+# cualquier re-hash— el más reciente puede ser el viejo: **la fecha miente; el hash no**. Es la misma
+# lección que `hydrate-profile.py` y `libs-compartidas-desde-store.sh` ya habían pagado.
+#
+# ── EL HOST ES CAÍDA, NO FUENTE ─────────────────────────────────────────────────────────────────
+# Si no está en el store se cae al host, pero **avisando**: un binario del host no tiene hash, así que
+# la imagen deja de ser reproducible por ese lado y eso tiene que verse en el log de la corrida, no
+# descubrirse después. Si no está en ninguno de los dos, se ABORTA con las dos rutas nombradas.
+#
+# Uso: . scripts/lib/herramientas-desde-store.sh
+# SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1
+# CPIO=$(herramienta libarchive usr/bin/bsdcpio cpio) || exit 1
+# Vars: STORE_ART (dónde buscar; por defecto se detecta) · TAKANA (binario; por defecto el del árbol)
+
+_herr_store() {
+ # Se detecta UNA vez y se dice cuál se eligió: en el hub el `./store` del clon está vacío y el
+ # store de verdad es `/store`, así que un default mudo manda a diagnosticar al lugar equivocado.
+ [ -n "${STORE_ART:-}" ] && { echo "$STORE_ART"; return; }
+ [ -n "${HAMMER_STORE:-}" ] && { echo "$HAMMER_STORE"; return; }
+ for c in store /store; do
+ if [ -d "$c" ] && [ -n "$(find "$c" -maxdepth 1 -name '*-*' -print -quit 2>/dev/null)" ]; then
+ echo "$c"; return
+ fi
+ done
+ echo store
+}
+
+_herr_takana() {
+ for c in "${TAKANA:-}" ./target/release/takana /usr/bin/takana; do
+ [ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return; }
+ done
+ command -v takana 2>/dev/null || echo ./target/release/takana
+}
+
+# herramienta <receta> <ruta-relativa-en-el-artefacto> [nombre-en-el-host]
+herramienta() {
+ _hr_receta="$1"; _hr_rel="$2"; _hr_host="${3:-}"
+ _hr_store="$(_herr_store)"; _hr_tk="$(_herr_takana)"
+ _hr_hash=$("$_hr_tk" --store "$_hr_store" hash "recipes/$_hr_receta.toml" 2>/dev/null \
+ | tail -1 | sed 's/^b3://')
+ if [ -n "$_hr_hash" ]; then
+ _hr_p="$_hr_store/$_hr_hash-$_hr_receta/$_hr_rel"
+ if [ -x "$_hr_p" ]; then
+ echo " · ${_hr_rel##*/} ← $_hr_receta $(echo "$_hr_hash" | cut -c1-12) (sellado)" >&2
+ echo "$_hr_p"; return 0
+ fi
+ fi
+ if [ -n "$_hr_host" ] && command -v "$_hr_host" >/dev/null 2>&1; then
+ echo " ⚠ ${_hr_rel##*/} ← $(command -v "$_hr_host") DEL HOST, sin hash: por acá la imagen no es reproducible" >&2
+ command -v "$_hr_host"; return 0
+ fi
+ echo " ✗ falta ${_hr_rel##*/}: ni sellado ($_hr_store/$_hr_hash-$_hr_receta/$_hr_rel) ni en el PATH ($_hr_host)" >&2
+ echo " construilo con: flock work/.farm-build.lock ./target/release/takana --store $_hr_store build recipes/$_hr_receta.toml" >&2
+ return 1
+}
+
+# ── sonda_hardlink <dir-origen> <dir-destino> ───────────────────────────────────────────────────
+# El staging de una imagen ENLAZA (hardlink) desde el rootfs, y `linkat()` no cruza un punto de
+# montaje aunque sea el mismo disco ([[hardlink-cruza-mount-exdev]]). Sin sonda, el fallo llega como
+# una PARED de `cp: Invalid cross-device link` fichero por fichero —cientos de líneas— y el diagnóstico
+# real («el STAGE está en otro montaje que el ROOTFS») no aparece en ninguna de ellas. Medido el
+# 2026-09-22 armando una imagen EFI con el rootfs en /store y el stage en /work.
+#
+# La sonda es FUNCIONAL —se intenta un enlace de verdad— y no una comparación de `st_dev`: en este
+# repo `store` y `work/out` son bind-mounts del MISMO dispositivo y `st_dev` coincide, así que
+# comparar números no dispara. Es la misma sonda que `hydrate-profile.py` ya tenía.
+sonda_hardlink() {
+ _sh_src="$1"; _sh_dst="$2"
+ mkdir -p "$_sh_dst" 2>/dev/null || true
+ _sh_a="$_sh_src/.sonda-hardlink.$$"; _sh_b="$_sh_dst/.sonda-hardlink.$$"
+ if ! : > "$_sh_a" 2>/dev/null; then
+ # origen de sólo lectura (un artefacto del store): se prueba con un fichero que YA está
+ _sh_a=$(find "$_sh_src" -maxdepth 2 -type f -print -quit 2>/dev/null)
+ [ -n "$_sh_a" ] || { echo "sonda_hardlink: $_sh_src no tiene ningún fichero con el que probar" >&2; return 1; }
+ _sh_limpiar=no
+ else
+ _sh_limpiar=si
+ fi
+ if ln "$_sh_a" "$_sh_b" 2>/dev/null; then
+ rm -f "$_sh_b"; [ "$_sh_limpiar" = si ] && rm -f "$_sh_a"
+ return 0
+ fi
+ [ "$_sh_limpiar" = si ] && rm -f "$_sh_a"
+ echo " ✗ EXDEV: no se puede ENLAZAR de '$_sh_src' a '$_sh_dst'." >&2
+ echo " El staging usa hardlinks y un enlace no cruza un montaje aunque sea el mismo disco." >&2
+ echo " origen está en: $(findmnt -n -T "$_sh_src" -o TARGET 2>/dev/null || echo '?')" >&2
+ echo " destino está en: $(findmnt -n -T "$_sh_dst" -o TARGET 2>/dev/null || echo '?')" >&2
+ echo " Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs." >&2
+ return 1
+}
diff --git a/scripts/metal-usb-sdboot.sh b/scripts/metal-usb-sdboot.sh
index d9a97c53..013c446f 100755
--- a/scripts/metal-usb-sdboot.sh
+++ b/scripts/metal-usb-sdboot.sh
@@ -25,8 +25,25 @@ KERNEL="${KERNEL:-$(ls -dt store/*-linux-metal/boot/bzImage 2>/dev/null | head -
[ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro kernel metal" >&2; exit 1; }
SDBOOT="${SDBOOT:-/home/sergio/.local/share/shuma/rootfs/arch/usr/lib/systemd/boot/efi/systemd-bootx64.efi}"
[ -r "$SDBOOT" ] || { echo "no encuentro systemd-bootx64.efi en $SDBOOT" >&2; exit 1; }
-MT=$(ls -d store/*-mtools/usr/bin 2>/dev/null | head -1)
-[ -n "$MT" ] && [ -x "$MT/mformat" ] || { echo "no encuentro mtools de hammer (build recipes/mtools.toml)" >&2; exit 1; }
+# Las herramientas salen del STORE por HASH VIGENTE (2026-09-22): `cpio` no existe en ninguna de las
+# tres máquinas del proyecto y mtools se resolvía por el primer glob, no por el hash de la receta de
+# hoy. Ver `scripts/lib/herramientas-desde-store.sh` y `scripts/vigia-ensamblado.sh`.
+. "$(dirname "$0")/lib/herramientas-desde-store.sh"
+MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1
+MCOPY=$(herramienta mtools usr/bin/mcopy mcopy) || exit 1
+MMD=$(herramienta mtools usr/bin/mmd mmd) || exit 1
+MDIR=$(herramienta mtools usr/bin/mdir mdir) || exit 1
+CPIO=$(herramienta libarchive usr/bin/bsdcpio cpio) || exit 1
+GZIP=$(herramienta gzip bin/gzip gzip) || exit 1
+if [ -n "${MT:-}" ]; then
+ MFORMAT="${MT}/mformat"; MCOPY="${MT}/mcopy"; MMD="${MT}/mmd"; MDIR="${MT}/mdir"
+ [ -x "$MFORMAT" ] || { echo "MT=$MT no tiene mformat" >&2; exit 1; }
+fi
+# ⚠ Y el GESTOR DE ARRANQUE de esta ruta tampoco es nuestro: `SDBOOT` apunta a un
+# `systemd-bootx64.efi` sacado de un rootfs de Arch en el home del usuario — un binario ajeno, sin
+# hash y fuera del store, en el camino de arranque del metal. La alternativa soberana ya existe y no
+# lleva gestor (`install-image-efi.sh`: el bzImage ES el binario EFI, ADR 0018). Anotado, no tocado:
+# esta ruta se usa para bringup con menú y cambiarla es su propia decisión.
IMG="${IMG:-work/hammer-metal-usb.img}"
# /dev/console=tty0 al FINAL ⇒ la getty de arje da shell en la PANTALLA. earlyprintk=efi para diagnóstico.
OPTIONS="${OPTIONS:-earlyprintk=efi,keep ignore_loglevel efi=novamap console=ttyS0,115200 console=tty0}"
@@ -50,7 +67,7 @@ chmod +x "$STG/init"
# 2) empaquetar initramfs.cpio.gz (root-owned, igual que iso-image.sh)
echo "==> empaquetando initramfs.cpio.gz"
-( cd "$STG" && find . -print0 | cpio --null -o -H newc -R 0:0 2>/dev/null | gzip -1 ) > "$WORK/initramfs.cpio.gz"
+( cd "$STG" && find . -print0 | "$CPIO" --null -o -H newc -R 0:0 2>/dev/null | "$GZIP" -1 ) > "$WORK/initramfs.cpio.gz"
IRD="$WORK/initramfs.cpio.gz"; echo " initramfs: $(du -h "$IRD" | cut -f1)"
# 3) ESP FAT32: BOOTX64.EFI=systemd-boot + loader.conf + entry + kernel + initrd
@@ -59,17 +76,17 @@ espkb=$(( ( $(stat -c%s "$KERNEL") + $(stat -c%s "$IRD") ) / 1024 + 16384 ))
# no la lee ("Not Found"). Piso de 48 MiB para que el FAT32 siempre sea válido.
[ "$espkb" -lt 49152 ] && espkb=49152
ESP="$WORK/esp.img"; rm -f "$ESP"; truncate -s "$(( espkb * 1024 ))" "$ESP"
-"$MT/mformat" -F -v HAMMER -i "$ESP" ::
-"$MT/mmd" -i "$ESP" ::/EFI ::/EFI/BOOT ::/loader ::/loader/entries
-"$MT/mcopy" -i "$ESP" "$SDBOOT" ::/EFI/BOOT/BOOTX64.EFI
+"$MFORMAT" -F -v HAMMER -i "$ESP" ::
+"$MMD" -i "$ESP" ::/EFI ::/EFI/BOOT ::/loader ::/loader/entries
+"$MCOPY" -i "$ESP" "$SDBOOT" ::/EFI/BOOT/BOOTX64.EFI
printf 'default hammer\ntimeout 8\neditor yes\n' > "$WORK/loader.conf"
printf 'title hammer (metal)\nlinux /bzImage\ninitrd /initramfs.cpio.gz\noptions %s\n' "$OPTIONS" > "$WORK/hammer.conf"
-"$MT/mcopy" -i "$ESP" "$WORK/loader.conf" ::/loader/loader.conf
-"$MT/mcopy" -i "$ESP" "$WORK/hammer.conf" ::/loader/entries/hammer.conf
-"$MT/mcopy" -i "$ESP" "$KERNEL" ::/bzImage
-"$MT/mcopy" -i "$ESP" "$IRD" ::/initramfs.cpio.gz
+"$MCOPY" -i "$ESP" "$WORK/loader.conf" ::/loader/loader.conf
+"$MCOPY" -i "$ESP" "$WORK/hammer.conf" ::/loader/entries/hammer.conf
+"$MCOPY" -i "$ESP" "$KERNEL" ::/bzImage
+"$MCOPY" -i "$ESP" "$IRD" ::/initramfs.cpio.gz
echo "==> ESP poblada ($(du -h "$ESP" | cut -f1)):"
-"$MT/mdir" -i "$ESP" ::/ ::/EFI/BOOT ::/loader/entries 2>/dev/null | grep -iE 'BOOTX64|bzImage|initram|hammer|loader' || true
+"$MDIR" -i "$ESP" ::/ ::/EFI/BOOT ::/loader/entries 2>/dev/null | grep -iE 'BOOTX64|bzImage|initram|hammer|loader' || true
# 4) imagen de disco GPT: GPT primaria (1MiB) + ESP (en sector 2048) + GPT backup
esp_sectors=$(( espkb * 2 )) # KiB→sectores de 512
@@ -83,7 +100,7 @@ DATA_LABEL="${DATA_LABEL:-HAMMERLOG}"
data_sectors=0
if [ "$DATA_MB" -gt 0 ]; then
DATA="$WORK/data.img"; rm -f "$DATA"; truncate -s "$(( DATA_MB * 1024 * 1024 ))" "$DATA"
- "$MT/mformat" -F -v "$DATA_LABEL" -i "$DATA" ::
+ "$MFORMAT" -F -v "$DATA_LABEL" -i "$DATA" ::
data_sectors=$(( DATA_MB * 2048 ))
echo "==> partición de datos FAT32 ${DATA_MB}M etiqueta=$DATA_LABEL (telemetría persistente)"
fi
diff --git a/scripts/vigia-ensamblado.sh b/scripts/vigia-ensamblado.sh
new file mode 100755
index 00000000..0983f8e4
--- /dev/null
+++ b/scripts/vigia-ensamblado.sh
@@ -0,0 +1,75 @@
+#!/bin/sh
+# vigia-ensamblado.sh — ¿de dónde sale cada herramienta que ARMA una imagen: del store o del host?
+#
+# ══ EL PUNTO CIEGO QUE CUBRE (medido el 2026-09-22) ═════════════════════════════════════════════
+# Los tres scripts de imagen tomaban sus herramientas del `PATH`, y en las TRES máquinas que tenemos
+# —este hub, su anfitrión y el worker `dev.gioser.net`— no existe ninguna de
+# `grub-mkimage grub-bios-setup cpio mformat xorriso`. O sea: **el pipeline de imagen estaba roto en
+# todas partes y sólo se veía al intentar correrlo**, con un `falta cpio` que se lee como un descuido
+# de la máquina y no como lo que era: la imagen dependiendo de software que nadie declaró.
+#
+# Lo absurdo del caso es que SEIS de las siete son nuestras y están selladas: `util-linux` (sfdisk),
+# `e2fsprogs` (mke2fs), `libarchive` (bsdcpio), `gzip`, `mtools`, `xorriso`. Todas estáticas musl, y
+# corren tal cual en el host — comprobado ejecutándolas, no supuesto.
+#
+# ⚠ **La séptima es el gestor de arranque, y no se puede resolver retirando la rama BIOS.** Hetzner
+# Cloud **arranca por BIOS** (medido en gioser: no existe `/sys/firmware/efi`, SDD 28 §3.1), así que
+# la imagen del servidor de producción es justamente la que necesita GRUB. Para objetivos UEFI la
+# rama soberana ya existe y no lleva gestor: el bzImage ES el binario EFI (ADR 0018).
+#
+# uso: scripts/vigia-ensamblado.sh [--fail]
+# --fail → exit 1 si alguna herramienta no sale del store (para CI)
+set -eu
+cd "$(dirname "$0")/.."
+. scripts/lib/herramientas-desde-store.sh
+
+FALLAR=0; [ "${1:-}" = "--fail" ] && FALLAR=1
+sellados=0; host=0; faltan=0
+
+mirar() { # mirar <receta> <ruta-rel> <nombre-host> <para qué>
+ if p=$(herramienta "$1" "$2" "$3" 2>/dev/null); then
+ case "$p" in
+ /store/*|store/*|*/store/*) printf ' ✓ %-12s %-22s %s\n' "${2##*/}" "$1" "sellado"; sellados=$((sellados+1)) ;;
+ *) printf ' ⚠ %-12s %-22s %s\n' "${2##*/}" "$1" "DEL HOST ($p) — sin hash"; host=$((host+1)) ;;
+ esac
+ else
+ printf ' ✗ %-12s %-22s %s\n' "${2##*/}" "$1" "NO ESTÁ: ni sellado ni en el host"; faltan=$((faltan+1))
+ fi
+}
+
+echo "== herramientas del ensamblado de imágenes (store: $(_herr_store))"
+mirar util-linux sbin/sfdisk sfdisk "particionar"
+mirar e2fsprogs sbin/mke2fs mke2fs "las ext4 con -d, sin root"
+mirar libarchive usr/bin/bsdcpio cpio "el initramfs newc"
+mirar gzip bin/gzip gzip "comprimir el initramfs"
+mirar mtools usr/bin/mformat mformat "poblar la ESP FAT sin montar"
+mirar mtools usr/bin/mcopy mcopy "idem"
+mirar xorriso usr/bin/xorriso xorriso "el ISO"
+mirar dosfstools sbin/mkfs.vfat mkfs.vfat "vfat, cuando hace falta"
+
+echo
+echo "== el gestor de arranque, que es el hueco de verdad"
+if [ -f recipes/grub.toml ]; then
+ echo " (hay recipes/grub.toml: actualizá este vigía)"
+else
+ printf ' ✗ %-12s %-22s %s\n' "grub" "(no hay receta)" "sólo del host"
+ for t in grub-mkimage grub-bios-setup; do
+ if command -v "$t" >/dev/null 2>&1; then
+ printf ' ⚠ %-18s %s DEL HOST, sin hash\n' "$t" "$(command -v "$t")"; host=$((host+1))
+ else
+ printf ' ✗ %-18s AUSENTE ⇒ la rama BIOS (install-image.sh, iso-image.sh) NO puede correr acá\n' "$t"
+ faltan=$((faltan+1))
+ fi
+ done
+ echo " Hetzner Cloud arranca por BIOS (medido, SDD 28 §3.1) ⇒ la rama no se puede retirar."
+ echo " Las dos salidas, ninguna gratis: receta de \`grub\`, o receta de \`limine\` (cubre BIOS"
+ echo " y UEFI de una). Para un objetivo UEFI la rama soberana ya está: install-image-efi.sh."
+fi
+
+echo
+echo "== resumen: $sellados sellada(s) · $host del host (sin hash) · $faltan ausente(s)"
+[ "$faltan" -gt 0 ] && echo " Una imagen que necesita algo ausente NO se puede armar en esta máquina."
+if [ "$FALLAR" = 1 ] && { [ "$faltan" -gt 0 ] || [ "$host" -gt 0 ]; }; then
+ exit 1
+fi
+exit 0