CLI: hammer bootstrap stage0 + limpieza de staging y e2e
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3d6d25661c
commit
1731e7ef23
Generated
+3
@@ -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",
|
||||
|
||||
@@ -97,6 +97,20 @@ pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
|
||||
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<ArtifactHash> {
|
||||
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<ArtifactHash> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,6 +224,32 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
fs_root: Option<PathBuf>,
|
||||
},
|
||||
/// [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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+7
-4
@@ -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 —
|
||||
|
||||
+13
-9
@@ -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<ArtifactHash>; // toolchain semilla
|
||||
pub fn stage1(seed: &ArtifactHash, store: &Store) -> Result<RootfsHash>; // userland mínimo
|
||||
pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>; // rebuild + diff
|
||||
pub fn all(seed: SeedSpec, store: &Store) -> Result<BootstrapManifest>;
|
||||
pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash>; // ✅ toolchain semilla
|
||||
pub fn stage1(seed: &ArtifactHash, store: &Store) -> Result<RootfsHash>; // ☐ userland mínimo
|
||||
pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>; // ☐ rebuild + diff
|
||||
pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>; // ☐
|
||||
```
|
||||
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user