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>
This commit is contained in:
2026-06-20 13:05:35 -04:00
co-authored by Claude Opus 4.8
parent 462e0275d3
commit 4dfb0ff127
4 changed files with 203 additions and 52 deletions
+88 -12
View File
@@ -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();
+7 -2
View File
@@ -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)
+98 -31
View File
@@ -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; }
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 <<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
+10 -7
View File
@@ -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")