bootstrap: stage1() ensambla el userland mínimo (Lote 5)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c5ab7e4502
commit
60a9451a03
@@ -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-<ver>/`—. Falla si la semilla no está sellada o si
|
||||
/// el ejecutable no aparece (o es ambiguo).
|
||||
pub fn toolchain_dir(&self, store: &Store) -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<RootfsHash> {
|
||||
// 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<u8>> = 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"!<arch>\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:");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user