From 1731e7ef239878c8ccf4e295147d4a4933581803 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 21:22:44 +0000 Subject: [PATCH] CLI: `hammer bootstrap stage0` + limpieza de staging y e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cablea el Stage 0 del bootstrap a la CLI: subcomando `bootstrap stage0 --url --sha256 --version [--seed zig|musl-cross-make]` que abre el store, arma el SeedSpec y llama a hammer_bootstrap::stage0, imprimiendo el b3 del artefacto sellado. - stage0 ahora limpia el staging `.bootstrap-tmp` pase lo que pase (extraído stage0_into): un sha256 erróneo ya no deja el tarball a medio descargar bajo el store. Test reforzado para afirmarlo. - e2e de CLL `bootstrap_cli.rs`: tarball falso vía file:// → sella + imprime hash, idempotente, sha erróneo falla sin sellar. Sin red. Docs SDD 11 §5 y roadmap marcan Stage 0 (lib+CLI) hecho; Stage 1/2 pendientes. Workspace 219 verde. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 3 + crates/hammer-bootstrap/src/lib.rs | 27 +++++++-- crates/hammer-cli/Cargo.toml | 3 + crates/hammer-cli/src/main.rs | 39 ++++++++++++ crates/hammer-cli/tests/bootstrap_cli.rs | 76 ++++++++++++++++++++++++ docs/10-roadmap.md | 11 ++-- docs/11-bootstrap.md | 22 ++++--- 7 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 crates/hammer-cli/tests/bootstrap_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 8e66e389..dda8ddc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,11 +454,14 @@ dependencies = [ "base64", "clap", "hammer-agent", + "hammer-bootstrap", "hammer-build", "hammer-core", "hammer-journal", "hammer-overlay", + "hex", "serde_json", + "sha2", "tempfile", "tracing", "tracing-subscriber", diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index fc410f64..3da67157 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -97,6 +97,20 @@ pub fn stage0(seed: &SeedSpec, store: &Store) -> Result { let _ = std::fs::remove_dir_all(&work); std::fs::create_dir_all(&work)?; + // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo + // no debe dejar el tarball a medio descargar bajo el store). + let result = stage0_into(seed, store, &h, &name, &work); + let _ = std::fs::remove_dir_all(&work); + result +} + +fn stage0_into( + seed: &SeedSpec, + store: &Store, + h: &ArtifactHash, + name: &str, + work: &Path, +) -> Result { let tarball = work.join("seed.tar"); download::fetch_url_to_file(&seed.url, &tarball)?; // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella. @@ -106,12 +120,9 @@ pub fn stage0(seed: &SeedSpec, store: &Store) -> Result { std::fs::create_dir_all(&tree)?; untar(&tarball, &tree)?; - let sealed = store.seal(&tree, &h, &name)?; + let sealed = store.seal(&tree, h, name)?; tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada"); - - // Limpieza del staging (el árbol ya se movió por rename; queda el tarball + el dir padre). - let _ = std::fs::remove_dir_all(&work); - Ok(h) + Ok(h.clone()) } /// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado. @@ -229,5 +240,11 @@ mod tests { !store.has(&seed.seed_hash(), &seed.store_name()), "un sha erróneo no debe sellar nada" ); + // Tampoco debe dejar staging a medio descargar bajo el store. + assert!( + !store.root().join(".bootstrap-tmp").exists() + || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(), + "el staging debe quedar limpio tras el fallo" + ); } } diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index 820c2674..af7aa368 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -20,6 +20,7 @@ llm-claude = ["hammer-agent/llm-claude"] [dependencies] hammer-core.workspace = true hammer-build.workspace = true +hammer-bootstrap.workspace = true hammer-overlay.workspace = true hammer-journal.workspace = true hammer-agent.workspace = true @@ -33,3 +34,5 @@ tracing-subscriber.workspace = true [dev-dependencies] tempfile.workspace = true base64.workspace = true +sha2.workspace = true +hex.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 4fa616e1..ab016210 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -224,6 +224,32 @@ enum Cmd { #[arg(long)] fs_root: Option, }, + /// [Track posterior] Bootstrap from-scratch (SDD 11). Encadena el toolchain semilla y el + /// userland mínimo hacia el auto-alojamiento. + Bootstrap { + #[command(subcommand)] + sub: BootstrapCmd, + }, +} + +#[derive(Subcommand)] +enum BootstrapCmd { + /// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de + /// extraer y lo sella; idempotente si ya está. Imprime el hash del artefacto. + Stage0 { + /// URL del tarball del toolchain (cualquier esquema de curl; `file://` para offline). + #[arg(long)] + url: String, + /// sha256 hex del tarball, fijado. Se verifica antes de sellar. + #[arg(long)] + sha256: String, + /// Versión de la semilla (entra en la identidad/hash del artefacto). + #[arg(long)] + version: String, + /// Clase de semilla. + #[arg(long, default_value = "zig")] + seed: String, + }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { @@ -427,6 +453,19 @@ fn main() -> anyhow::Result<()> { Cmd::Query { expr, base_ref, fs_root } => { run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?; } + Cmd::Bootstrap { sub } => match sub { + BootstrapCmd::Stage0 { url, sha256, version, seed } => { + let 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::SeedSpec { kind, version, url, sha256 }; + let hash = hammer_bootstrap::stage0(&spec, &store)?; + println!("{hash}"); + } + }, } Ok(()) } diff --git a/crates/hammer-cli/tests/bootstrap_cli.rs b/crates/hammer-cli/tests/bootstrap_cli.rs new file mode 100644 index 00000000..5478a56f --- /dev/null +++ b/crates/hammer-cli/tests/bootstrap_cli.rs @@ -0,0 +1,76 @@ +//! E2E del CLI `hammer bootstrap stage0` (track posterior, SDD 11). Construye un tarball de +//! semilla falso, lo ingiere vía `file://` (sin red) y verifica el camino completo: +//! sella + imprime el hash, es idempotente, y un sha256 erróneo falla sin sellar. + +use std::path::Path; +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_hammer"); + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(bytes); + hex::encode(h.finalize()) +} + +/// Crea un tarball con `bin/zig` dentro y devuelve `(url file://, sha256 hex)`. +fn make_seed_tarball(dir: &Path) -> (String, String) { + let src = dir.join("src"); + std::fs::create_dir_all(src.join("bin")).unwrap(); + std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap(); + let tarball = dir.join("seed.tar.gz"); + let st = Command::new("tar") + .arg("-czf") + .arg(&tarball) + .arg("-C") + .arg(&src) + .arg(".") + .status() + .unwrap(); + assert!(st.success()); + let sha = sha256_hex(&std::fs::read(&tarball).unwrap()); + (format!("file://{}", tarball.display()), sha) +} + +fn stage0(store: &Path, url: &str, sha256: &str, version: &str) -> std::process::Output { + Command::new(BIN) + .arg("--store") + .arg(store) + .args(["bootstrap", "stage0", "--url"]) + .arg(url) + .args(["--sha256", sha256, "--version", version]) + .output() + .unwrap() +} + +#[test] +fn stage0_seals_idempotent_and_rejects_bad_sha() { + let tmp = tempfile::tempdir().unwrap(); + let (url, sha) = make_seed_tarball(tmp.path()); + let store = tmp.path().join("store"); + + // --- primera ingestión: sella e imprime el hash --- + let out = stage0(&store, &url, &sha, "0.13.0"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let hash = String::from_utf8(out.stdout).unwrap().trim().to_string(); + assert!(hash.starts_with("b3:"), "hash inesperado: {hash}"); + + // El artefacto está en el store con el contenido extraído. + let zig = store.join(format!("{}-seed-zig", hash.trim_start_matches("b3:"))).join("bin/zig"); + assert!(zig.is_file(), "bin/zig debe existir en el artefacto sellado"); + + // --- idempotente: mismo hash, sin re-sellar --- + let out2 = stage0(&store, &url, &sha, "0.13.0"); + assert!(out2.status.success()); + assert_eq!(String::from_utf8(out2.stdout).unwrap().trim(), hash); + + // --- sha erróneo: falla y no sella un artefacto nuevo --- + let bad = stage0(&store, &url, &"0".repeat(64), "bad"); + assert!(!bad.status.success(), "un sha erróneo debe fallar"); + assert!( + String::from_utf8_lossy(&bad.stderr).contains("sha256 mismatch"), + "stderr: {}", + String::from_utf8_lossy(&bad.stderr) + ); +} diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 5e0ee5c0..c123e97d 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -179,10 +179,13 @@ pre-requisito de validación. Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resumen: -- **Bootstrap from-scratch** en tres etapas ([SDD 11 §3](11-bootstrap.md)): Stage 0 (toolchain - semilla `zig`/`musl-cross-make` sellado por hash), Stage 1 (userland mínimo cross-compilado: - musl + busybox/toybox + init + `hammerd`), Stage 2 (rebuild nativo dentro del rootfs y diff - de hashes ⇒ auto-alojamiento bit-reproducible). ⏭️ **Primer paso del track.** +- **Bootstrap from-scratch** en tres etapas ([SDD 11 §3](11-bootstrap.md)): + - **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 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` real que la Fase 5 dejó diferido. **Decisión: adoptar `arje`** (init from-scratch ya existente en el monorepo `tawasuyu`, con PID 1 + supervisión real) en lugar de escribir uno nuevo — diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index e5bdf5f1..0cf474be 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -112,19 +112,23 @@ 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(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; // ☐ ``` -CLI: +`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. + +CLI (implementado lo de Stage 0; el resto pendiente): ``` -hammer bootstrap stage0 [--seed zig|musl-cross-make] -hammer bootstrap stage1 [--target x86_64-linux-musl] -hammer bootstrap stage2 --verify -hammer bootstrap --all # las tres + reporte de reproducibilidad +hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make] # ✅ +hammer bootstrap stage1 [--target x86_64-linux-musl] # ☐ +hammer bootstrap stage2 --verify # ☐ +hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐ ``` ## 6. Decisiones abiertas (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))