tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

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>

60a9451a03faa7f1186ab784c33279eeca03bc2e · por Sergio · (hace 4 meses) · padre c5ab7e450 · ver el árbol en este commit

Delta semántico · 16

crates/hammer-bootstrap/src/lib.rs

crates/hammer-cli/src/main.rs

4 archivos · +301 −21

diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs
index d3f8b994..422c67ba 100644
--- a/crates/hammer-bootstrap/src/lib.rs
+++ b/crates/hammer-bootstrap/src/lib.rs
@@ -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:");
+        }
+    }
 }
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index ab016210..0a58e5c6 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -250,6 +250,19 @@ enum BootstrapCmd {
         #[arg(long, default_value = "zig")]
         seed: String,
     },
+    /// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0,
+    /// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs.
+    Stage1 {
+        /// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `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 (`musl.toml`, `busybox.toml`).
+        #[arg(long, default_value = "recipes")]
+        recipes: String,
+    },
 }
 
 fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
@@ -465,6 +478,22 @@ fn main() -> anyhow::Result<()> {
                 let hash = hammer_bootstrap::stage0(&spec, &store)?;
                 println!("{hash}");
             }
+            BootstrapCmd::Stage1 { seed_hash, seed, recipes } => {
+                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 base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
+                let spec = hammer_bootstrap::Stage1Spec {
+                    seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
+                    seed_kind,
+                    recipes_dir: std::path::PathBuf::from(recipes),
+                };
+                let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
+                println!("{hash}");
+            }
         },
     }
     Ok(())
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index c123e97d..b4af9f37 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -183,7 +183,12 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume
   - **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 1** ◑ — userland mínimo cross-compilado. Hecho: recetas pinned `musl` 1.2.5 +
+    `busybox` 1.36.1 (estáticas, zig cc), `hammer bootstrap stage1` que las construye con la
+    semilla, ensambla el rootfs FHS (hardlink) y lo sella con un `RootfsHash` de contenido +
+    init provisional (busybox/inittab). Falta: `hammerd` (receta Cargo, sourcing del plan M2) y
+    sustituir el init por `arje`. El cross-compile real se valida en la VM (los e2e se saltan
+    sin `.dev-fs`); el manifiesto anota la línea de Stage 1.
   - **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`
diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md
index 9272077e..7f0bec0d 100644
--- a/docs/11-bootstrap.md
+++ b/docs/11-bootstrap.md
@@ -118,12 +118,19 @@ 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(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store)
+    -> Result<RootfsHash>;                                                       // ◑ musl+busybox+init; hammerd ☐
+pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>;        // ☐ rebuild + diff
+pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>;          // ☐
 ```
 
+`stage1` cross-compila `musl` + `busybox` con la semilla (no el zig del host), ensambla un rootfs
+FHS por hardlink y lo sella; el `RootfsHash` se deriva del **contenido** (hashes de componentes +
+init), no del árbol en disco, así que es reproducible. El PID 1 es el `init` de busybox vía
+`/etc/inittab` provisional. Falta `hammerd` (receta Cargo: ya construible por el lab, pendiente su
+sourcing pinned + vendor del plan M2) y la sustitución del init por `arje` (ADR 0007).
+
 `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.
@@ -132,7 +139,7 @@ CLI (implementado lo de Stage 0; el resto pendiente):
 
 ```
 hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make]   # ✅
-hammer bootstrap stage1 [--target x86_64-linux-musl]                                       # ☐
+hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR]                      # ◑ musl+busybox
 hammer bootstrap stage2 --verify                                                           # ☐
 hammer bootstrap --all                # las tres + reporte de reproducibilidad             # ☐
 ```