arranque-grafo: hammer boot menu — orquestador del menú + wiring en el init (ADR 0010)

hammer boot menu ata las tres piezas del contrato: emite el grafo → lanza el
compositor (mirada, --compositor configurable) → activa el nodo que el usuario
dejó en boot-select. Graceful: sin compositor (servidor headless) emite el grafo
y sigue el arranque. Refactor: activate_and_report compartido con boot activate;
--out/--select configurables (testeable sin /run/hammer root-only).

Wiring: iso-image INSTALLER=1 + install-image-efi bundlean el CLI hammer (static
musl) al sistema instalado (el producto trae arje-zero/hammerd pero no el CLI);
el wrapper /sbin/init lo invoca tras hammer-recover, salida al serial (no pinta
tty0 ⇒ respeta cero-parpadeo). Cierra la pieza #3 del handoff mirada (quién
lanza el menú en el boot): lo ownea hammer, desde el hook de init.

Tests: boot_menu.rs (3 caminos del glue: graceful/sin-selección/selección→activate)
+ efi-disk-boot-test asevera que el menú corre. Validado en OVMF: pivote →
INIT-OK → menú (emite boot-graph.json + saltea sin mirada) → arje-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 21:30:30 -04:00
co-authored by Claude Opus 4.8
parent c7d1d9cf5c
commit a5be781ef6
6 changed files with 203 additions and 11 deletions
+83 -11
View File
@@ -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 {
+85
View File
@@ -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}"
);
}
+1
View File
@@ -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
+8
View File
@@ -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"
+16
View File
@@ -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"
+10
View File
@@ -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"