gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

Etapa B3: /store y /var/lib/hammer en particiones dedicadas (GPT)

scripts/disk-image.sh ahora arma una imagen GPT de 3 particiones ext4 en vez
de una sola: vda1=/ , vda2=/store (CAS inmutable), vda3=/var/lib/hammer (estado
mutable). Construcción sin root ni loopback: cp -al stagea el rootfs por
hardlinks (vacía store/ y var/lib/hammer/, instala el wrapper /sbin/init),
mke2fs -d puebla cada ext4 bajo unshare -r (root-owned), sfdisk escribe la GPT
y dd conv=sparse,notrunc empalma cada fs en su offset (imagen sparse, ~2G
reales). El wrapper /sbin/init monta vda2/vda3 y hace exec de arje-zero (el
kernel sólo monta vda1). drive-rebuild.py: root=/dev/vda1 en modo DISK.

Consecuencia resuelta: con /store en su propia partición el sellado cruza
filesystems y rename(2) da EXDEV. Store::seal cae a copia recursiva a un
staging dentro del store (preserva symlinks+modos) + rename store-interno
(atómico, mismo FS) + borrado del origen. Test copy_tree añadido.

Verificado in-VM (kernel hammer, KVM): vda{1,2,3} montados dedicados, 0
errores Cross-device, stage1' == stage1 ✓ REPRODUCIBLE. Cierra el ☐ de
SDD 11 §6 (particionado/montaje en la imagen destino).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4dfb0ff12796e164f20289581e13c0b147edac28 · por sergio · (hace 4 meses) · padre 462e0275d · ver el árbol en este commit

Delta semántico · 4

crates/hammer-core/src/store.rs

4 archivos · +203 −52

diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs
index 71908511..3290bdfa 100644
--- a/crates/hammer-core/src/store.rs
+++ b/crates/hammer-core/src/store.rs
@@ -102,12 +102,14 @@ impl Store {
     /// Sella el árbol de salida de un build en el store bajo su hash.
     ///
     /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
-    /// - Si no, mueve `out_dir` por rename atómico al destino y marca el árbol read-only
-    ///   (todos los archivos pierden `w`; directorios mantienen `x` para poder atravesarlos).
+    /// - Si no, mueve `out_dir` al destino y marca el árbol read-only (todos los archivos
+    ///   pierden `w`; directorios mantienen `x` para poder atravesarlos).
     ///
-    /// Requisito: `out_dir` y el store deben estar en el mismo filesystem para que el rename
-    /// sea atómico. El lab garantiza esto colocando el tmpfs/DESTDIR del sandbox bajo el
-    /// mismo punto de montaje que el store, o haciendo una copia previa si no.
+    /// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem.
+    /// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)`
+    /// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename
+    /// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo
+    /// atómica (nadie ve el `dst` a medio escribir).
     pub fn seal(
         &self,
         out_dir: &Path,
@@ -121,18 +123,68 @@ impl Store {
         if let Some(parent) = dst.parent() {
             std::fs::create_dir_all(parent)?;
         }
-        std::fs::rename(out_dir, &dst).map_err(|e| {
-            crate::Error::Store(format!(
-                "rename {} → {}: {e}",
-                out_dir.display(),
-                dst.display()
-            ))
-        })?;
+        match std::fs::rename(out_dir, &dst) {
+            Ok(()) => {}
+            // EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos.
+            Err(e) if e.raw_os_error() == Some(EXDEV) => {
+                let parent = dst.parent().unwrap_or_else(|| Path::new("."));
+                let fname = dst.file_name().expect("dst con nombre");
+                let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy()));
+                let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo
+                copy_tree(out_dir, &staging).map_err(|e| {
+                    crate::Error::Store(format!(
+                        "copia cross-device {} → {}: {e}",
+                        out_dir.display(),
+                        staging.display()
+                    ))
+                })?;
+                std::fs::rename(&staging, &dst).map_err(|e| {
+                    let _ = std::fs::remove_dir_all(&staging);
+                    crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display()))
+                })?;
+                std::fs::remove_dir_all(out_dir).map_err(|e| {
+                    crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display()))
+                })?;
+            }
+            Err(e) => {
+                return Err(crate::Error::Store(format!(
+                    "rename {} → {}: {e}",
+                    out_dir.display(),
+                    dst.display()
+                )))
+            }
+        }
         make_tree_read_only(&dst)?;
         Ok(dst)
     }
 }
 
