From 60a9451a03faa7f1186ab784c33279eeca03bc2e Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 11 Jun 2026 00:43:03 +0000 Subject: [PATCH] =?UTF-8?q?bootstrap:=20stage1()=20ensambla=20el=20userlan?= =?UTF-8?q?d=20m=C3=ADnimo=20(Lote=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-compila musl + busybox con la semilla de Stage 0 (no el zig del host), ensambla un rootfs FHS por hardlink y lo sella. CLI: `hammer bootstrap stage1`. - seed_toolchain_dir(store, hash, kind): primitivo sin SeedSpec, para que Stage 1 resuelva el toolchain desde (seed_hash, kind) sin re-pasar url/sha256 - RootfsHash = hash de CONTENIDO (componentes ordenados + init), no del árbol en disco ⇒ reproducible aunque difieran inodes/timestamps - assemble_rootfs: esqueleto FHS + hydrate de cada componente + /etc/inittab provisional (busybox init); descarta el sidecar .hammer del rootfs - idempotente (no reensambla si el rootfs ya está sellado); anota línea stage 1 - hammerd (receta Cargo) y arje quedan como slots pendientes (plan M2 / ADR 0007) +3 tests (rootfs_hash determinista/sensible, assemble_rootfs, recetas del repo parsean y hashean). Workspace verde; el cross-compile real se valida en la VM. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-bootstrap/src/lib.rs | 269 +++++++++++++++++++++++++++-- crates/hammer-cli/src/main.rs | 29 ++++ docs/10-roadmap.md | 7 +- docs/11-bootstrap.md | 17 +- 4 files changed, 301 insertions(+), 21 deletions(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index d3f8b994..422c67ba 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use hammer_build::download; -use hammer_core::{ArtifactHash, Store}; +use hammer_core::{ArtifactHash, Recipe, Store}; pub use manifest::{BootstrapManifest, StageEntry}; @@ -91,20 +91,31 @@ impl SeedSpec { /// 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(), - )), - } + seed_toolchain_dir(store, &self.seed_hash(), self.kind) + } +} + +/// Igual que [`SeedSpec::toolchain_dir`] pero sólo desde la identidad mínima `(seed_hash, kind)`: +/// lo que basta para localizar la semilla ya sellada sin re-pasar `url`/`sha256`. Lo usa Stage 1, +/// que conoce el `seed_hash` (de Stage 0 o del manifiesto) pero no necesariamente el `SeedSpec`. +pub fn seed_toolchain_dir( + store: &Store, + seed_hash: &ArtifactHash, + kind: SeedKind, +) -> Result { + let base = store.path_of(seed_hash, &format!("seed-{}", kind.as_str())); + if !base.is_dir() { + return Err(Error::Other(format!( + "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", + kind.as_str(), + base.display() + ))); + } + match 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(), + )), } } @@ -149,6 +160,153 @@ pub fn seed_build_config( Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() }) } +/// Hash de un rootfs sellado por Stage 1. Es un `ArtifactHash` derivado del **contenido lógico** +/// (los hashes de sus componentes + el init), no del árbol en disco: así dos ensamblados de los +/// mismos artefactos producen el mismo hash aunque difieran en inodes o timestamps. +pub type RootfsHash = ArtifactHash; + +/// Los componentes del userland mínimo, en orden de ensamblado. `musl` primero (libc), luego +/// `busybox` (coreutils + sh + init). `hammerd` (Rust, vía receta Cargo) y el init `arje` entran +/// en un lote posterior (ADR 0008): hasta entonces el PID 1 provisional es el `init` de busybox. +const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox"]; + +/// `inittab` del init provisional (busybox). Monta los pseudo-FS y abre una shell. Cuando +/// `hammerd` entre como servicio supervisado se añadirá `::respawn:/sbin/hammerd`; cuando entre +/// `arje` como PID 1 (ADR 0007) este archivo se reemplaza por su supervisión real. +const PROVISIONAL_INITTAB: &str = "\ +# Stage 1 — init provisional (busybox). Reemplazado por arje (ADR 0007) más adelante. +::sysinit:/bin/mount -t proc proc /proc +::sysinit:/bin/mount -t sysfs sysfs /sys +::sysinit:/bin/mount -t devtmpfs dev /dev +::sysinit:/bin/mount -o remount,rw / +::respawn:/bin/sh +::ctrlaltdel:/bin/umount -a -r +::shutdown:/bin/umount -a -r +"; + +/// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio +/// de recetas donde viven `musl.toml` / `busybox.toml`. +#[derive(Debug, Clone)] +pub struct Stage1Spec { + pub seed_hash: ArtifactHash, + pub seed_kind: SeedKind, + pub recipes_dir: PathBuf, +} + +/// **Stage 1** — cross-compila el userland mínimo con la semilla de Stage 0, ensambla un rootfs +/// FHS y lo sella. Devuelve el [`RootfsHash`] y anota la línea del manifiesto. +/// +/// Idempotente: si el rootfs (mismo hash de contenido) ya está sellado, no reconstruye. El +/// cross-compile real requiere el rootfs base + toolchain del lab; en una máquina sin `.dev-fs` +/// `hammer_build::build` falla limpio antes de ensamblar nada. +pub fn stage1( + spec: &Stage1Spec, + base_cfg: &hammer_build::BuildConfig, + store: &Store, +) -> Result { + // Toolchain = la semilla sellada, no el zig del host. + let zig_dir = seed_toolchain_dir(store, &spec.seed_hash, spec.seed_kind)?; + let cfg = hammer_build::BuildConfig { zig_dir, ..base_cfg.clone() }; + + // 1) Construir cada componente (cacheado por el lab si ya existe). + let mut components: Vec<(String, ArtifactHash)> = Vec::with_capacity(STAGE1_COMPONENTS.len()); + for name in STAGE1_COMPONENTS { + let recipe_path = spec.recipes_dir.join(format!("{name}.toml")); + let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { + Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) + })?; + let h = hammer_build::build(&recipe, &cfg, store)?; + components.push((name.to_string(), h)); + } + + // 2) Hash del rootfs = función pura de sus componentes (reproducible, sin tocar disco). + let rootfs_hash = rootfs_hash(&components); + let store_name = "stage1-rootfs"; + + // 3) Ensamblar + sellar (si no estaba ya). Staging bajo el store ⇒ rename atómico en seal. + if !store.has(&rootfs_hash, store_name) { + let staging = store + .root() + .join(".bootstrap-tmp") + .join(rootfs_hash.store_dir_name(store_name)); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging)?; + let result = assemble_and_seal(store, &components, &rootfs_hash, store_name, &staging); + let _ = std::fs::remove_dir_all(&staging); + result?; + } else { + tracing::info!(hash = %rootfs_hash, "stage1: rootfs ya sellado (idempotente)"); + } + + // 4) Línea del manifiesto. `recipe_hash = None`: el rootfs es un ensamblado de varias recetas, + // no de una sola; el `seed_hash` ancla de qué toolchain salió. + manifest::append_line( + store, + StageEntry { + stage: 1, + recipe_hash: None, + artifact_hash: rootfs_hash.clone(), + seed_hash: Some(spec.seed_hash.clone()), + ts: now_unix(), + }, + )?; + Ok(rootfs_hash) +} + +/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + el +/// init provisional. Cambiar un componente, el orden o el inittab re-hashea el rootfs. +fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash { + let mut inputs: Vec> = vec![b"hammer-stage1-rootfs-v1".to_vec()]; + for (name, h) in components { + inputs.push(name.as_bytes().to_vec()); + inputs.push(h.as_str().as_bytes().to_vec()); + } + inputs.push(PROVISIONAL_INITTAB.as_bytes().to_vec()); + let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); + ArtifactHash::of_inputs(&refs) +} + +fn assemble_and_seal( + store: &Store, + components: &[(String, ArtifactHash)], + rootfs_hash: &RootfsHash, + store_name: &str, + staging: &Path, +) -> Result<()> { + assemble_rootfs(store, components, staging)?; + store.seal(staging, rootfs_hash, store_name)?; + tracing::info!(hash = %rootfs_hash, "stage1: rootfs sellado"); + Ok(()) +} + +/// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada +/// componente sellado (hardlinks) y el init provisional. Es la pieza testeable sin build real. +fn assemble_rootfs( + store: &Store, + components: &[(String, ArtifactHash)], + staging: &Path, +) -> Result<()> { + // Esqueleto FHS mínimo. Los pseudo-FS quedan como puntos de montaje vacíos. + for d in ["proc", "sys", "dev", "tmp", "run", "etc", "root", "var", "usr/bin", "bin", "sbin"] { + std::fs::create_dir_all(staging.join(d))?; + } + + // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem). + for (name, h) in components { + let dir = store.path_of(h, name); + hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static)?; + } + + // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs + // ejecutable: lo quitamos para que la imagen quede limpia y su hash no dependa de cuál + // componente lo escribió último. + let _ = std::fs::remove_dir_all(staging.join(".hammer")); + + // Init provisional (busybox lo lee de /etc/inittab). + std::fs::write(staging.join("etc/inittab"), PROVISIONAL_INITTAB)?; + Ok(()) +} + /// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`. /// /// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a @@ -451,4 +609,85 @@ mod tests { assert_eq!(cfg.rootfs, base.rootfs); assert_eq!(cfg.work_root, base.work_root); } + + // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real --- + + /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol + /// antes del `seal` (que lo marca read-only). + fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash { + let h = ArtifactHash::from_hex(hex); + let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name)); + std::fs::create_dir_all(&work).unwrap(); + build(&work); + store.seal(&work, &h, name).unwrap(); + h + } + + #[test] + fn rootfs_hash_is_deterministic_and_sensitive() { + let base = [ + ("musl".to_string(), ArtifactHash::from_hex("11")), + ("busybox".to_string(), ArtifactHash::from_hex("22")), + ]; + let a = rootfs_hash(&base); + assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash"); + + let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))]; + assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea"); + + let reordered = [base[1].clone(), base[0].clone()]; + assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa"); + } + + #[test] + fn assemble_rootfs_hydrates_and_writes_provisional_init() { + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + + let hmusl = seal_component(&store, "musl", "aa11", |w| { + std::fs::create_dir_all(w.join("usr/lib")).unwrap(); + std::fs::write(w.join("usr/lib/libc.a"), b"!\n").unwrap(); + }); + let hbb = seal_component(&store, "busybox", "bb22", |w| { + std::fs::create_dir_all(w.join("bin")).unwrap(); + std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap(); + std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); + // Sidecar de provenance que NO debe terminar en el rootfs. + std::fs::create_dir_all(w.join(".hammer")).unwrap(); + std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap(); + }); + + let staging = store.root().join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + let components = [("musl".to_string(), hmusl), ("busybox".to_string(), hbb)]; + assemble_rootfs(&store, &components, &staging).unwrap(); + + assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado"); + assert!(staging.join("bin/busybox").is_file(), "busybox hidratado"); + assert_eq!( + std::fs::read_link(staging.join("bin/sh")).unwrap(), + std::path::PathBuf::from("busybox"), + "el symlink de applet se replica literal" + ); + assert!(staging.join("proc").is_dir() && staging.join("sbin").is_dir(), "esqueleto FHS"); + let inittab = std::fs::read_to_string(staging.join("etc/inittab")).unwrap(); + assert!(inittab.contains("/bin/sh"), "init provisional abre una shell"); + assert!(inittab.contains("mount -t proc"), "monta los pseudo-FS"); + assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs"); + } + + #[test] + fn shipped_stage1_recipes_parse_and_hash() { + // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK). + let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + for name in STAGE1_COMPONENTS { + let path = recipes_dir.join(format!("{name}.toml")); + let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); + assert_eq!(&r.name, name); + let h = hammer_build::artifact_hash(&r, &store).expect("hash"); + assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:"); + } + } } diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index ab016210..0a58e5c6 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -250,6 +250,19 @@ enum BootstrapCmd { #[arg(long, default_value = "zig")] seed: String, }, + /// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0, + /// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs. + Stage1 { + /// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `b3:`). + #[arg(long)] + seed_hash: String, + /// Clase de semilla (debe coincidir con la de Stage 0). + #[arg(long, default_value = "zig")] + seed: String, + /// Directorio de recetas (`musl.toml`, `busybox.toml`). + #[arg(long, default_value = "recipes")] + recipes: String, + }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { @@ -465,6 +478,22 @@ fn main() -> anyhow::Result<()> { let hash = hammer_bootstrap::stage0(&spec, &store)?; println!("{hash}"); } + BootstrapCmd::Stage1 { seed_hash, seed, recipes } => { + let seed_kind = match seed.as_str() { + "zig" => hammer_bootstrap::SeedKind::Zig, + "musl-cross-make" => hammer_bootstrap::SeedKind::MuslCrossMake, + other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"), + }; + let store = hammer_core::Store::open(&cli.store)?; + let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); + let spec = hammer_bootstrap::Stage1Spec { + seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")), + seed_kind, + recipes_dir: std::path::PathBuf::from(recipes), + }; + let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?; + println!("{hash}"); + } }, } Ok(()) diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index c123e97d..b4af9f37 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -183,7 +183,12 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume - **Stage 0** ✅ — crate `hammer-bootstrap` + `hammer bootstrap stage0`: ingiere el toolchain semilla pinned (`SeedSpec`, hash por identidad), verifica sha256 antes de sellar, idempotente. Cubierto por 4 unit + 1 e2e de CLI (offline, `file://`). - - **Stage 1** ☐ — userland mínimo cross-compilado (musl + busybox/toybox + `arje` + `hammerd`). + - **Stage 1** ◑ — userland mínimo cross-compilado. Hecho: recetas pinned `musl` 1.2.5 + + `busybox` 1.36.1 (estáticas, zig cc), `hammer bootstrap stage1` que las construye con la + semilla, ensambla el rootfs FHS (hardlink) y lo sella con un `RootfsHash` de contenido + + init provisional (busybox/inittab). Falta: `hammerd` (receta Cargo, sourcing del plan M2) y + sustituir el init por `arje`. El cross-compile real se valida en la VM (los e2e se saltan + sin `.dev-fs`); el manifiesto anota la línea de Stage 1. - **Stage 2** ☐ — rebuild nativo dentro del rootfs y diff de hashes ⇒ auto-alojamiento bit-reproducible. - Reemplazar el init de Alpine por **tu init** (bus por pipes nativo) — habilita el `CRASHED` diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index 9272077e..7f0bec0d 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -118,12 +118,19 @@ No introduce mecanismo nuevo de build: encadena recetas y persiste el manifiesto ```rust // hammer-bootstrap -pub fn stage0(seed: &SeedSpec, store: &Store) -> Result; // ✅ toolchain semilla -pub fn stage1(seed: &ArtifactHash, store: &Store) -> Result; // ☐ userland mínimo -pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result; // ☐ rebuild + diff -pub fn all(seed: &SeedSpec, store: &Store) -> Result; // ☐ +pub fn stage0(seed: &SeedSpec, store: &Store) -> Result; // ✅ toolchain semilla +pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store) + -> Result; // ◑ musl+busybox+init; hammerd ☐ +pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result; // ☐ rebuild + diff +pub fn all(seed: &SeedSpec, store: &Store) -> Result; // ☐ ``` +`stage1` cross-compila `musl` + `busybox` con la semilla (no el zig del host), ensambla un rootfs +FHS por hardlink y lo sella; el `RootfsHash` se deriva del **contenido** (hashes de componentes + +init), no del árbol en disco, así que es reproducible. El PID 1 es el `init` de busybox vía +`/etc/inittab` provisional. Falta `hammerd` (receta Cargo: ya construible por el lab, pendiente su +sourcing pinned + vendor del plan M2) y la sustitución del init por `arje` (ADR 0007). + `SeedSpec { kind, version, url, sha256 }` es la identidad pinned de la semilla; `seed_hash()` deriva el `ArtifactHash` de `(kind, version, sha256)` — no del `url` ni del host, así que cualquier espejo del mismo tarball produce el mismo artefacto. @@ -132,7 +139,7 @@ CLI (implementado lo de Stage 0; el resto pendiente): ``` hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make] # ✅ -hammer bootstrap stage1 [--target x86_64-linux-musl] # ☐ +hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR] # ◑ musl+busybox hammer bootstrap stage2 --verify # ☐ hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐ ```