From 4dfb0ff12796e164f20289581e13c0b147edac28 Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 13:05:35 -0400 Subject: [PATCH] Etapa B3: /store y /var/lib/hammer en particiones dedicadas (GPT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/hammer-core/src/store.rs | 100 ++++++++++++++++++++++--- docs/11-bootstrap.md | 9 ++- scripts/disk-image.sh | 129 ++++++++++++++++++++++++-------- scripts/drive-rebuild.py | 17 +++-- 4 files changed, 203 insertions(+), 52 deletions(-) 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")