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>
This commit is contained in:
Sergio
2026-06-09 13:56:10 +00:00
co-authored by Claude Opus 4.7
parent 736e73e2ef
commit e53877a532
7 changed files with 181 additions and 11 deletions
+10 -2
View File
@@ -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
}
}
+4
View File
@@ -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,
};
+14 -1
View File
@@ -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());
@@ -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;
@@ -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;
+6 -8
View File
@@ -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)?;
+143
View File
@@ -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