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
` 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 ` 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= 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= 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; }
-echo "==> rootfs : $ROOTFS ($(du -sh "$ROOTFS" | cut -f1))"
-echo "==> imagen : $IMG ($SIZE, ext4, label hammer-root)"
+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"
+
+# --- 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 < mke2fs + empalme de las 3 particiones (unshare -r, sin sudo)"
+unshare -r sh -eu < 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=" >&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")