diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 5695032c..ab51598a 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -443,6 +443,23 @@ enum BootCmd { #[arg(long)] from_select: bool, }, + /// [ADR 0010 · glue de arranque] Orquesta el menú de boot completo: emite el grafo, lanza el + /// compositor (mirada) que lo dibuja sobre KMS, y activa el nodo que el usuario eligió. Pensado + /// para correr como root TEMPRANO en el arranque (desde el hook de init, junto a `hammer-recover`, + /// antes de incarnar arje-zero). Graceful: si no hay compositor instalado (p.ej. servidor headless) + /// emite el grafo y sigue el arranque normal. Contrato: el compositor renderiza el menú y, al + /// confirmar, escribe el id en `/run/hammer/boot-select` y termina; hammer entonces activa. + Menu { + /// Comando del compositor a lanzar (se parte por espacios). El primer token es el binario. + #[arg(long, default_value = "mirada-compositor --arranque")] + compositor: String, + /// Path donde publicar el grafo antes de lanzar el compositor. + #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)] + out: PathBuf, + /// Path del canal de vuelta donde el compositor deja el id elegido. + #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)] + select: PathBuf, + }, } #[derive(Subcommand)] @@ -1238,18 +1255,49 @@ fn main() -> anyhow::Result<()> { boot_graph::BOOT_SELECT_PATH), }; let j = hammer_journal::Journal::open(&journal)?; - let r = boot_graph::activate(&root, &state_root, Some(&j), &id)?; - if r.already_current { - println!("✓ nodo {} ya era el vivo — no-op", r.target); - } else if r.kind == boot_graph::NodeKind::Recovery { - println!("✓ recuperación: generación {:?} revertida", r.rolled_back); - } else { - println!("✓ nodo {} activado", r.target); - println!(" {} generación(es) revertida(s): {:?}", - r.rolled_back.len(), r.rolled_back); + let graph_out = std::path::PathBuf::from(boot_graph::BOOT_GRAPH_PATH); + activate_and_report(&root, &state_root, &j, &id, &graph_out)?; + } + BootCmd::Menu { compositor, out, select } => { + // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen). + if let Some(parent) = out.parent() { std::fs::create_dir_all(parent).ok(); } + boot_graph::emit(&state_root, &out)?; + let g = boot_graph::build(&state_root)?; + println!("hammer boot menu: grafo en {} ({} nodo/s)", out.display(), g.nodes.len()); + + // 2) Lanzar el compositor (primer token = binario; resto = args). Limpiamos un + // boot-select viejo para no re-activar una elección anterior. + let _ = std::fs::remove_file(&select); + let parts: Vec<&str> = compositor.split_whitespace().collect(); + let Some((bin, args)) = parts.split_first() else { + anyhow::bail!("--compositor vacío"); + }; + match std::process::Command::new(bin).args(args).status() { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("hammer boot menu: sin compositor ({bin}) — arranque normal"); + return Ok(()); + } + Err(e) => { + eprintln!("hammer boot menu: no pude lanzar {bin}: {e} — arranque normal"); + return Ok(()); + } + Ok(s) if !s.success() => { + eprintln!("hammer boot menu: el compositor salió con {s} — sigo con lo que dejó en boot-select"); + } + Ok(_) => {} + } + + // 3) ¿El usuario eligió un nodo? Si sí, activarlo; si no, arranque normal. + let sel = std::fs::read_to_string(&select).ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + match sel { + None => println!("hammer boot menu: sin selección — arranque normal"), + Some(id) => { + let j = hammer_journal::Journal::open(&journal)?; + activate_and_report(&root, &state_root, &j, &id, &out)?; + } } - // Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo. - boot_graph::emit(&state_root, std::path::Path::new(boot_graph::BOOT_GRAPH_PATH)).ok(); } } } @@ -1257,6 +1305,30 @@ fn main() -> anyhow::Result<()> { Ok(()) } +/// Activa un nodo del grafo de arranque, imprime el resultado y republica el grafo (la vista de mirada +/// debe reflejar el nuevo nodo vivo). Compartido por `hammer boot activate` y `hammer boot menu`. +fn activate_and_report( + root: &std::path::Path, + state_root: &std::path::Path, + journal: &hammer_journal::Journal, + id: &str, + graph_out: &std::path::Path, +) -> anyhow::Result<()> { + use hammer_upgrade::boot_graph; + let r = boot_graph::activate(root, state_root, Some(journal), id)?; + if r.already_current { + println!("✓ nodo {} ya era el vivo — no-op", r.target); + } else if r.kind == boot_graph::NodeKind::Recovery { + println!("✓ recuperación: generación {:?} revertida", r.rolled_back); + } else { + println!("✓ nodo {} activado", r.target); + println!(" {} generación(es) revertida(s): {:?}", r.rolled_back.len(), r.rolled_back); + } + // Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo. + boot_graph::emit(state_root, graph_out).ok(); + Ok(()) +} + /// Imprime el reporte de un `mirror push|pull`. fn print_sync(verbo: &str, r: &hammer_mirror::SyncReport) { for d in &r.copied { diff --git a/crates/hammer-cli/tests/boot_menu.rs b/crates/hammer-cli/tests/boot_menu.rs new file mode 100644 index 00000000..a0e7addf --- /dev/null +++ b/crates/hammer-cli/tests/boot_menu.rs @@ -0,0 +1,85 @@ +//! E2E del orquestador `hammer boot menu` (ADR 0010, glue de arranque): emite el grafo, lanza el +//! compositor (mirada) y activa el nodo elegido. Probamos el GLUE headless con stubs de compositor — +//! el pivot de generaciones ya lo cubren los tests de `boot_graph::activate`. Tres caminos: +//! A) sin compositor instalado (servidor headless) → emite el grafo y sigue el arranque (exit 0). +//! B) el compositor no elige nada → "sin selección", sigue el arranque (exit 0). +//! C) el compositor escribe un id en boot-select → menu lo consume y se lo pasa a `activate`. + +use std::path::Path; +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_hammer"); + +fn menu(state: &Path, work: &Path, compositor: &str) -> std::process::Output { + Command::new(BIN) + .args(["boot", "--state-root"]) + .arg(state) + .arg("--journal") + .arg(work.join("journal")) + .arg("menu") + .arg("--compositor") + .arg(compositor) + .arg("--out") + .arg(work.join("graph.json")) + .arg("--select") + .arg(work.join("boot-select")) + .output() + .expect("ejecutar hammer boot menu") +} + +fn write_stub(path: &Path, body: &str) -> String { + std::fs::write(path, body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path.display().to_string() +} + +#[test] +fn a_sin_compositor_emite_grafo_y_sigue() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + let out = menu(&state, tmp.path(), "no-existe-compositor-xyz --arranque"); + assert!(out.status.success(), "graceful sin compositor debe exit 0: {out:?}"); + // el grafo se publica igual (otros lectores lo usan) + assert!(tmp.path().join("graph.json").exists(), "el grafo debe emitirse aun sin compositor"); + let so = String::from_utf8_lossy(&out.stdout); + assert!(so.contains("sin compositor"), "debe reportar la ausencia del compositor: {so}"); +} + +#[test] +fn b_compositor_sin_seleccion_sigue_arranque() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + let stub = write_stub(&tmp.path().join("noop.sh"), "#!/bin/sh\nexit 0\n"); + let out = menu(&state, tmp.path(), &stub); + assert!(out.status.success(), "sin selección debe exit 0: {out:?}"); + let so = String::from_utf8_lossy(&out.stdout); + assert!(so.contains("sin selección"), "debe reportar que no hubo elección: {so}"); +} + +#[test] +fn c_seleccion_viaja_de_boot_select_a_activate() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + // stub que "elige" un id escribiéndolo en el canal de vuelta (el path que menu le pasa por --select). + let sel = tmp.path().join("boot-select"); + let stub = write_stub( + &tmp.path().join("pick.sh"), + &format!("#!/bin/sh\necho cafe1234deadbeef > '{}'\n", sel.display()), + ); + let out = menu(&state, tmp.path(), &stub); + // El state_root de prueba está vacío (sin generación viva) ⇒ activate falla; PERO eso prueba que el + // id viajó de boot-select → activate (el glue). El pivot exitoso lo cubren los tests de boot_graph. + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "con un id inválido y grafo vacío, activate debe fallar"); + assert!( + err.contains("generación viva") || err.contains("cafe1234") || err.contains("grafo"), + "el fallo debe venir de activate (el id llegó): stderr={err}" + ); +} diff --git a/scripts/efi-disk-boot-test.sh b/scripts/efi-disk-boot-test.sh index 34633a9e..38cfecc3 100755 --- a/scripts/efi-disk-boot-test.sh +++ b/scripts/efi-disk-boot-test.sh @@ -41,6 +41,7 @@ info 'EFI stub: Loaded initrd' 'EFI-stub cargó kernel+i info 'EXT4-fs \(vda2\): mounted' 'kernel montó hammer-root (kernel-side)' check 'HAMMER-EFI-DISK-PIVOT-OK' 'initramfs pivote resolvió hammer-root y va a switch_root' check 'HAMMER-EFI-INIT-OK' 'switch_root → arje-zero PID1 (init real corriendo)' +check 'hammer boot menu: grafo en' 'menú de arranque por grafo corrió (ADR 0010: emite boot-graph.json)' reject_panic() { if clean | grep -aqiE 'HAMMER-EFI-FALLO|Kernel panic'; then echo " ✗ hubo FALLO/panic de pivote"; fail=1; else echo " ✓ sin FALLO ni panic"; fi; } reject_panic diff --git a/scripts/hammer-live-install.sh b/scripts/hammer-live-install.sh index e5032b80..b098b2d1 100755 --- a/scripts/hammer-live-install.sh +++ b/scripts/hammer-live-install.sh @@ -156,6 +156,11 @@ INIT "$BB" mkdir -p "$MNT/usr/sbin" cp "$PAYLOAD/hammer-recover" "$MNT/usr/sbin/hammer-recover"; "$BB" chmod 0755 "$MNT/usr/sbin/hammer-recover" fi + # CLI hammer (static musl) para el menú de arranque por grafo (ADR 0010): lo copia al disco desde el + # payload; el wrapper lo invoca como `hammer boot menu`. + if [ -x "$PAYLOAD/hammer" ]; then + cp "$PAYLOAD/hammer" "$MNT/usr/bin/hammer"; "$BB" chmod 0755 "$MNT/usr/bin/hammer" + fi "$BB" rm -f "$MNT/sbin/init" cat > "$MNT/sbin/init" <<'INIT' #!/bin/sh @@ -166,6 +171,9 @@ INIT /bin/busybox mount -t devtmpfs dev /dev 2>/dev/null || true echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true [ -x /usr/sbin/hammer-recover ] && /usr/sbin/hammer-recover || true +# Menú de arranque por grafo (ADR 0010): emite /run/hammer/boot-graph.json y, si hay compositor (mirada), +# lo pinta sobre KMS. Graceful (sin compositor sigue) y al serial (no pinta tty0 ⇒ cero-parpadeo). +[ -x /usr/bin/hammer ] && /usr/bin/hammer boot menu > /dev/ttyS0 2>&1 || true exec /usr/bin/arje-zero INIT "$BB" chmod +x "$MNT/sbin/init" diff --git a/scripts/install-image-efi.sh b/scripts/install-image-efi.sh index f9411a0e..3ff0050b 100755 --- a/scripts/install-image-efi.sh +++ b/scripts/install-image-efi.sh @@ -117,6 +117,16 @@ echo "==> staging root (hardlinks del rootfs, sin /store ni /var/lib/hammer)" cp -al "$ROOTFS" "$ROOT_TREE" rm -rf "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer" mkdir -p "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer" +# CLI `hammer` (static musl) para el menú de arranque por grafo (ADR 0010): el hook de init lo invoca +# como `hammer boot menu`. El producto trae arje-zero/hammerd pero NO el CLI ⇒ lo inyectamos acá si +# está compilado (igual patrón que hammer-recover). Opcional: sin él, el wrapper saltea el menú. +HAMMER_MUSL="${HAMMER_MUSL:-$ROOT/target/x86_64-unknown-linux-musl/release/hammer}" +if [ -x "$HAMMER_MUSL" ]; then + cp "$HAMMER_MUSL" "$ROOT_TREE/usr/bin/hammer"; chmod 0755 "$ROOT_TREE/usr/bin/hammer" + echo " + CLI hammer (static musl) para el menú de arranque por grafo" +else + echo " ! sin hammer musl ($HAMMER_MUSL) ⇒ el disco no traerá el menú de arranque (cargo build --target x86_64-unknown-linux-musl -p hammer-cli)" >&2 +fi # /sbin/init: wrapper que emite un marcador a /dev/ttyS0 (sobrevive `quiet`, que suprime el printk # kernel-side) y hace exec de arje-zero. Prueba positiva de que el switch_root llegó al init real. rm -f "$ROOT_TREE/sbin/init" @@ -127,6 +137,12 @@ cat > "$ROOT_TREE/sbin/init" <<'RINIT' # /dev/ttyS0 no existe todavía y el echo se pierde). arje-zero re-monta lo que necesite, es idempotente. /bin/busybox mount -t devtmpfs dev /dev 2>/dev/null || true echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true +# Menú de arranque por grafo (ADR 0010): emite /run/hammer/boot-graph.json y, si hay un compositor +# (mirada), lo pinta sobre KMS para elegir un nodo. Graceful: sin compositor, emite el grafo y sigue. +# Salida al serial (no pinta tty0 ⇒ respeta el cero-parpadeo). No bloquea el arranque si falla. +if [ -x /usr/bin/hammer ]; then + /usr/bin/hammer boot menu > /dev/ttyS0 2>&1 || true +fi exec /usr/bin/arje-zero RINIT chmod +x "$ROOT_TREE/sbin/init" diff --git a/scripts/iso-image.sh b/scripts/iso-image.sh index 3b0daf7c..b7f355bc 100755 --- a/scripts/iso-image.sh +++ b/scripts/iso-image.sh @@ -116,6 +116,16 @@ if [ "${INSTALLER:-0}" = 1 ]; then fi [ -x "$RECOVER_BIN" ] || { echo "no pude construir hammer-recover (target musl?)" >&2; exit 1; } install -m 0755 "$RECOVER_BIN" "$IPL/hammer-recover" + # CLI `hammer` (static musl) para el menú de arranque por grafo (ADR 0010): el hook de init del + # sistema instalado lo invoca como `hammer boot menu`. El producto trae arje-zero/hammerd pero NO el + # CLI ⇒ lo bundleamos al payload; hammer-install lo copia a /usr/bin/hammer del disco. + HAMMER_BIN="$ROOT/target/x86_64-unknown-linux-musl/release/hammer" + if [ ! -x "$HAMMER_BIN" ]; then + echo "==> compilando hammer CLI (static musl) para el payload" + cargo build --release --target x86_64-unknown-linux-musl -p hammer-cli --manifest-path "$ROOT/Cargo.toml" >/dev/null 2>&1 + fi + [ -x "$HAMMER_BIN" ] || { echo "no pude construir el CLI hammer (target musl?)" >&2; exit 1; } + install -m 0755 "$HAMMER_BIN" "$IPL/hammer" fi echo "==> empaquetando initramfs.cpio.gz"