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, } 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, /// 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 <