+/// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante.
+const EXDEV: i32 = 18;
+
+/// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y
+/// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`.
+fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
+    let meta = std::fs::symlink_metadata(src)?;
+    let ft = meta.file_type();
+    if ft.is_symlink() {
+        let target = std::fs::read_link(src)?;
+        std::os::unix::fs::symlink(target, dst)?;
+    } else if ft.is_dir() {
+        std::fs::create_dir_all(dst)?;
+        for entry in std::fs::read_dir(src)? {
+            let entry = entry?;
+            copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
+        }
+        // Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras).
+        std::fs::set_permissions(dst, meta.permissions())?;
+    } else {
+        // Archivo regular: `std::fs::copy` lleva contenido + bits de permiso.
+        std::fs::copy(src, dst)?;
+    }
+    Ok(())
+}
+
 /// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
 /// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
 /// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
@@ -196,6 +248,30 @@ mod tests {
         assert!(store.has(&hash(), "demo"));
     }
 
+    #[test]
+    fn copy_tree_preserva_symlinks_y_modos() {
+        use std::os::unix::fs::PermissionsExt;
+        let tmp = tempfile::tempdir().unwrap();
+        let src = tmp.path().join("src");
+        std::fs::create_dir_all(src.join("sub")).unwrap();
+        std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap();
+        std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755))
+            .unwrap();
+        std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap();
+
+        let dst = tmp.path().join("dst");
+        copy_tree(&src, &dst).unwrap();
+
+        // El symlink se replica con su target literal (no se resuelve).
+        let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap();
+        assert!(link.file_type().is_symlink());
+        assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe"));
+        // El modo 0o755 del archivo sobrevive.
+        let exe = std::fs::metadata(dst.join("sub/exe")).unwrap();
+        assert_eq!(exe.permissions().mode() & 0o777, 0o755);
+        assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n");
+    }
+
     #[test]
     fn find_by_hash_resolves_by_prefix() {
         let store_dir = tempfile::tempdir().unwrap();
diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md
index eb70d6da..4b52a5ae 100644
--- a/docs/11-bootstrap.md
+++ b/docs/11-bootstrap.md
@@ -182,8 +182,13 @@ hammer bootstrap manifest             # imprime el bootstrap.json (log de transp
   como receta git pinned en un lote posterior. ✅
 - **Layout de la semilla:** ingesta pura en Stage 0 + resolución del toolchain al usarlo
   (`SeedSpec::toolchain_dir`, localiza `zig` en raíz o hijo versionado). ✅
-- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino: track posterior
-  del roadmap. ☐
+- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino: ✅ (Etapa B3).
+  `scripts/disk-image.sh` arma una imagen GPT con 3 particiones ext4 dedicadas —
+  `/dev/vda1`→`/`, `/dev/vda2`→`/store` (CAS inmutable), `/dev/vda3`→`/var/lib/hammer` (estado
+  mutable). El kernel monta vda1; un wrapper `/sbin/init` monta vda2/vda3 y hace `exec` del init
+  real (arje-zero). Consecuencia que hubo que resolver: con `/store` en su propia partición, el
+  sellado del store cruza filesystems y `rename(2)` da EXDEV ⇒ `Store::seal` cae a copia-a-staging
+  dentro del store + rename store-interno (atómico). Verificado in-VM: rebuild ✓ REPRODUCIBLE.
 - **Kernel:** importado pinned ahora; from-source después. ☐
 
 ## 7. Auto-alojamiento: el builder rootfs (camino a Stage 2 pleno)
diff --git a/scripts/disk-image.sh b/scripts/disk-image.sh
index 57e167b1..def50b0f 100755
--- a/scripts/disk-image.sh
+++ b/scripts/disk-image.sh
@@ -1,53 +1,120 @@
 #!/bin/sh
-# disk-image.sh — Etapa B (imagen en disco real): empaqueta un rootfs como imagen ext4 booteable de
-# disco virtio (/dev/vda) en vez de initramfs. Con un kernel que trae VIRTIO_BLK+EXT4 built-in
-# (recipes/linux.toml), QEMU monta la imagen como root REAL y el kernel arranca con
-# `root=/dev/vda rw rdinit=/sbin/init` — sin initramfs, sin el wrapper /init+switch_root: `/` ya es un
-# mount ext4 pivotable (el muro pivot_root del path initramfs desaparece por construcción).
+# disk-image.sh — Etapa B3 (mounts dedicados): empaqueta el rootfs como una imagen de disco virtio
+# PARTICIONADA (GPT) con tres filesystems ext4 dedicados, en vez de una única partición que mete todo
+# junto (B2). Es el paso hacia la imagen instalable real: el store y el estado mutable dejan de ser meros
+# directorios del rootfs y pasan a ser particiones propias, como en un sistema instalado de verdad.
 #
-# `mke2fs -d <dir>` puebla la imagen DESDE un directorio sin montar nada y sin root (e2fsprogs >= 1.43).
+#   /dev/vda1  →  /                  rootfs (runtime + toolchain + /work del rebuild)   [hammer-root]
+#   /dev/vda2  →  /store             artefactos CAS BLAKE3 (inmutables)                 [hammer-store]
+#   /dev/vda3  →  /var/lib/hammer    estado mutable: journal + overlays (upper/work)    [hammer-state]
+#
+# El kernel (recipes/linux.toml) trae VIRTIO_BLK+EXT4+EFI_PARTITION(=y por defconfig) ⇒ lee la GPT y
+# monta /dev/vda1 como root sin initramfs. Las otras dos particiones las monta un wrapper `/sbin/init`
+# (montar /store + /var/lib/hammer, luego `exec /usr/bin/arje-zero`) — el init real (arje-zero) sigue
+# en su sitio; el wrapper es el puente mientras arje no lea fstab/cards de montaje por sí mismo.
+#
+# Construcción sin root ni loopback:
+#   - `cp -al` arma un árbol del rootfs por HARDLINKS (sin copiar datos), le vacía store/ y
+#     var/lib/hammer/ (deja mountpoints vacíos) y le instala el wrapper /sbin/init. NUNCA muta $ROOTFS.
+#   - `mke2fs -d <dir>` puebla cada ext4 desde un directorio sin montar nada (e2fsprogs >= 1.43).
+#   - `unshare -r` mapea nuestro uid→0 ⇒ los ficheros uid-host se sellan como uid 0 en la imagen (igual
+#     que el `--owner=root:root` del cpio; el copy-up de overlay del rebuild falla EACCES si van uid 1000).
+#   - `sfdisk` escribe la GPT sobre el fichero-imagen (no necesita root) y `dd conv=notrunc` empalma cada
+#     ext4 en el offset de su partición. Tamaños exactos: la partición mide lo mismo que el ext4 (M==MiB).
 #
 # Uso:
-#   ./scripts/disk-image.sh                 # crea work/hammer-disk.img desde work/builder-rootfs
-#   BOOT=1 KERNEL=<bzImage> KVM=1 ./scripts/disk-image.sh   # además bootea y corre rebuild-stage1
+#   ./scripts/disk-image.sh                                   # crea work/hammer-disk.img
+#   BOOT=1 KERNEL=<bzImage> KVM=1 ./scripts/disk-image.sh     # además bootea y corre rebuild-stage1
 #
 # Variables:
-#   ROOTFS  dir rootfs a empaquetar      (default work/builder-rootfs, lo arma selfhost-verify)
-#   IMG     imagen de salida             (default work/hammer-disk.img)
-#   SIZE    tamaño de la imagen          (default 6G; el rootfs ronda ~2G + holgura para el rebuild)
-#   BOOT    1 ⇒ bootea con drive-rebuild.py tras crear la imagen
-#   KERNEL  bzImage con VIRTIO_BLK+EXT4  (requerido si BOOT=1)
-#   KVM/MEM/NET  passthrough a drive-rebuild.py
+#   ROOTFS      dir rootfs a empaquetar     (default work/builder-rootfs, lo arma selfhost-verify)
+#   IMG         imagen de salida            (default work/hammer-disk.img)
+#   ROOT_SIZE   MiB de la partición /        (default 6144; incluye /work del rebuild)
+#   STORE_SIZE  MiB de la partición /store   (default 2048; artefactos CAS ~0.4G + reseal del rebuild)
+#   STATE_SIZE  MiB de /var/lib/hammer       (default 2048; journal + overlays)
+#   BOOT        1 ⇒ bootea con drive-rebuild.py tras crear la imagen
+#   KERNEL      bzImage con VIRTIO_BLK+EXT4  (requerido si BOOT=1)
+#   KVM/MEM/NET passthrough a drive-rebuild.py
 set -eu
 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
 cd "$ROOT"
 
 ROOTFS="${ROOTFS:-work/builder-rootfs}"
 IMG="${IMG:-work/hammer-disk.img}"
-SIZE="${SIZE:-6G}"
+ROOT_SIZE="${ROOT_SIZE:-6144}"
+STORE_SIZE="${STORE_SIZE:-2048}"
+STATE_SIZE="${STATE_SIZE:-2048}"
 
 [ -d "$ROOTFS" ] || { echo "no existe ROOTFS: $ROOTFS (corré selfhost-verify para armar el builder)" >&2; exit 1; }
-[ -x "$ROOTFS/sbin/init" ] || [ -L "$ROOTFS/sbin/init" ] || { echo "ROOTFS sin /sbin/init (arje-zero)" >&2; exit 1; }
+[ -e "$ROOTFS/sbin/init" ] || { echo "ROOTFS sin /sbin/init (arje-zero)" >&2; exit 1; }
+command -v sfdisk >/dev/null 2>&1 || { echo "falta sfdisk (util-linux) para la tabla GPT" >&2; exit 1; }
+command -v mke2fs >/dev/null 2>&1 || { echo "falta mke2fs (e2fsprogs)" >&2; exit 1; }
+unshare -r true 2>/dev/null || { echo "se requiere 'unshare -r' (ficheros root-owned sin sudo)" >&2; exit 1; }
+
+STAGE="work/.disk-stage"
+rm -rf "$STAGE"
+mkdir -p "$STAGE"
+ROOT_TREE="$STAGE/root"
+ROOT_IMG="$STAGE/root.img"
+STORE_IMG="$STAGE/store.img"
+STATE_IMG="$STAGE/state.img"
+
+# --- árbol del rootfs SIN store/estado, con el wrapper /sbin/init (hardlinks, no copia datos) ---
+echo "==> staging root (hardlinks) sin /store ni /var/lib/hammer + wrapper /sbin/init"
+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"   # mountpoints vacíos
+rm -f "$ROOT_TREE/sbin/init"                               # era symlink → /usr/bin/arje-zero
+cat > "$ROOT_TREE/sbin/init" <<'INIT'
+#!/bin/sh
+# Wrapper PID1 (Etapa B3): monta los filesystems dedicados antes del init real. El kernel arranca con
+# root=/dev/vda1; /store (CAS inmutable) y /var/lib/hammer (estado mutable) son particiones propias que
+# el kernel NO monta solo. DEVTMPFS_MOUNT=y deja /dev poblado, así que /dev/vda{2,3} ya existen aquí.
+/bin/busybox mount -t ext4 /dev/vda2 /store          || echo "init: no pude montar /store (/dev/vda2)"
+/bin/busybox mount -t ext4 /dev/vda3 /var/lib/hammer || echo "init: no pude montar /var/lib/hammer (/dev/vda3)"
+exec /usr/bin/arje-zero
+INIT
+chmod +x "$ROOT_TREE/sbin/init"
 
-echo "==> rootfs : $ROOTFS ($(du -sh "$ROOTFS" | cut -f1))"
-echo "==> imagen : $IMG ($SIZE, ext4, label hammer-root)"
+# --- tabla de particiones GPT (sectores de 512 B; 1 MiB = 2048 sectores, alineación por defecto) ---
+SECT_MIB=2048
+ROOT_SECT=$(( ROOT_SIZE  * SECT_MIB ))
+STORE_SECT=$(( STORE_SIZE * SECT_MIB ))
+STATE_SECT=$(( STATE_SIZE * SECT_MIB ))
+START1=2048
+START2=$(( START1 + ROOT_SECT ))
+START3=$(( START2 + STORE_SECT ))
+TOTAL_SECT=$(( START3 + STATE_SECT + 2048 ))   # + 1 MiB de slack para la GPT de respaldo
+
+echo "==> imagen : $IMG (GPT, root ${ROOT_SIZE}M + store ${STORE_SIZE}M + estado ${STATE_SIZE}M)"
 rm -f "$IMG"
-# Ownership root en la imagen (igual que el `--owner=root:root` del cpio): el rebuild usa bwrap+overlay
-# con un userns que mapea 0→0; si los ficheros van con el uid del host (1000) el copy-up falla EACCES.
-# `unshare -r` mapea nuestro uid→0, así los ficheros uid-1000 se ven como root y mke2fs escribe uid 0
-# en la imagen — sin sudo. mke2fs -d puebla desde el dir sin montar. Fallback a mke2fs directo si no hay
-# unshare (la imagen quedaría uid-host: bootea y da shell, pero el rebuild fallaría en el copy-up).
-if command -v unshare >/dev/null 2>&1 && unshare -r true 2>/dev/null; then
-    unshare -r mke2fs -q -t ext4 -L hammer-root -d "$ROOTFS" "$IMG" "$SIZE"
-else
-    echo "   (sin unshare -r: imagen con uid del host; el rebuild in-VM podría fallar el copy-up)" >&2
-    mke2fs -q -t ext4 -L hammer-root -d "$ROOTFS" "$IMG" "$SIZE"
-fi
-echo "==> imagen creada: $(du -h "$IMG" | cut -f1)"
+truncate -s $(( TOTAL_SECT * 512 )) "$IMG"
+sfdisk --quiet "$IMG" >/dev/null <<EOF
+label: gpt
+start=$START1, size=$ROOT_SECT,  type=linux, name="hammer-root"
+start=$START2, size=$STORE_SECT, type=linux, name="hammer-store"
+start=$START3, size=$STATE_SECT, type=linux, name="hammer-state"
+EOF
+
+# --- poblar cada ext4 (root-owned vía unshare -r) y empalmar en su offset ---
+echo "==> mke2fs + empalme de las 3 particiones (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
+# conv=sparse,notrunc: empalma sin destruir la GPT y deja los bloques cero del ext4 como huecos (si no,
+# dd rellena la imagen entera y la sparse del truncate se pierde → 11G reales en vez de ~2G).
+dd if="$ROOT_IMG"  of="$IMG" bs=512 seek=$START1 conv=sparse,notrunc status=none
+dd if="$STORE_IMG" of="$IMG" bs=512 seek=$START2 conv=sparse,notrunc status=none
+dd if="$STATE_IMG" of="$IMG" bs=512 seek=$START3 conv=sparse,notrunc status=none
+EOF
+
+rm -rf "$STAGE"
+echo "==> imagen creada: $(du -h "$IMG" | cut -f1) en disco (sparse), $(( TOTAL_SECT * 512 / 1024 / 1024 ))M virtuales"
 
 if [ "${BOOT:-0}" = 1 ]; then
     [ -n "${KERNEL:-}" ] || { echo "BOOT=1 requiere KERNEL=<bzImage con VIRTIO_BLK+EXT4>" >&2; exit 1; }
-    echo "==> booteando de disco (root=/dev/vda, sin initramfs ni switch_root)"
+    echo "==> booteando de disco particionado (root=/dev/vda1, /store+/var/lib/hammer montados por /sbin/init)"
     DISK="$IMG" KERNEL="$KERNEL" KVM="${KVM:-0}" MEM="${MEM:-6144}" NET="${NET:-1}" \
         python3 scripts/drive-rebuild.py
 fi
diff --git a/scripts/drive-rebuild.py b/scripts/drive-rebuild.py
index 228975cf..0322985f 100755
--- a/scripts/drive-rebuild.py
+++ b/scripts/drive-rebuild.py
@@ -8,10 +8,12 @@
 #
 # Variables de entorno (todas opcionales):
 #   BUILDER_CPIO  initramfs del builder        (default work/builder.cpio.gz)
-#   DISK          imagen ext4 a bootear como ROOT real (Etapa B). Si está seteada, se ignora el
-#                 initramfs: QEMU monta la imagen como disco virtio (/dev/vda) y el kernel arranca con
-#                 `root=/dev/vda rw rdinit=/sbin/init` — sin initramfs ni switch_root (/ ya es un mount
-#                 ext4 real, pivotable). Requiere un kernel con VIRTIO_BLK+EXT4 (recipes/linux.toml).
+#   DISK          imagen de disco PARTICIONADA (GPT) a bootear como ROOT real (Etapa B3). Si está
+#                 seteada, se ignora el initramfs: QEMU monta la imagen como disco virtio y el kernel
+#                 arranca con `root=/dev/vda1 rw rdinit=/sbin/init` — sin initramfs ni switch_root (/ ya
+#                 es un mount ext4 real, pivotable). La partición 1 es /, y el wrapper /sbin/init monta
+#                 las particiones dedicadas /store (vda2) y /var/lib/hammer (vda3) antes del init real.
+#                 La arma scripts/disk-image.sh. Requiere kernel con VIRTIO_BLK+EXT4 (recipes/linux.toml).
 #   KERNEL        bzImage/vmlinuz a bootear     (default /boot/vmlinuz-linux)
 #   MEM           RAM de la VM en MiB           (default 6144)
 #   CPU           modelo de CPU TCG             (default Broadwell; AVX2 sin KVM)
@@ -62,10 +64,11 @@ net = ["-netdev", "user,id=n0", "-device", "e1000,netdev=n0"] if WANT_NET else [
 # (p.ej. el frente kernel-from-source: el bzImage hammer bootea pero bwrap falla en pivot_root porque /
 # es el rootfs absoluto del namespace; `rootfstype=tmpfs` NO lo arregla — la causa es que / no tiene
 # mount padre, no el tipo de fs). Override con APPEND="…".
-# Boot de disco (Etapa B) vs initramfs. En disco / ya es un mount ext4 real ⇒ rdinit=/sbin/init directo
-# (arje-zero PID1), sin el wrapper switch_root del path initramfs.
+# Boot de disco (Etapa B3) vs initramfs. En disco / ya es un mount ext4 real ⇒ rdinit=/sbin/init directo,
+# sin el wrapper switch_root del path initramfs. La GPT tiene 3 particiones: vda1=/ , vda2=/store ,
+# vda3=/var/lib/hammer; el kernel monta vda1 y el wrapper /sbin/init monta las otras dos.
 if DISK:
-    APPEND = os.environ.get("APPEND", "console=ttyS0 root=/dev/vda rw rdinit=/sbin/init")
+    APPEND = os.environ.get("APPEND", "console=ttyS0 root=/dev/vda1 rw rdinit=/sbin/init")
     media = ["-drive", f"file={DISK},if=virtio,format=raw", "-append", APPEND]
 else:
     APPEND = os.environ.get("APPEND", "console=ttyS0 rdinit=/sbin/init")