From 989e0d2c7f5c0e44b358b0985c61da84cfef06dd Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 11 Jun 2026 00:28:29 +0000 Subject: [PATCH] bootstrap: seed como toolchain del sandbox (Lote 3) + decisiones ADR 0008 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeedSpec::toolchain_dir resuelve el dir del compilador dentro de la semilla sellada, listo para que el sandbox lo bindee como /opt/zig; seed_build_config deriva una BuildConfig que cross-compila con el seed pinned, no con el zig del host. Stage 0 sigue siendo ingesta pura: el layout se resuelve al usarlo (zig en la raíz o en el hijo versionado), con error claro si falta o es ambiguo. Decisiones del Lote 4 registradas en ADR 0008 / SDD 11: - coreutils+shell = busybox (balsa desechable; GPLv2 no contamina el final) - init arje diferido (Stage 1 con init mínimo provisional; CRASHED real luego) - layout de semilla: ingesta pura + resolución al usar +5 tests (layouts: raíz / hijo versionado / no-sellada / ambiguo / build_config). Workspace verde. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-bootstrap/src/lib.rs | 144 ++++++++++++++++++++++++++++- docs/11-bootstrap.md | 20 ++-- docs/adr/0008-bootstrap-stages.md | 16 +++- 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index a2064b68..d3f8b994 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -14,7 +14,7 @@ pub mod manifest; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use hammer_build::download; @@ -83,6 +83,70 @@ impl SeedSpec { pub fn store_name(&self) -> String { format!("seed-{}", self.kind.as_str()) } + + /// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo + /// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin + /// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable + /// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico + /// top-level versionado `zig-linux-x86_64-/`—. Falla si la semilla no está sellada o si + /// el ejecutable no aparece (o es ambiguo). + pub fn toolchain_dir(&self, store: &Store) -> Result { + let base = store.path_of(&self.seed_hash(), &self.store_name()); + if !base.is_dir() { + return Err(Error::Other(format!( + "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", + self.kind.as_str(), + base.display() + ))); + } + match self.kind { + SeedKind::Zig => locate_tool_dir(&base, "zig"), + SeedKind::MuslCrossMake => Err(Error::Other( + "toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(), + )), + } + } +} + +/// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`. +/// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no +/// elegir a ciegas entre layouts ambiguos. +fn locate_tool_dir(base: &Path, tool: &str) -> Result { + if base.join(tool).is_file() { + return Ok(base.to_path_buf()); + } + let mut hits = Vec::new(); + for entry in std::fs::read_dir(base)? { + let path = entry?.path(); + if path.is_dir() && path.join(tool).is_file() { + hits.push(path); + } + } + hits.sort(); + match hits.len() { + 1 => Ok(hits.pop().unwrap()), + 0 => Err(Error::Other(format!( + "no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}", + base.display() + ))), + _ => Err(Error::Other(format!( + "ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}", + base.display() + ))), + } +} + +/// Deriva una [`hammer_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla +/// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el +/// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto +/// de la config (rootfs, work, cache) se hereda intacto. +pub fn seed_build_config( + base: &hammer_build::BuildConfig, + seed: &SeedSpec, + store: &Store, +) -> Result { + let zig_dir = seed.toolchain_dir(store)?; + Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() }) } /// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`. @@ -309,4 +373,82 @@ mod tests { "el staging debe quedar limpio tras el fallo" ); } + + /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz + /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain. + fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) { + let src = dir.join("src"); + let zig = src.join(zig_rel); + std::fs::create_dir_all(zig.parent().unwrap()).unwrap(); + std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap(); + let tarball = dir.join("seed.tar.gz"); + let st = Command::new("tar") + .args(["-czf"]) + .arg(&tarball) + .arg("-C") + .arg(&src) + .arg(".") + .status() + .unwrap(); + assert!(st.success()); + let sha = sha256_hex(&std::fs::read(&tarball).unwrap()); + let store = Store::open(dir.join("store")).unwrap(); + let seed = spec(&format!("file://{}", tarball.display()), &sha); + stage0(&seed, &store).unwrap(); + (store, seed) + } + + #[test] + fn toolchain_dir_finds_zig_in_versioned_subdir() { + let tmp = tempfile::tempdir().unwrap(); + let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); + let dir = seed.toolchain_dir(&store).unwrap(); + assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display()); + assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display()); + } + + #[test] + fn toolchain_dir_finds_zig_at_root() { + let tmp = tempfile::tempdir().unwrap(); + let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig"); + let dir = seed.toolchain_dir(&store).unwrap(); + let base = store.path_of(&seed.seed_hash(), &seed.store_name()); + assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla"); + } + + #[test] + fn toolchain_dir_errors_when_unsealed() { + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada + let err = seed.toolchain_dir(&store).unwrap_err().to_string(); + assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}"); + } + + #[test] + fn toolchain_dir_errors_when_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas. + let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig"); + let base = store.path_of(&seed.seed_hash(), &seed.store_name()); + // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test. + let b = base.join("b"); + let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("zig"), b"x").unwrap(); + let err = seed.toolchain_dir(&store).unwrap_err().to_string(); + assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}"); + } + + #[test] + fn seed_build_config_points_zig_dir_at_seed() { + let tmp = tempfile::tempdir().unwrap(); + let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); + let base = hammer_build::BuildConfig::defaults_for_store(store.root()); + let cfg = seed_build_config(&base, &seed, &store).unwrap(); + assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap()); + // El resto de la config se hereda sin cambios. + assert_eq!(cfg.rootfs, base.rootfs); + assert_eq!(cfg.work_root, base.work_root); + } } diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index 15f7c641..9272077e 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -137,14 +137,20 @@ hammer bootstrap stage2 --verify hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐ ``` -## 6. Decisiones abiertas (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md)) +## 6. Decisiones (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md)) -- **Semilla primaria:** `zig` vs `musl-cross-make`. Inclinación: `zig` por hermeticidad - (ADR 0003), con `musl-cross-make` como escotilla por receta. -- **busybox vs toybox** para el userland mínimo (licencia vs cobertura de comandos). -- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino (track posterior - del roadmap). -- **Kernel:** importado pinned ahora; from-source después. +- **Semilla primaria:** `zig` por hermeticidad (ADR 0003), con `musl-cross-make` como escotilla + por receta. ✅ +- **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una balsa desechable hacia el + userland nativo en Rust/Zig; se prioriza compatibilidad de flags y velocidad, y su GPLv2 no + contamina el sistema final. ✅ +- **init `arje` diferido:** Stage 1 con init mínimo provisional; `arje` (y el `CRASHED` real) entran + 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. ☐ +- **Kernel:** importado pinned ahora; from-source después. ☐ ## 7. Hecho cuando diff --git a/docs/adr/0008-bootstrap-stages.md b/docs/adr/0008-bootstrap-stages.md index a3a4e15e..180e3312 100644 --- a/docs/adr/0008-bootstrap-stages.md +++ b/docs/adr/0008-bootstrap-stages.md @@ -19,8 +19,20 @@ mecanismo de build nuevo): como una *fuente fijada por sha256*, y se sella en el store. El `ArtifactHash` se deriva de la **identidad pinned** (clase + versión + sha256), no de rutas del host. **`zig` es la semilla primaria**; `musl-cross-make` (gcc+musl) es escotilla detrás de la misma interfaz. -1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, busybox/toybox, el init - `arje` [ADR 0007](0007-arje-como-init-propio.md), `hammerd`), ensamblado en un rootfs sellado. +1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, **busybox**, init, `hammerd`), + ensamblado en un rootfs sellado. Decisiones de implementación fijadas al abrir el Lote 4: + - **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una *balsa desechable* —se sustituye + por el userland multi-call nativo en Rust/Zig más adelante—, así que se optimiza la velocidad + y la compatibilidad de flags POSIX en el pipeline (mismo comportamiento que el rootfs Alpine de + desarrollo). Su GPLv2 queda *atrapada* en una etapa temprana del manifiesto; no contamina el + sistema final mutable. + - **init `arje` diferido:** Stage 1 arranca con musl+busybox+`hammerd` y un init mínimo + provisional. `arje` ([ADR 0007](0007-arje-como-init-propio.md)) —y con él el `CRASHED` real + de la Fase 5— se integra como receta git pinned en un lote posterior, sin retrabajo. + - **layout de la semilla:** Stage 0 sella el árbol del tarball **tal cual** (ingesta pura, no + aplana); el directorio del toolchain se resuelve **al usarlo** localizando el ejecutable `zig` + en la raíz o en su único hijo versionado (`SeedSpec::toolchain_dir`). Stage 0 no adivina + layouts y el sandbox recibe un `zig_dir` correcto sea cual sea la forma del tarball. 2. **Stage 2 — rebuild nativo:** dentro del rootfs Stage 1, recompilar con las herramientas de Stage 1 y comparar hashes (`stage1` vs `stage1'`). Iguales ⇒ auto-alojado y reproducible.