bootstrap: builder rootfs — ensambla el rebuild in-rootfs (Stage 2 pleno, §7)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
731d4f6ffc
commit
f17a8fa301
@@ -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<ArtifactHash>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut tomls: Vec<PathBuf> = 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/<hash>-seed-<kind>, 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<BuilderReport> {
|
||||
// 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();
|
||||
|
||||
@@ -274,6 +274,42 @@ enum BootstrapCmd {
|
||||
#[arg(long)]
|
||||
verify: Option<String>,
|
||||
},
|
||||
/// [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<String>,
|
||||
/// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
|
||||
#[arg(long)]
|
||||
work_cache: Option<String>,
|
||||
/// 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(())
|
||||
|
||||
+25
-1
@@ -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 <H> --seed-hash <H> \
|
||||
[--toolchain .dev-fs/alpine] [--toolchain-tag alpine-3.23.4] \
|
||||
[--hammer-bin <static-musl-hammer>] [--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
|
||||
|
||||
@@ -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:<STAGE1> --seed-hash b3:<SEED> \
|
||||
--hammer-bin target/x86_64-unknown-linux-musl/release/hammer \
|
||||
--toolchain .dev-fs/alpine --toolchain-tag alpine-3.23.4 \
|
||||
--ref-content b3:<OF_TREE_STAGE1> \
|
||||
--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 <vmlinuz> -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`):
|
||||
|
||||
Reference in New Issue
Block a user