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>
140 lines
6.0 KiB
Rust
140 lines
6.0 KiB
Rust
//! 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}"
|
|
);
|
|
}
|
|
|
|
// --- `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");
|
|
}
|