Caché persistente de zig + bootstrap reproducible del .dev-fs
- `BuildConfig.cache_root` (override `HAMMER_CACHE`): bindea `/cache` al sandbox
y exporta `ZIG_GLOBAL_CACHE_DIR=/cache/zig`. Evita ~30s de recompilación de
musl en cada build.
- `scripts/bootstrap-devfs.sh`: idempotente, descarga+verifica alpine-minirootfs
3.23.4 y zig 0.16.0 con sha256 fijo, instala build tools en el rootfs vía apk
bajo bwrap.
- `make_tree_read_only`: solo archivos regulares pierden `w`; los directorios
conservan 0o755 para no estorbar GC ni `rm -rf` administrativo. La
inmutabilidad estricta del store se delega al mount RO de la distro propia.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
e53877a53209cf15e3cd682fa97b16048e6ffc62 · por Sergio · 2026-06-09T13:56:10Z (hace 4 meses) · padre 736e73e2e · ver el árbol en este commit
Delta semántico · 10
crates/hammer-build/src/config.rs
crates/hammer-build/src/lib.rs
cambió por dentro fn build pub
crates/hammer-build/src/sandbox.rs
crates/hammer-build/tests/end_to_end_autotools.rs
crates/hammer-build/tests/end_to_end_hello.rs
crates/hammer-core/src/store.rs
diff --git a/crates/hammer-build/src/config.rs b/crates/hammer-build/src/config.rs
index 17e25751..6cda2eb6 100644
--- a/crates/hammer-build/src/config.rs
+++ b/crates/hammer-build/src/config.rs
@@ -16,12 +16,15 @@ pub struct BuildConfig {
/// patch+compile, y `out` por build). Debe estar en el mismo filesystem que el store
/// para que el `seal` final sea un rename atómico.
pub work_root: PathBuf,
+ /// Directorio host persistente que se bindea como `/cache` en el sandbox; alberga la
+ /// caché global de `zig` (objetos de musl, libc, etc.) entre builds. `None` desactiva.
+ pub cache_root: Option<PathBuf>,
}
impl BuildConfig {
/// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
/// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
- /// work = `/…/work`.
+ /// work = `/…/work`, cache = `/…/.dev-fs/cache`.
pub fn defaults_for_store(store_root: &Path) -> Self {
let project_root = store_root
.parent()
@@ -31,10 +34,12 @@ impl BuildConfig {
rootfs: project_root.join(".dev-fs/alpine"),
zig_dir: project_root.join(".dev-fs/tools/zig"),
work_root: project_root.join("work"),
+ cache_root: Some(project_root.join(".dev-fs/cache")),
}
}
- /// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`).
+ /// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
+ /// `HAMMER_CACHE`). `HAMMER_CACHE=""` (vacío) desactiva la caché.
pub fn from_env_or_defaults(store_root: &Path) -> Self {
let mut cfg = Self::defaults_for_store(store_root);
if let Ok(v) = std::env::var("HAMMER_ROOTFS") {
@@ -46,6 +51,9 @@ impl BuildConfig {
if let Ok(v) = std::env::var("HAMMER_WORK") {
cfg.work_root = PathBuf::from(v);
}
+ if let Ok(v) = std::env::var("HAMMER_CACHE") {
+ cfg.cache_root = if v.is_empty() { None } else { Some(PathBuf::from(v)) };
+ }
cfg
}
}
diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index 3c1de99b..51933b90 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -55,11 +55,15 @@ pub fn build(
// monolítico que pide nuestra estrategia de hidratación primaria.
env.push(("LDFLAGS".into(), "-static".into()));
}
+ if let Some(cache) = &cfg.cache_root {
+ std::fs::create_dir_all(cache.join("zig"))?;
+ }
let sb = Sandbox {
rootfs: cfg.rootfs.clone(),
zig_dir: cfg.zig_dir.clone(),
src_dir: src_tree.clone(),
out_dir: out_dir.clone(),
+ cache_dir: cfg.cache_root.clone(),
env,
};
diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs
index 90fa9548..e96151c7 100644
--- a/crates/hammer-build/src/sandbox.rs
+++ b/crates/hammer-build/src/sandbox.rs
@@ -17,6 +17,9 @@ pub struct Sandbox {
pub zig_dir: PathBuf,
pub src_dir: PathBuf,
pub out_dir: PathBuf,
+ /// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`.
+ /// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s).
+ pub cache_dir: Option<PathBuf>,
/// Vars de entorno extra inyectadas además del set por defecto.
pub env: Vec<(String, String)>,
}
@@ -70,8 +73,14 @@ impl Sandbox {
"--die-with-parent".into(),
];
+ if let Some(cache) = &self.cache_dir {
+ args.push("--bind".into());
+ args.push(cache.display().to_string());
+ args.push("/cache".into());
+ }
+
// Entorno mínimo y predecible.
- let defaults: Vec<(&str, &str)> = vec![
+ let mut defaults: Vec<(&str, &str)> = vec![
("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
("HOME", "/tmp"),
("DESTDIR", "/out"),
@@ -82,6 +91,10 @@ impl Sandbox {
("LC_ALL", "C"),
("LANG", "C"),
];
+ if self.cache_dir.is_some() {
+ defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig"));
+ defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig"));
+ }
for (k, v) in defaults {
args.push("--setenv".into());
args.push(k.into());
diff --git a/crates/hammer-build/tests/end_to_end_autotools.rs b/crates/hammer-build/tests/end_to_end_autotools.rs
index 1380b14a..3c8bb14c 100644
--- a/crates/hammer-build/tests/end_to_end_autotools.rs
+++ b/crates/hammer-build/tests/end_to_end_autotools.rs
@@ -106,6 +106,7 @@ fn build_autotools_hello_end_to_end() {
rootfs: project.join(".dev-fs/alpine"),
zig_dir: project.join(".dev-fs/tools/zig"),
work_root: tempfile::tempdir().unwrap().keep(),
+ cache_root: Some(project.join(".dev-fs/cache")),
};
if skip_if_no_layout(&cfg) {
return;
diff --git a/crates/hammer-build/tests/end_to_end_hello.rs b/crates/hammer-build/tests/end_to_end_hello.rs
index aa718923..efe2f5b9 100644
--- a/crates/hammer-build/tests/end_to_end_hello.rs
+++ b/crates/hammer-build/tests/end_to_end_hello.rs
@@ -114,6 +114,9 @@ fn build_hello_end_to_end() {
rootfs: rootfs.clone(),
zig_dir: zig_dir.clone(),
work_root,
+ // Reusa la caché global del dev — la primera corrida tras `cargo clean` paga
+ // ~30s recompilando musl; las siguientes terminan en segundos.
+ cache_root: Some(project.join(".dev-fs/cache")),
};
if skip_if_no_layout(&cfg) {
return;
diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs
index 1f4f7e14..629b25e9 100644
--- a/crates/hammer-core/src/store.rs
+++ b/crates/hammer-core/src/store.rs
@@ -105,8 +105,11 @@ impl Store {
}
}
-/// Hace todo el árbol read-only. Archivos pierden `w` para todos los usuarios; directorios
-/// mantienen permisos suficientes para listarse y atravesarse, pero pierden `w`.
+/// 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`.
+/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
+/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
fn make_tree_read_only(root: &Path) -> crate::Result<()> {
use std::os::unix::fs::PermissionsExt;
@@ -114,18 +117,13 @@ fn make_tree_read_only(root: &Path) -> crate::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
let ft = meta.file_type();
if ft.is_symlink() {
- // No tocamos symlinks (chmod sigue el link en muchas plataformas; no merece la pena).
return Ok(());
}
if ft.is_dir() {
for entry in std::fs::read_dir(path)? {
walk(&entry?.path())?;
}
- // Quitar bits de escritura del directorio (lectura+ejec sí, para poder atravesar).
- let mut perms = meta.permissions();
- perms.set_mode(perms.mode() & !0o222);
- std::fs::set_permissions(path, perms)?;
- } else {
+ } else if ft.is_file() {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() & !0o222);
std::fs::set_permissions(path, perms)?;
diff --git a/scripts/bootstrap-devfs.sh b/scripts/bootstrap-devfs.sh
new file mode 100755
index 00000000..9494918b
--- /dev/null
+++ b/scripts/bootstrap-devfs.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+# bootstrap-devfs.sh — Prepara .dev-fs/ con todo lo que hammer-build necesita:
+# 1. Rootfs Alpine minirootfs como base hermética del sandbox.
+# 2. Compilador zig oficial bajo .dev-fs/tools/zig (symlink versionado).
+# 3. Build tools (make, autoconf, automake, m4, patch, coreutils, libtool,
+# pkgconf, bash) instaladas dentro del rootfs con apk.
+# 4. Directorios de caché y work para builds posteriores.
+#
+# Idempotente: cada paso se salta si ya hizo. Reinvocar tras un cambio de
+# versión actualiza in-place.
+#
+# Variables (override por entorno):
+# ALPINE_BRANCH rama de Alpine (default v3.23)
+# ALPINE_VER versión exacta del tarball (default 3.23.4)
+# ALPINE_SHA256 sha256 del tarball (verificado obligatorio)
+# ZIG_VER versión de zig (default 0.16.0)
+# ZIG_SHA256 sha256 del tarball zig
+#
+# Uso:
+# ./scripts/bootstrap-devfs.sh # bootstrap completo
+# ./scripts/bootstrap-devfs.sh --skip-apk # sólo rootfs + zig
+# ./scripts/bootstrap-devfs.sh --force-zig # re-descarga zig
+
+set -euo pipefail
+
+ALPINE_BRANCH="${ALPINE_BRANCH:-v3.23}"
+ALPINE_VER="${ALPINE_VER:-3.23.4}"
+ALPINE_SHA256="${ALPINE_SHA256:-85498865362aa7ebececa0d725a2f2e4db7ac4e4b2850b8df21645afa0d03ee3}"
+ZIG_VER="${ZIG_VER:-0.16.0}"
+ZIG_SHA256="${ZIG_SHA256:-70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00}"
+
+SKIP_APK=0
+FORCE_ZIG=0
+FORCE_ROOTFS=0
+for arg in "$@"; do
+ case "$arg" in
+ --skip-apk) SKIP_APK=1 ;;
+ --force-zig) FORCE_ZIG=1 ;;
+ --force-rootfs) FORCE_ROOTFS=1 ;;
+ -h|--help)
+ sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
+ exit 0 ;;
+ *)
+ echo "argumento desconocido: $arg" >&2
+ exit 1 ;;
+ esac
+done
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DEVFS="$REPO_ROOT/.dev-fs"
+ROOTFS="$DEVFS/alpine"
+TOOLS="$DEVFS/tools"
+ZIG_DIR="$TOOLS/zig-x86_64-linux-$ZIG_VER"
+ZIG_LINK="$TOOLS/zig"
+CACHE="$DEVFS/cache"
+WORK="$REPO_ROOT/work"
+
+log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
+
+mkdir -p "$DEVFS" "$TOOLS" "$CACHE" "$WORK"
+
+# --- 1. Rootfs Alpine ---------------------------------------------------------
+if [[ $FORCE_ROOTFS -eq 1 ]] && [[ -d "$ROOTFS" ]]; then
+ log "rootfs: --force-rootfs ⇒ borro $ROOTFS"
+ rm -rf "$ROOTFS"
+fi
+
+if [[ -f "$ROOTFS/bin/busybox" ]]; then
+ ok "rootfs Alpine ya presente en $ROOTFS"
+else
+ log "rootfs: descargando alpine-minirootfs $ALPINE_VER"
+ tar="$DEVFS/alpine-minirootfs-$ALPINE_VER.tar.gz"
+ url="https://dl-cdn.alpinelinux.org/alpine/$ALPINE_BRANCH/releases/x86_64/alpine-minirootfs-$ALPINE_VER-x86_64.tar.gz"
+ curl -fsSL "$url" -o "$tar"
+ echo "$ALPINE_SHA256 $tar" | sha256sum -c -
+ mkdir -p "$ROOTFS"
+ tar -xzf "$tar" -C "$ROOTFS"
+ rm -f "$tar"
+ ok "rootfs Alpine $ALPINE_VER extraído"
+fi
+
+# --- 2. Compilador zig --------------------------------------------------------
+if [[ $FORCE_ZIG -eq 1 ]] && [[ -e "$ZIG_DIR" ]]; then
+ log "zig: --force-zig ⇒ borro $ZIG_DIR"
+ rm -rf "$ZIG_DIR"
+fi
+
+if [[ -x "$ZIG_LINK/zig" ]]; then
+ ok "zig $ZIG_VER ya presente en $ZIG_LINK"
+else
+ log "zig: descargando $ZIG_VER"
+ tar="$TOOLS/zig.tar.xz"
+ url="https://ziglang.org/download/$ZIG_VER/zig-x86_64-linux-$ZIG_VER.tar.xz"
+ curl -fsSL "$url" -o "$tar"
+ echo "$ZIG_SHA256 $tar" | sha256sum -c -
+ tar -xJf "$tar" -C "$TOOLS"
+ rm -f "$tar"
+ ln -sfn "zig-x86_64-linux-$ZIG_VER" "$ZIG_LINK"
+ ok "zig $ZIG_VER instalado, symlink $ZIG_LINK"
+fi
+
+# --- 3. Build tools en el rootfs (apk add) ------------------------------------
+if [[ $SKIP_APK -eq 1 ]]; then
+ log "apk: --skip-apk ⇒ saltando build tools"
+else
+ NEEDED="make autoconf automake m4 patch coreutils libtool pkgconf bash"
+ missing=""
+ for pkg in $NEEDED; do
+ # apk info -e devuelve el paquete si está instalado, vacío si no.
+ if ! bwrap --bind "$ROOTFS" / --proc /proc --dev /dev --unshare-all \
+ --setenv PATH /usr/bin:/usr/sbin:/bin:/sbin \
+ /sbin/apk info -e "$pkg" >/dev/null 2>&1; then
+ missing="$missing $pkg"
+ fi
+ done
+ if [[ -n "$missing" ]]; then
+ log "apk: instalando paquetes faltantes:$missing"
+ bwrap --bind "$ROOTFS" / --proc /proc --dev /dev \
+ --ro-bind /etc/resolv.conf /etc/resolv.conf --share-net \
+ /sbin/apk update >/dev/null
+ bwrap --bind "$ROOTFS" / --proc /proc --dev /dev \
+ --ro-bind /etc/resolv.conf /etc/resolv.conf --share-net \
+ /sbin/apk add $missing
+ fi
+ ok "build tools presentes en el rootfs ($NEEDED)"
+fi
+
+# --- 4. Resumen ---------------------------------------------------------------
+cat <<EOF
+
+bootstrap-devfs.sh — OK
+ rootfs: $ROOTFS
+ zig: $ZIG_LINK ($("$ZIG_LINK/zig" version))
+ cache: $CACHE
+ work: $WORK
+
+Variables que la CLI ya recoge por defecto (override con env vars):
+ HAMMER_ROOTFS = $ROOTFS
+ HAMMER_ZIG = $ZIG_LINK
+ HAMMER_CACHE = $CACHE # vacío ("") desactiva la caché
+ HAMMER_WORK = $WORK
+EOF