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:
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user