diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index ab51598a..1f85091c 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -436,19 +436,31 @@ enum BootCmd { }, /// Activa el nodo `` 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, - /// 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 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). diff --git a/crates/hammer-cli/tests/boot_menu.rs b/crates/hammer-cli/tests/boot_menu.rs index a0e7addf..a5f97856 100644 --- a/crates/hammer-cli/tests/boot_menu.rs +++ b/crates/hammer-cli/tests/boot_menu.rs @@ -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"); +}