From f17a8fa30149a4e1d0442db66f38a5c3ed9e1b7b Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 11 Jun 2026 14:32:14 +0000 Subject: [PATCH] =?UTF-8?q?bootstrap:=20builder=20rootfs=20=E2=80=94=20ens?= =?UTF-8?q?ambla=20el=20rebuild=20in-rootfs=20(Stage=202=20pleno,=20=C2=A7?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El único sub-ítem que le quedaba a Stage 2: el rebuild *dentro* del rootfs. El Stage 1 que booteamos es runtime (musl+busybox+hammerd+arje-zero), sin compilador — no puede reconstruirse. El builder rootfs es Stage 1 + el toolchain adentro. Implementa `hammer_bootstrap::builder_rootfs` (variante a pragmática, SDD 11 §7.2): sobre el Stage 1 rootfs hidratado monta /toolchain (rootfs Alpine = sandbox de build), /store con la semilla replicada (el hammer de adentro resuelve zig por hash), /usr/bin/hammer + /etc/hammer/recipes, y el driver /usr/bin/rebuild-stage1 que apunta HAMMER_ROOTFS=/toolchain, corre `bootstrap stage1` y compara stage1' contra la referencia con `stage2 --verify` (lee SEED_HASH/SEED_KIND/REF_CONTENT de /etc/hammer/rebuild.env). - BuilderSpec/BuilderReport + builder_hash lógico (insumos: stage1+semilla+ recetas+binario+tag toolchain+driver) — reproducible y auditable sin hashear el árbol Alpine; anota línea stage 2 en el manifiesto. - link_or_copy_tree (hardlink-or-copy, sin chmod: no toca permisos del .dev-fs). - El builder no se sella (toolchain Alpine no es content-addressed); se ensambla en out_dir para empaquetar como initramfs. - CLI `hammer bootstrap builder --stage1 H --seed-hash H [--hammer-bin] [--toolchain] [--ref-content] [--work-cache] [--out]`. +4 tests (assemble, hash determinista, sin-ref, stage1 no sellado). Validado contra el store real: Stage 1 73d7a9be… + semilla 3ce721ec… ⇒ builder 81dad3d9… (1.2 GB con el toolchain). Lo que queda es operacional: bootear en la VM y correr rebuild-stage1 — runbook §8c documenta la receta (hammer estático musl, initramfs, qemu -cpu Broadwell, --work-cache para rebuild offline). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-bootstrap/src/lib.rs | 392 +++++++++++++++++++++++++++++ crates/hammer-cli/src/main.rs | 74 ++++++ docs/11-bootstrap.md | 26 +- docs/runbooks/stage1-vm-boot.md | 52 ++++ 4 files changed, 543 insertions(+), 1 deletion(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 6316a690..2715a76c 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -469,6 +469,274 @@ pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> R } } +// ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ──────────────────── +// +// El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no +// puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que, +// booteada en la VM, corre `hammer bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara +// su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit. +// +// Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por +// hammer). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el +// auto-alojamiento *puro* (variante b: el toolchain construido por hammer desde fuente, incremental). +// +// El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se +// ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es +// reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario + +// tag del toolchain + el propio driver), apto para el log de transparencia. + +/// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee +/// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al +/// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va +/// vacío, sólo imprime el content-hash de `stage1'` para comparación manual. +const REBUILD_DRIVER: &str = r#"#!/bin/sh +# rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7). +# Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y +# compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera. +set -eu +. /etc/hammer/rebuild.env # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío) +export HAMMER_ROOTFS=/toolchain # el sandbox de build: el toolchain (variante a, desde Alpine) +export HAMMER_WORK=/work +export HAMMER_CACHE= +echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)" +PRIME=$(hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes) +echo ">> stage1' = $PRIME" +if [ -n "${REF_CONTENT:-}" ]; then + hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT" +else + hammer --store /store bootstrap stage2 --rootfs "$PRIME" +fi +"#; + +/// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7). +#[derive(Debug, Clone)] +pub struct BuilderSpec { + /// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base + /// runtime sobre la que se hidrata el toolchain. + pub stage1_rootfs: RootfsHash, + /// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `hammer` + /// de adentro resuelva el toolchain por hash, igual que afuera. + pub seed_hash: ArtifactHash, + pub seed_kind: SeedKind, + /// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild). + pub recipes_dir: PathBuf, + /// Binario `hammer` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que hammer + /// construye desde fuente. + pub hammer_bin: PathBuf, + /// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`). + pub toolchain_src: PathBuf, + /// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del + /// builder sin tener que hashear el árbol Alpine entero. + pub toolchain_tag: String, + /// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se + /// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`. + pub ref_content: Option, + /// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y + /// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`). + pub work_cache: Option, +} + +/// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado. +#[derive(Debug, Clone)] +pub struct BuilderReport { + pub builder_hash: ArtifactHash, + pub out_dir: PathBuf, +} + +/// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain +/// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder. +fn builder_hash( + stage1: &RootfsHash, + seed: &ArtifactHash, + toolchain_tag: &str, + recipes_digest: &str, + hammer_digest: &str, +) -> ArtifactHash { + ArtifactHash::of_inputs(&[ + b"hammer-builder-rootfs-v1", + stage1.as_str().as_bytes(), + seed.as_str().as_bytes(), + toolchain_tag.as_bytes(), + recipes_digest.as_bytes(), + hammer_digest.as_bytes(), + REBUILD_DRIVER.as_bytes(), + ]) +} + +/// sha256 hex del contenido de un archivo. +fn sha256_file(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path)?; + let mut h = Sha256::new(); + h.update(&bytes); + Ok(hex::encode(h.finalize())) +} + +/// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden. +/// Cambiar una receta, añadir o quitar una, re-hashea el builder. +fn recipes_digest(dir: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut tomls: Vec = std::fs::read_dir(dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml")) + .collect(); + tomls.sort(); + let mut h = Sha256::new(); + for p in &tomls { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + h.update(name.as_bytes()); + h.update([0u8]); + h.update(std::fs::read(p)?); + h.update([0u8]); + } + Ok(hex::encode(h.finalize())) +} + +/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y +/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder +/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`). +fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> { + let meta = std::fs::symlink_metadata(src)?; + let ft = meta.file_type(); + if ft.is_symlink() { + let target = std::fs::read_link(src)?; + let _ = std::fs::remove_file(dst); + std::os::unix::fs::symlink(target, dst)?; + } else if ft.is_dir() { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()))?; + } + } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() { + std::fs::copy(src, dst)?; + } + Ok(()) +} + +/// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el +/// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`, +/// instala `hammer` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes. +fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> { + // 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros). + let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs"); + if !stage1_dir.is_dir() { + return Err(Error::Other(format!( + "stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero", + spec.stage1_rootfs, + stage1_dir.display() + ))); + } + hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static)?; + + // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config. + for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] { + std::fs::create_dir_all(staging.join(d))?; + } + + // 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs). + if !spec.toolchain_src.is_dir() { + return Err(Error::Other(format!( + "toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)", + spec.toolchain_src.display() + ))); + } + link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"))?; + + // 4) Semilla → /store/-seed-, tal cual está sellada afuera, para que el `hammer` de + // adentro resuelva el toolchain por hash sin re-ingerirla. + let seed_name = format!("seed-{}", spec.seed_kind.as_str()); + let seed_dir = store.path_of(&spec.seed_hash, &seed_name); + if !seed_dir.is_dir() { + return Err(Error::Other(format!( + "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", + seed_name, + seed_dir.display() + ))); + } + link_or_copy_tree( + &seed_dir, + &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)), + )?; + + // 5) Binario hammer → /usr/bin/hammer (+x). + let hammer_dst = staging.join("usr/bin/hammer"); + if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() { + std::fs::copy(&spec.hammer_bin, &hammer_dst)?; + } + set_executable(&hammer_dst)?; + + // 6) Recetas → /etc/hammer/recipes. + for entry in std::fs::read_dir(&spec.recipes_dir)? { + let path = entry?.path(); + if path.extension().and_then(|x| x.to_str()) == Some("toml") { + let name = path.file_name().unwrap(); + std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?; + } + } + + // 7) Caché de fuentes → /work (opcional, para rebuild offline). + if let Some(cache) = &spec.work_cache { + if cache.is_dir() { + link_or_copy_tree(cache, &staging.join("work"))?; + } + } + + // 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo). + let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or(""); + let env = format!( + "SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n", + spec.seed_hash, + spec.seed_kind.as_str(), + ref_content, + ); + std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?; + let driver = staging.join("usr/bin/rebuild-stage1"); + std::fs::write(&driver, REBUILD_DRIVER)?; + set_executable(&driver)?; + Ok(()) +} + +/// Marca un archivo como ejecutable (0o755). +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +/// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada +/// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash +/// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida. +pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result { + // Hash lógico (insumos), independiente del ensamblado en disco. + let rdigest = recipes_digest(&spec.recipes_dir)?; + let hdigest = sha256_file(&spec.hammer_bin)?; + let bhash = builder_hash( + &spec.stage1_rootfs, + &spec.seed_hash, + &spec.toolchain_tag, + &rdigest, + &hdigest, + ); + + let _ = std::fs::remove_dir_all(out_dir); + std::fs::create_dir_all(out_dir)?; + assemble_builder(spec, store, out_dir)?; + + manifest::append_line( + store, + StageEntry { + stage: 2, + recipe_hash: None, + artifact_hash: bhash.clone(), + seed_hash: Some(spec.seed_hash.clone()), + ts: now_unix(), + }, + )?; + tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado"); + Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() }) +} + /// **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 @@ -918,6 +1186,130 @@ mod tests { } } + // --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM --- + + /// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos. + fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) { + let store = Store::open(dir.join("store")).unwrap(); + + // Stage 1 rootfs sellado (con el nombre que espera el builder). + let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| { + std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap(); + std::fs::create_dir_all(w.join("sbin")).unwrap(); + std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap(); + }); + + // Semilla zig sellada con bin/zig (nombre store seed-zig). + let seed = seal_component(&store, "seed-zig", "5ee0", |w| { + std::fs::create_dir_all(w.join("bin")).unwrap(); + std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap(); + }); + + // Recetas, binario hammer y toolchain (Alpine sintético), fuera del store. + let recipes = dir.join("recipes"); + std::fs::create_dir_all(&recipes).unwrap(); + std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap(); + std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap(); + + let hammer_bin = dir.join("hammer"); + std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap(); + + let toolchain = dir.join("alpine"); + std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap(); + std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap(); + + let spec = BuilderSpec { + stage1_rootfs: stage1, + seed_hash: seed, + seed_kind: SeedKind::Zig, + recipes_dir: recipes, + hammer_bin, + toolchain_src: toolchain, + toolchain_tag: "alpine-test".into(), + ref_content: Some(ArtifactHash::from_hex("dead")), + work_cache: None, + }; + (store, spec) + } + + #[test] + fn builder_rootfs_assembles_full_image() { + let tmp = tempfile::tempdir().unwrap(); + let (store, spec) = builder_fixture(tmp.path()); + let out = tmp.path().join("builder-out"); + + let report = builder_rootfs(&spec, &store, &out).unwrap(); + assert!(report.builder_hash.as_str().starts_with("b3:")); + assert_eq!(report.out_dir, out); + + // Base Stage 1 hidratada (init + symlink). + assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado"); + assert_eq!( + std::fs::read_link(out.join("sbin/init")).unwrap(), + PathBuf::from("/usr/bin/arje-zero"), + ); + // Toolchain en /toolchain, semilla en /store, hammer + recetas + driver. + assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado"); + assert!( + out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(), + "semilla replicada en /store", + ); + assert!(out.join("usr/bin/hammer").is_file(), "binario hammer"); + assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas"); + assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild"); + + // El driver y hammer son ejecutables. + use std::os::unix::fs::PermissionsExt; + let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap(); + assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x"); + + // rebuild.env lleva la semilla, la clase y la referencia embebidas. + let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap(); + assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}"); + assert!(env.contains("SEED_KIND=zig"), "{env}"); + assert!(env.contains("REF_CONTENT=b3:dead"), "{env}"); + + // Línea de manifiesto (stage 2) con el hash lógico del builder. + let man = BootstrapManifest::load(&store).unwrap(); + assert!( + man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash), + "el builder debe anotar su línea en el manifiesto", + ); + } + + #[test] + fn builder_hash_is_deterministic_and_sensitive() { + let s1 = ArtifactHash::from_hex("11"); + let seed = ArtifactHash::from_hex("22"); + let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd"); + assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd"), "puro"); + assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd"), "tag del toolchain"); + assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd"), "recetas"); + assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2"), "binario hammer"); + } + + #[test] + fn builder_rootfs_without_ref_leaves_empty_ref_content() { + let tmp = tempfile::tempdir().unwrap(); + let (store, mut spec) = builder_fixture(tmp.path()); + spec.ref_content = None; + let out = tmp.path().join("builder-out"); + builder_rootfs(&spec, &store, &out).unwrap(); + let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap(); + assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}"); + } + + #[test] + fn builder_rootfs_errors_when_stage1_unsealed() { + let tmp = tempfile::tempdir().unwrap(); + let (store, mut spec) = builder_fixture(tmp.path()); + spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado + let out = tmp.path().join("builder-out"); + let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string(); + assert!(err.contains("no sellado"), "{err}"); + } + #[test] fn artifact_content_hash_errors_when_unsealed() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index c882eb1c..3244fd03 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -274,6 +274,42 @@ enum BootstrapCmd { #[arg(long)] verify: Option, }, + /// [Stage 2 · builder] Ensambla el **builder rootfs** (Stage 1 + toolchain) que, booteado en la + /// VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento, SDD 11 §7). Variante + /// pragmática: el toolchain entra desde `.dev-fs/alpine`. + Builder { + /// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`). + #[arg(long)] + stage1: String, + /// Hash de la semilla sellada por Stage 0 (con o sin `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 a embeber. + #[arg(long, default_value = "recipes")] + recipes: String, + /// Binario `hammer` estático a instalar en `/usr/bin/hammer`. + #[arg(long, default_value = "target/x86_64-unknown-linux-musl/release/hammer")] + hammer_bin: String, + /// Toolchain del sandbox de build → `/toolchain` (rootfs Alpine de `.dev-fs`). + #[arg(long, default_value = ".dev-fs/alpine")] + toolchain: String, + /// Etiqueta de identidad del toolchain (entra al hash lógico del builder). + #[arg(long, default_value = "alpine")] + toolchain_tag: String, + /// Content-hash `of_tree(stage1)` (referencia anclada por `stage2`) para que el rebuild se + /// auto-verifique en la VM. Sin él, el driver sólo imprime el content-hash de `stage1'`. + #[arg(long)] + ref_content: Option, + /// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline. + #[arg(long)] + work_cache: Option, + /// Dónde ensamblar el builder (se reensambla limpio en cada corrida). + #[arg(long, default_value = "work/builder-rootfs")] + out: String, + }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { @@ -530,6 +566,44 @@ fn main() -> anyhow::Result<()> { } } } + BootstrapCmd::Builder { + stage1, + seed_hash, + seed, + recipes, + hammer_bin, + toolchain, + toolchain_tag, + ref_content, + work_cache, + out, + } => { + 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 spec = hammer_bootstrap::BuilderSpec { + stage1_rootfs: hammer_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")), + seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")), + seed_kind, + recipes_dir: std::path::PathBuf::from(recipes), + hammer_bin: std::path::PathBuf::from(hammer_bin), + toolchain_src: std::path::PathBuf::from(toolchain), + toolchain_tag, + ref_content: ref_content + .map(|h| hammer_core::ArtifactHash::from_hex(h.trim_start_matches("b3:").to_string())), + work_cache: work_cache.map(std::path::PathBuf::from), + }; + let out = std::path::PathBuf::from(out); + let report = hammer_bootstrap::builder_rootfs(&spec, &store, &out)?; + println!("builder rootfs: {}", report.builder_hash); + println!("ensamblado en: {}", report.out_dir.display()); + println!( + "siguiente: empaquetar como initramfs y bootear en la VM; adentro corre `rebuild-stage1` (runbook §8c)" + ); + } }, } Ok(()) diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index 45a463c2..6addc3d7 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -203,9 +203,33 @@ recetas, la **semilla** (`zig`, ya un artefacto sellado), `make`+autotools, `car `stage2` ya ancla `of_tree(stage1)`; el builder produce `stage1'` y `verify_against` emite el veredicto. El determinismo necesario está cubierto (paths fijos `/src`, `SOURCE_DATE_EPOCH`, locks -deterministas). El sub-ítem es: **ensamblar el builder** (variante de `assemble_rootfs` que hidrata +deterministas). El sub-ítem era: **ensamblar el builder** (variante de `assemble_rootfs` que hidrata el toolchain) y **correr el rebuild anidado** en la VM. +### 7.4 Estado: el ensamblado del builder, hecho ✅ + +El **ensamblado** del builder está implementado (`hammer_bootstrap::builder_rootfs`, variante a): + +``` +hammer bootstrap builder --stage1 --seed-hash \ + [--toolchain .dev-fs/alpine] [--toolchain-tag alpine-3.23.4] \ + [--hammer-bin ] [--ref-content of_tree(stage1)] \ + [--work-cache work/] [--out work/builder-rootfs] +``` + +Sobre el rootfs de Stage 1 (hidratado como base) monta: `/toolchain` (el rootfs Alpine, el sandbox +de build), `/store` con la **semilla replicada** (el `hammer` de adentro resuelve zig por hash), +`/usr/bin/hammer` + `/etc/hammer/recipes`, y el driver **`/usr/bin/rebuild-stage1`** que lee +`/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta `HAMMER_ROOTFS=/toolchain`, +corre `hammer bootstrap stage1` y compara `stage1'` contra la referencia con `stage2 --verify`. + +El builder **no se sella** (su `/toolchain` Alpine no es content-addressed); se ensambla en un +`out_dir` para empaquetar como initramfs. Su identidad sí es reproducible: un `ArtifactHash` *lógico* +de sus insumos (stage1 + semilla + recetas + binario + tag del toolchain + el driver), anotado en el +manifiesto (línea stage 2). Validado contra el store real (Stage 1 `73d7a9be…` + semilla +`3ce721ec…` ⇒ builder `81dad3d9…`, 1.2 GB con el toolchain). El paso que queda es **operacional**: +bootear el builder en la VM y correr `rebuild-stage1` (ver [runbook §8c](runbooks/stage1-vm-boot.md)). + ## 8. Hecho cuando `hammer bootstrap --all` produce un rootfs que, ejecutado en la VM destino, **reconstruye su diff --git a/docs/runbooks/stage1-vm-boot.md b/docs/runbooks/stage1-vm-boot.md index eeb68b51..40740ada 100644 --- a/docs/runbooks/stage1-vm-boot.md +++ b/docs/runbooks/stage1-vm-boot.md @@ -266,6 +266,58 @@ Con ambos, `musl` y `busybox` reconstruyen **bit-idéntico**. Sumados `hammerd` queda a Stage 2 es el rebuild *dentro* de un **builder rootfs** (un Stage 1 que incluya el toolchain) — el auto-alojamiento pleno, un hito propio (el Stage 1 mínimo es runtime, sin compilador). +## 8c. Builder rootfs — el rebuild in-rootfs (auto-alojamiento, SDD 11 §7) + +El **ensamblado** del builder ya está implementado (`hammer bootstrap builder`, [SDD 11 §7.4](../11-bootstrap.md); +variante a, toolchain desde Alpine). Produce, sobre el Stage 1 rootfs, una imagen con el toolchain +adentro lista para reconstruirse a sí misma. Receta operacional: + +**1. Binario `hammer` estático (musl).** El builder lo bootea como PID-algo en la VM, así que debe +ser autocontenido (como hammerd/arje-zero, crt-static — §7): + +```sh +cargo build --release --target x86_64-unknown-linux-musl -p hammer-cli +# (o cargo rustc -- -C target-feature=+crt-static, según el camino de oro del boot) +``` + +**2. Ensamblar el builder** anclando la referencia `of_tree(stage1)` (la que imprimió `stage2`): + +```sh +hammer --store store bootstrap builder \ + --stage1 b3: --seed-hash b3: \ + --hammer-bin target/x86_64-unknown-linux-musl/release/hammer \ + --toolchain .dev-fs/alpine --toolchain-tag alpine-3.23.4 \ + --ref-content b3: \ + --work-cache work \ + --out work/builder-rootfs +``` + +`--work-cache work` embebe los mirrors git / tarballs ya fetcheados en `/work` ⇒ rebuild **offline y +determinista** en la VM (sin él, el rebuild fetchea por red; la seed card trae `networking: full`). + +**3. Empaquetar como initramfs y bootear** (igual que §4/§7, `-cpu Broadwell` por AVX): + +```sh +( cd work/builder-rootfs && find . | cpio -o -H newc | gzip ) > builder.cpio.gz +qemu-system-x86_64 -m 1024 -no-reboot -nographic -cpu Broadwell \ + -kernel -initrd builder.cpio.gz -append "console=ttyS0 rdinit=/sbin/init" +``` + +**4. Dentro de la VM**, desde la shell de la consola, correr el driver embebido: + +```sh +rebuild-stage1 +# >> rebuild stage1 (toolchain in-rootfs, semilla b3:3ce721ec…) +# >> stage1' = b3:… +# ✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit) ← of_tree(stage1')==REF_CONTENT +``` + +`rebuild-stage1` apunta `HAMMER_ROOTFS=/toolchain`, corre `hammer bootstrap stage1` con la semilla y +las recetas de adentro, y compara con `stage2 --verify $REF_CONTENT`. **✓ REPRODUCIBLE** cierra el +auto-alojamiento end-to-end de la variante (a); **✗ DIVERGENTE** caza un no-determinismo nuevo +([SDD 09 §2](../09-trust-model.md)). La variante (b) — el toolchain construido por hammer desde +fuente — reemplaza `/toolchain` Alpine pieza a pieza, con este mismo lazo verificando cada paso. + ## 9. Cross-check opcional — `arje-packager` arje trae su propio empaquetador (`03_ukupacha/arje/init/arje-packager`):