boot: activate --from-select idempotente — el hook de apagado de arje-zero lo invoca incondicional

Contrato PLAN-KIKIN §9 (respondido en HANDOFF-KIKIN-DESDE-HAMMER.md): mirada
escribe /run/hammer/boot-select y dispara el reboot SIN salir; /run es tmpfs,
así que la selección se aplica en el apagado o nunca. arje-zero corre esto en
toda secuencia de apagado:
- sin fichero o vacío → no-op limpio (exit 0)
- con selección → activa (rollback E4) y CONSUME el canal; si falla, el fichero
  queda para diagnóstico
- --select parametrizable (testeable); 3 tests nuevos (d/e/f)
- 'boot menu' documentado como HARNESS de dev/VM: en producción mirada no sale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:12:35 -04:00
co-authored by Claude Fable 5
parent 6c864dcee3
commit e8e25fb88e
2 changed files with 101 additions and 13 deletions
+47 -13
View File
@@ -436,19 +436,31 @@ enum BootCmd {
},
/// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
/// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
///
/// `--from-select` es IDEMPOTENTE: sin fichero (o vacío) es un no-op limpio (exit 0), y tras
/// activar con éxito el fichero se consume. Contrato PLAN-KIKIN §9 (tawasuyu, 2026-07-16):
/// mirada escribe la selección y dispara el reinicio SIN salir (sostiene el DRM master);
/// arje-zero (PID1) invoca esto INCONDICIONAL en su secuencia de apagado, antes del reboot —
/// `/run` es tmpfs, así que la selección se aplica en el apagado o no se aplica nunca.
Activate {
/// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
id: Option<String>,
/// Lee el id desde `/run/hammer/boot-select` (canal de vuelta de mirada).
/// Lee el id desde el canal de vuelta de mirada (no-op limpio si no existe o está vacío).
#[arg(long)]
from_select: bool,
/// Path del canal de vuelta que deja mirada.
#[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)]
select: PathBuf,
},
/// [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.
/// [ADR 0010 · HARNESS de dev/VM] Orquesta el menú de boot completo: emite el grafo, lanza el
/// compositor que lo dibuja sobre KMS, ESPERA su salida y activa lo que dejó en boot-select.
/// Sirve para validar el glue en OVMF/QEMU con un compositor stub que sí termina.
///
/// EN PRODUCCIÓN NO SE USA (PLAN-KIKIN §4.1/§9, 2026-07-16): mirada real NUNCA sale — sostiene
/// el DRM master del greeter al escritorio para no re-modesetear (parpadeo). El camino real es:
/// arje-zero publica el grafo al arrancar (`hammer boot graph`), mirada escribe boot-select y
/// dispara el reboot por el bus de arje, y arje-zero corre `hammer boot activate --from-select`
/// en su secuencia de apagado.
Menu {
/// Comando del compositor a lanzar (se parte por espacios). El primer token es el binario.
#[arg(long, default_value = "mirada-compositor --arranque")]
@@ -1243,20 +1255,42 @@ fn main() -> anyhow::Result<()> {
println!(" {} nodo(s); vivo: {vivo}", g.nodes.len());
}
}
BootCmd::Activate { id, from_select } => {
BootCmd::Activate { id, from_select, select } => {
let id = match (id, from_select) {
(Some(id), _) => id,
(None, true) => std::fs::read_to_string(boot_graph::BOOT_SELECT_PATH)
.map_err(|e| anyhow::anyhow!(
"no pude leer {}: {e}", boot_graph::BOOT_SELECT_PATH))?
.trim().to_string(),
// Idempotente (contrato PLAN-KIKIN §9): arje-zero llama esto incondicional
// en TODO apagado; sin selección no hay nada que aplicar y el apagado sigue.
(None, true) => match std::fs::read_to_string(&select) {
Ok(s) if s.trim().is_empty() => {
println!(
"hammer boot activate: {} vacío — sin selección, no-op",
select.display()
);
return Ok(());
}
Ok(s) => s.trim().to_string(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!(
"hammer boot activate: sin selección ({} no existe) — no-op",
select.display()
);
return Ok(());
}
Err(e) => anyhow::bail!("no pude leer {}: {e}", select.display()),
},
(None, false) => anyhow::bail!(
"pasá un <id> o usá --from-select para leerlo de {}",
boot_graph::BOOT_SELECT_PATH),
select.display()),
};
let j = hammer_journal::Journal::open(&journal)?;
let graph_out = std::path::PathBuf::from(boot_graph::BOOT_GRAPH_PATH);
activate_and_report(&root, &state_root, &j, &id, &graph_out)?;
// La selección ya se aplicó: consumir el canal para que una segunda corrida
// (u otro apagado en la misma sesión) sea el no-op de arriba. Sólo en éxito —
// si activate falló, el `?` ya salió y el fichero queda para diagnóstico.
if from_select {
let _ = std::fs::remove_file(&select);
}
}
BootCmd::Menu { compositor, out, select } => {
// 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen).
+54
View File
@@ -83,3 +83,57 @@ fn c_seleccion_viaja_de_boot_select_a_activate() {
"el fallo debe venir de activate (el id llegó): stderr={err}"
);
}
// --- `hammer boot activate --from-select` idempotente (contrato PLAN-KIKIN §9) ---------------------
// arje-zero lo invoca INCONDICIONAL en todo apagado: sin selección debe ser un no-op limpio (exit 0),
// y el fichero sólo se consume si la activación tuvo éxito (si falla, queda para diagnóstico).
fn activate_from_select(state: &Path, work: &Path, select: &Path) -> std::process::Output {
Command::new(BIN)
.args(["boot", "--state-root"])
.arg(state)
.arg("--journal")
.arg(work.join("journal"))
.args(["activate", "--from-select", "--select"])
.arg(select)
.output()
.expect("ejecutar hammer boot activate")
}
#[test]
fn d_activate_from_select_sin_fichero_es_noop() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let out = activate_from_select(&state, tmp.path(), &tmp.path().join("boot-select"));
assert!(out.status.success(), "sin fichero debe ser no-op exit 0: {out:?}");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
}
#[test]
fn e_activate_from_select_vacio_es_noop() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let sel = tmp.path().join("boot-select");
std::fs::write(&sel, "\n").unwrap();
let out = activate_from_select(&state, tmp.path(), &sel);
assert!(out.status.success(), "fichero vacío debe ser no-op exit 0: {out:?}");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
}
#[test]
fn f_activate_fallido_no_consume_el_select() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let sel = tmp.path().join("boot-select");
std::fs::write(&sel, "cafe1234deadbeef\n").unwrap();
// state_root vacío ⇒ no hay generación viva ⇒ activate falla…
let out = activate_from_select(&state, tmp.path(), &sel);
assert!(!out.status.success(), "con grafo vacío activate debe fallar: {out:?}");
// …y el canal queda intacto para diagnóstico (sólo se consume en éxito).
assert!(sel.exists(), "el boot-select NO debe consumirse cuando activate falla");
}