From 14e10d92926af3dd90558e9e90bc38a9d988c68c Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 11 Jun 2026 01:55:52 +0000 Subject: [PATCH] bootstrap: arje-zero como PID 1 de Stage 1 (I2 del SDD 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza el init provisional de busybox por arje-zero como PID 1 — el paso "init real" que entrega el CRASHED real (Fase 5 diferida, ADR 0007). - STAGE1_COMPONENTS += arje-zero (la receta puente ya lo construía) - assemble_rootfs: genera /ente/seed.card.json (template autocontenido), crea /sbin/init -> /usr/bin/arje-zero, y los mount points que arje monta (incl. /ente, /var/lib/hammer, /sys/fs/cgroup, /dev/pts, /dev/shm). Se retira el inittab. - la seed declara hammerd como Payload::Native Restart (su on_death = el CRASHED real) + console-getty supervisada; ULIDs fijos ⇒ RootfsHash reproducible (v2) - la seed se VERIFICÓ contra el tipo real card_core::Card (from_json + validate pasan), no sólo como JSON — evidencia, no aserción - +2 tests (ensamblado con arje init + seed válida); 21 verdes en el crate Falta para cerrar Stage 1: bus único (B.2) y atestación (A1/A2). Boot real en VM. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-bootstrap/src/lib.rs | 182 +++++++++++++++++++++++------ docs/10-roadmap.md | 12 +- docs/12-init-real.md | 10 +- 3 files changed, 158 insertions(+), 46 deletions(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index be5a8f6a..b32b7773 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -165,26 +165,91 @@ pub fn seed_build_config( /// 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), `busybox` -/// (coreutils + sh + init), `hammerd` (daemon, receta Cargo). El init `arje` (ADR 0007) reemplaza -/// al de busybox en un lote posterior; hasta entonces el PID 1 provisional es el `init` de busybox -/// que arranca a `hammerd` por `/etc/inittab`. -const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd"]; +/// Los componentes del userland mínimo, en orden de ensamblado: `musl` (libc), `busybox` +/// (coreutils + sh), `hammerd` (daemon, receta Cargo) y `arje-zero` (el init PID 1, ADR 0007). +/// arje-zero es PID 1 vía el symlink `/sbin/init`; supervisa a `hammerd` y a la getty por la +/// seed card ([SDD 12](../../docs/12-init-real.md)) — su muerte es el `CRASHED` real. +const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"]; -/// `inittab` del init provisional (busybox). Monta los pseudo-FS, arranca `hammerd` como servicio -/// respawn y abre una shell. Cuando entre `arje` como PID 1 (ADR 0007) este archivo se reemplaza -/// por su supervisión real (que entrega el `CRASHED` que la Fase 5 dejó diferido). -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:/usr/bin/hammerd -::respawn:/bin/sh -::ctrlaltdel:/bin/umount -a -r -::shutdown:/bin/umount -a -r -"; +/// La **seed card** que arje-zero lee en `/ente/seed.card.json` al boot ([SDD 12 §4](../../docs/12-init-real.md)): +/// un `Card` `Virtual` cuyos `genesis` son `hammerd` (servicio `Native` con `Restart` — el +/// `CRASHED` real) y una `console-getty` supervisada. La forma replica una seed real de arje +/// (`seeds/arje-qemu.card.json`) para que `Card::validate()` la acepte; los ULID son fijos para que +/// el `RootfsHash` sea reproducible. Template autocontenido (opción A del SDD): hammer no depende de +/// `card-core` como librería; el pin a arje-zero + el boot en VM cubren el drift de schema. +const STAGE1_SEED_CARD: &str = r#"{ + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFQA00", + "lineage": null, + "label": "hammer-stage1", + "provides": ["Spawn", "Journal"], + "requires": [], + "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, + "soma": { + "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, + "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, + "cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null }, + "cpu_affinity": null + }, + "payload": "Virtual", + "supervision": "OneShot", + "lifecycle": "daemon", + "priority": "normal", + "flow": { "input": [], "output": [] }, + "genesis": [ + { + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFQA01", + "lineage": null, + "label": "hammerd", + "provides": [], + "requires": [], + "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, + "soma": { + "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, + "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, + "cgroup": { "path": "arje.slice/hammerd", "cpu_weight": null, "io_weight": null }, + "cpu_affinity": null + }, + "payload": { "Native": { + "exec": "/usr/bin/hammerd", + "argv": ["--store", "/store", "--journal", "/var/lib/hammer/journal"], + "envp": [["RUST_LOG", "info"], ["ENTE_BUS_SOCK", "/run/ente-bus.sock"]] + }}, + "supervision": { "Restart": { "initial": 200, "max": 10000 } }, + "lifecycle": "daemon", + "priority": "normal", + "flow": { "input": [], "output": [] }, + "genesis": [] + }, + { + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFQA02", + "lineage": null, + "label": "console-getty", + "provides": [], + "requires": [], + "permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, + "soma": { + "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, + "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, + "cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null }, + "cpu_affinity": null + }, + "payload": { "Native": { + "exec": "/bin/busybox", + "argv": ["getty", "-n", "-l", "/bin/sh", "115200", "console"], + "envp": [] + }}, + "supervision": { "Restart": { "initial": 200, "max": 10000 } }, + "lifecycle": "daemon", + "priority": "normal", + "flow": { "input": [], "output": [] }, + "genesis": [] + } + ] +} +"#; /// 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`. @@ -255,15 +320,16 @@ pub fn stage1( 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. +/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + la +/// seed card del init. Cambiar un componente, el orden o la seed re-hashea el rootfs. El tag `v2` +/// marca el corte respecto al rootfs con init provisional (`v1`). fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash { - let mut inputs: Vec> = vec![b"hammer-stage1-rootfs-v1".to_vec()]; + let mut inputs: Vec> = vec![b"hammer-stage1-rootfs-v2".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()); + inputs.push(STAGE1_SEED_CARD.as_bytes().to_vec()); let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); ArtifactHash::of_inputs(&refs) } @@ -282,14 +348,19 @@ fn assemble_and_seal( } /// 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. +/// componente sellado (hardlinks), la seed card de arje y `/sbin/init`→arje-zero. Es la pieza +/// testeable sin build real ([SDD 12](../../docs/12-init-real.md)). 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"] { + // Esqueleto FHS. arje-zero monta él mismo los pseudo-FS (arje-kernel), así que sólo deben + // existir como puntos de montaje vacíos; `/ente` lleva la seed, `/var/lib/hammer` el diario. + for d in [ + "proc", "sys", "sys/fs/cgroup", "dev", "dev/pts", "dev/shm", "run", "tmp", "etc", "root", + "var", "var/lib/hammer", "ente", "usr/bin", "bin", "sbin", + ] { std::fs::create_dir_all(staging.join(d))?; } @@ -304,8 +375,14 @@ fn assemble_rootfs( // 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)?; + // La seed card que arje-zero lee al boot. + std::fs::write(staging.join("ente/seed.card.json"), STAGE1_SEED_CARD)?; + + // arje-zero es PID 1 vía `/sbin/init`. Symlink absoluto: el kernel lo sigue sea cual sea el + // cmdline, sin exigir `init=`. Idempotente: si ya existe (re-ensamblado), lo reemplazamos. + let init_link = staging.join("sbin/init"); + let _ = std::fs::remove_file(&init_link); + std::os::unix::fs::symlink("/usr/bin/arje-zero", &init_link)?; Ok(()) } @@ -642,7 +719,7 @@ mod tests { } #[test] - fn assemble_rootfs_hydrates_and_writes_provisional_init() { + fn assemble_rootfs_hydrates_and_installs_arje_init() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); @@ -658,24 +735,55 @@ mod tests { std::fs::create_dir_all(w.join(".hammer")).unwrap(); std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap(); }); + let harje = seal_component(&store, "arje-zero", "cc33", |w| { + std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap(); + }); let staging = store.root().join("staging"); std::fs::create_dir_all(&staging).unwrap(); - let components = [("musl".to_string(), hmusl), ("busybox".to_string(), hbb)]; + let components = [ + ("musl".to_string(), hmusl), + ("busybox".to_string(), hbb), + ("arje-zero".to_string(), harje), + ]; 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!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado"); + // arje-zero es PID 1 vía /sbin/init. assert_eq!( - std::fs::read_link(staging.join("bin/sh")).unwrap(), - std::path::PathBuf::from("busybox"), - "el symlink de applet se replica literal" + std::fs::read_link(staging.join("sbin/init")).unwrap(), + std::path::PathBuf::from("/usr/bin/arje-zero"), + "/sbin/init apunta a arje-zero" ); - 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"); + // La seed card está en su lugar y es JSON válido con los genesis esperados. + let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&seed).unwrap(); + assert_eq!(v["label"], "hammer-stage1"); + let labels: Vec<&str> = v["genesis"].as_array().unwrap() + .iter().map(|g| g["label"].as_str().unwrap()).collect(); + assert_eq!(labels, vec!["hammerd", "console-getty"]); + // Puntos de montaje que arje monta solos, presentes como dirs vacíos. + assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir()); assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs"); + // Ya no hay init provisional de busybox. + assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado"); + } + + #[test] + fn stage1_seed_card_is_valid_json_with_native_hammerd() { + // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart + // (el CRASHED real) — un template roto rompería el boot de arje-zero. + let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap(); + assert_eq!(v["payload"], "Virtual", "la semilla es Virtual"); + let hammerd = &v["genesis"][0]; + assert_eq!(hammerd["label"], "hammerd"); + assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd"); + assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart"); + let getty = &v["genesis"][1]; + assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox"); } #[test] diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 19cb134c..5245dead 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -185,11 +185,13 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume idempotente. Cubierto por 4 unit + 1 e2e de CLI (offline, `file://`). - **Stage 1** ◑ — userland mínimo cross-compilado. Hecho: recetas pinned `musl` 1.2.5 + `busybox` 1.36.1 (estáticas, zig cc) + `hammerd` (receta Cargo: repo pinned + deps - vendoreadas en el fetch para build `--offline`); `hammer bootstrap stage1` las construye con - la semilla, ensambla el rootfs FHS (hardlink) y lo sella con un `RootfsHash` de contenido + - init provisional (busybox/inittab que arranca `hammerd`). Falta sólo 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. + vendoreadas en el fetch para build `--offline`) + `arje-zero` (init PID 1, receta Cargo a + tawasuyu); `hammer bootstrap stage1` los construye con la semilla, ensambla el rootfs FHS + (hardlink) y lo sella con un `RootfsHash` de contenido. El init es **arje-zero como PID 1** + (`/sbin/init`→arje-zero) con su seed card `/ente/seed.card.json` (hammerd + getty supervisados + ⇒ el `CRASHED` real) — diseño en [SDD 12](12-init-real.md), seed verificada contra + `card_core::Card`. Falta: bus único (B.2) y atestación (A1/A2). El cross-compile y el boot + reales se validan en la VM; 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/12-init-real.md b/docs/12-init-real.md index bbfa2ac7..40ba3db8 100644 --- a/docs/12-init-real.md +++ b/docs/12-init-real.md @@ -134,11 +134,13 @@ descarta: acoplar los grafos de build de los dos repos es justo lo que C.3 evita ## 6. Fases de implementación (riesgo creciente) - **I1 — arje-zero construible.** ✅ (`recipes/arje-zero.toml`, receta puente). -- **I2 — arje es PID 1 de Stage 1.** `STAGE1_COMPONENTS += arje-zero`; `assemble_rootfs` genera - `/ente/seed.card.json` (§4, opción A), provee los mount points + `/sbin/init`→arje-zero, y retira +- **I2 — arje es PID 1 de Stage 1.** ✅ `STAGE1_COMPONENTS += arje-zero`; `assemble_rootfs` genera + `/ente/seed.card.json` (§4, opción A), provee los mount points (incl. `/ente`, `/var/lib/hammer`, + `/sys/fs/cgroup`, `/dev/pts`, `/dev/shm`) + `/sbin/init`→`/usr/bin/arje-zero`, y ya **no** escribe el `/etc/inittab` provisional. arje supervisa hammerd ⇒ **CRASHED real manejado por el init**. - Validación: boot en VM (no testeable sin display/kernel; los unit tests cubren la generación de la - seed y el ensamblado, como el resto de Stage 1). + La seed embebida se **verificó contra el tipo real** `card_core::Card` (`from_json` + `validate()` + pasan), no sólo como JSON válido. El boot completo se valida en VM (no testeable sin kernel); los + unit tests cubren la seed y el ensamblado, como el resto de Stage 1. - **I3 — bus único (plan B.2).** hammerd `announce` en `arje-bus` (cambio en hammerd) y **expone el `CRASHED` a la capa de IA** de hammer (`agent.sock` emite `{"t":"crashed",…}`). El `agent.sock` (JSON, API de IA) queda **encima**; `arje-bus` (postcard) es el plano de control del init.