Gunnar

mikronova/draupnir

tests/redfish_kvm_loop_proof.rs

//! **THE LOOP — a real ISO booted THROUGH Redfish, on a real machine.**
//!
//! `tests/redfish_server_conformance.rs` proves the *wire*: every response checked
//! against DMTF's own schema, registry and mockup. This file proves the *machine*:
//! the same `BootSpec` that [`BootSpec::iso_boot`] builds, routed with
//! [`on_metal`](BootSpec::on_metal) through draupnir's Redfish **client**, into
//! draupnir's Redfish **server**, out the other side as a QEMU/KVM guest — which
//! really boots the ISO and says so on its serial console.
//!
//! ```text
//!   BootSpec::iso_boot(..).on_metal(bmc)
//!        └─ RedfishBoot (client, cert PINNED)
//!             ├─ POST  VirtualMedia.InsertMedia     → BootSpec::medium
//!             ├─ PATCH Boot.BootSourceOverrideTarget → BootOrder::Medium
//!             └─ POST  ComputerSystem.Reset {On}    → KvmBoot::boot
//!                                                       └─ QEMU + OVMF + /dev/kvm
//!                                                            └─ ttyS0 says so
//! ```
//!
//! # The form is `iso-redfish-sim`, NEVER `iso-metal`
//!
//! **An emulator proves the wire, never the hardware.** There is no BMC on this box.
//! Recording this green in the cell a real-hardware green would occupy is exactly the
//! false green the form split exists to prevent, so every matrix row this file emits
//! is under `draupnir/iso-redfish-sim`. `iso-metal` stays Absent, visibly, on its own
//! row, until an actual iDRAC/iLO is burned.
//!
//! # The RED that matters
//!
//! A server can answer `204 No Content` to a boot-override PATCH and then boot
//! exactly what it would have booted anyway. Every status code in the conformance
//! suite would still be green. So the last test here flips the override to `Hdd`
//! **with the ISO still in the tray**, boots again for real, and asserts that neither
//! the firmware's DVD-ROM line nor the appliance banner appears — measured on a
//! genuinely blank disk, which is a sabotage that cannot be reverted away.
//!
//! # Running it
//!
//! ```text
//! DRAUPNIR_VM_BOOT_TIMEOUT=200 cargo test \
//!   --features redfish-server,backend-redfish,backend-tunnr \
//!   --test redfish_kvm_loop_proof -- --ignored --nocapture
//! ```
//!
//! `#[ignore]` because it needs `/dev/kvm`, `qemu-system-x86_64`, OVMF and a real
//! ISO. When a prerequisite is missing it **skips LOUDLY, by name**, and never
//! returns a green that stands for a boot that did not happen.
#![cfg(all(
    feature = "redfish-server",
    feature = "backend-redfish",
    feature = "backend-tunnr"
))]

use std::path::{Path, PathBuf};
use std::time::Duration;

use draupnir::redfish::wire;
use draupnir::redfish::RedfishBoot;
use draupnir::redfish_server::{NodeConfig, RedfishKvmServer};
use draupnir::{Boot, BootOrder, BootSpec, BootTarget, Lifecycle, PowerState, Seen, VirtualMedia};

/// Assert on the real value AND record it as a matrix row under the
/// **`iso-redfish-sim`** form. Same `assert_emit!` doctrine as
/// `tests/testmatrix_rows.rs`: the assert is the gate, the emit is the observation.
macro_rules! assert_emit {
    ($check:expr, $ok:expr, $($detail:tt)+) => {{
        let ok: bool = $ok;
        let detail = format!($($detail)+);
        draupnir::functional_status("draupnir/iso-redfish-sim", $check, ok, &detail);
        println!("ROW iso-redfish-sim/{:<28} {:<5} {}", $check, if ok { "GREEN" } else { "RED" }, detail);
        assert!(ok, "iso-redfish-sim::{} — {}", $check, detail);
    }};
}

/// The ISO this proof burns. Overridable: the appliance ISO's path is a property of
/// the box, not of the test.
fn proof_iso() -> String {
    std::env::var("PROOF_ISO").unwrap_or_else(|_| {
        format!(
            "{}/Hämtningar/gunnar-appliance-x86_64.iso",
            std::env::var("HOME").unwrap_or_default()
        )
    })
}

/// The console substring that means **the firmware executed code off the medium** —
/// UEFI naming the CD/DVD device it chose. Generic to QEMU: any bootable ISO produces
/// it and a disk boot never does.
const MEDIUM_MARKER: &str = "UEFI QEMU DVD-ROM";

/// The console substring that means **the system off the medium reached userspace and
/// is serving**. Booting firmware is not the claim; a running appliance is.
fn proof_marker() -> String {
    std::env::var("PROOF_MARKER").unwrap_or_else(|_| "gunnar ssh listener bound".to_string())
}

/// A scratch directory for the blank disk. Deliberately **not** `std::env::temp_dir()`
/// — `/tmp` is a tmpfs on this box, and a 256 MiB raw disk there is 256 MiB of RAM.
fn scratch_dir() -> PathBuf {
    let dir = std::env::var("REDFISH_PROOF_SCRATCH").map(PathBuf::from).unwrap_or_else(|_| {
        PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
            .join("scratch/draupnir-redfish")
    });
    let _ = std::fs::create_dir_all(&dir);
    dir
}

/// Report a missing prerequisite by name and return `true` (skip). Never silent.
fn missing(what: &str, path: &str) -> bool {
    eprintln!(
        "SKIP redfish_kvm_loop_proof: {what} not present at `{path}` — this proof did NOT run"
    );
    true
}

fn prerequisites_absent(iso: &str) -> bool {
    if !Path::new("/dev/kvm").exists() {
        return missing("/dev/kvm", "/dev/kvm");
    }
    if !Path::new(iso).is_file() {
        return missing("the boot medium (ISO)", iso);
    }
    false
}

const BMC_USER: &str = "redfish-admin";
const BMC_PASS: &str = "loop-proof-secret";

/// A genuinely blank raw disk — the thing an `Hdd` override boots, and the thing that
/// makes the negative leg real. Created with `std::fs`, no `qemu-img` subprocess.
fn blank_disk(tag: &str) -> PathBuf {
    let p = scratch_dir().join(format!("blank-{tag}-{}.raw", std::process::id()));
    let f = std::fs::File::create(&p).expect("create the blank disk");
    f.set_len(256 * 1024 * 1024).expect("size the blank disk");
    drop(f);
    p
}

/// Start the BMC in front of this box's KVM, with a blank local disk as the thing an
/// `Hdd` override would boot.
fn start_bmc(disk: &Path) -> RedfishKvmServer {
    RedfishKvmServer::start(
        NodeConfig::new("System.Embedded.1")
            .credentials(BMC_USER, BMC_PASS)
            .local_disk(disk.to_string_lossy())
            .sized(1024, 2),
    )
    .expect("draupnir's BMC starts")
}

/// The client, with the server's cert PINNED — full TLS verification stays on and
/// that one cert is the sole trusted root. This is the posture draupnir uses against
/// a real BMC, and it is why the server had to grow a real TLS identity at all.
fn pinned_client(server: &RedfishKvmServer) -> RedfishBoot {
    RedfishBoot::new()
        .with_password(BMC_PASS)
        .pin_cert_pem(server.cert_pem().as_bytes().to_vec())
}

fn dump_console(label: &str, console: &str) {
    println!("---------------- {label} ({} bytes) ----------------", console.len());
    println!("{console}");
    println!("---------------- END {label} ----------------");
}

// ===========================================================================
// THE LOOP
// ===========================================================================

/// **Boot a real ISO on a real machine, driven entirely over Redfish.**
#[test]
#[ignore = "REAL BOOT: needs /dev/kvm, qemu-system-x86_64, OVMF and an ISO on disk"]
fn an_iso_booted_through_the_redfish_loop_reaches_its_serving_marker() {
    let iso = proof_iso();
    if prerequisites_absent(&iso) {
        return;
    }
    let disk = blank_disk("loop");
    let server = start_bmc(&disk);
    let bmc = server.bmc_endpoint();
    println!("BMC: {} system={}", server.base_url(), bmc.system_id);

    // ONE spec — the SAME value `iso_boot` builds for a local KVM boot, routed to
    // "metal". Nothing about QEMU, nothing about this server: honesty rule 2, so that
    // "it works in the demo" and "it works on the customer's BMC" are the same claim
    // about the same value.
    let spec = BootSpec::iso_boot("iso-redfish-sim", &iso).on_metal(bmc.clone());
    spec.validate().expect("the medium spec is well formed");
    assert_emit!(
        "one-spec",
        spec.boot_order == BootOrder::Medium && spec.medium_path() == Some(iso.as_str()),
        "the spec routed to Redfish still says boot off `{iso}` (order {:?})",
        spec.boot_order
    );

    // The whole burn, through draupnir's own Redfish CLIENT: InsertMedia, the
    // one-time boot override, ComputerSystem.Reset {On}.
    let client = pinned_client(&server);
    let machine = client
        .boot(&spec)
        .expect("the Redfish client drives the burn against draupnir's BMC");
    println!("BURNED via Redfish: machine id={}", machine.id);

    // The BMC really mounted THIS ISO, and really translated the override.
    assert_emit!(
        "insert-media",
        server.inserted_image().as_deref() == Some(iso.as_str()),
        "VirtualMedia.Image == `{:?}`",
        server.inserted_image()
    );
    let applied = server
        .last_boot_spec()
        .expect("the BMC handed its backend a spec");
    assert_emit!(
        "override-applied",
        applied.boot_order == BootOrder::Medium && applied.medium_path() == Some(iso.as_str()),
        "the BMC booted order={:?} medium={:?}",
        applied.boot_order,
        applied.medium_path()
    );

    // ── the machine ──────────────────────────────────────────────────────────
    // Half 1: the FIRMWARE really chose the CD. Any bootable ISO prints this and a
    // disk boot never does.
    let firmware = server
        .await_serial_marker(MEDIUM_MARKER, Duration::from_secs(60), Duration::from_millis(250))
        .expect("the guest the BMC booted is addressable");
    // Half 2: the system that came off it reached USERSPACE and is serving.
    let marker = proof_marker();
    let serving = server
        .await_serial_marker(&marker, Duration::from_secs(120), Duration::from_millis(250))
        .expect("the guest is still addressable");

    // A thing that keeps running is the NORMAL case: observe it is STILL up, and
    // never wait for it to exit — read the power state back OVER REDFISH, through the
    // client's Lifecycle, so the readback is part of the loop too.
    let bound = RedfishBoot::for_node(bmc.clone(), BMC_PASS)
        .pin_cert_pem(server.cert_pem().as_bytes().to_vec());
    let power = bound.status(&machine);

    let console = server.serial_log().unwrap_or_default();
    dump_console("SERIAL CONSOLE (redfish loop)", &console);
    println!("FIRMWARE: {}", firmware.detail());
    println!("SERVING : {}", serving.detail());
    println!("POWER   : {power:?} (read back over Redfish)");

    // Tear the machine down BEFORE asserting, so a failed assertion never leaks a
    // QEMU process — and do it over Redfish (ComputerSystem.Reset {ForceOff}).
    let _ = bound.power_off(&machine);

    assert_emit!(
        "firmware-off-medium",
        firmware.saw_marker(),
        "{}",
        firmware.detail()
    );
    assert_emit!(
        "appliance-serving",
        serving.saw_marker(),
        "{}",
        serving.detail()
    );
    if let Seen::Marker { line, after } = &serving {
        println!("PROOF: booted off the medium THROUGH REDFISH and served in {after:?}");
        println!("PROOF: `{}`", line.trim());
    }
    assert_emit!(
        "power-readback",
        power.as_ref() == Ok(&PowerState::On),
        "ComputerSystem.PowerState read over Redfish = {power:?}"
    );
    // A real capture, not an empty string that trivially contains nothing — an empty
    // console would make every negative assertion vacuous.
    assert_emit!(
        "console-non-vacuous",
        console.len() > 4096,
        "{} bytes of real serial console",
        console.len()
    );
    let _ = std::fs::remove_file(&disk);
}

/// **RED — the boot override is APPLIED OUTPUT, not a `204` on the wire.**
///
/// The positive proof above is worth exactly what this is worth. Here the ISO stays
/// **in the tray** — `VirtualMedia.Inserted` is still `true` throughout — and only the
/// `BootSourceOverrideTarget` changes, from `Cd` to `Hdd`. If the server merely
/// acknowledged the PATCH and booted what it always boots, the appliance would come
/// up and this would fail.
///
/// The disk it boots instead is genuinely blank, which is a sabotage that cannot be
/// reverted away: there is nothing on it to run.
#[test]
#[ignore = "REAL BOOT: needs /dev/kvm, qemu-system-x86_64 and OVMF"]
fn an_hdd_override_really_stops_the_machine_booting_the_medium_in_its_tray() {
    let iso = proof_iso();
    if prerequisites_absent(&iso) {
        return;
    }
    let disk = blank_disk("hdd-red");
    let server = start_bmc(&disk);
    let bmc = server.bmc_endpoint();
    let client = pinned_client(&server);

    // 1. Insert the SAME appliance ISO that boots green above.
    client
        .insert_media(&bmc, &iso)
        .expect("VirtualMedia.InsertMedia");
    assert_emit!(
        "red-tray-loaded",
        server.inserted_image().as_deref() == Some(iso.as_str()),
        "the appliance ISO is in the tray: {:?}",
        server.inserted_image()
    );

    // 2. Override to Hdd — the ONLY thing that differs from the green leg.
    client
        .set_boot_override(&bmc, BootTarget::Hdd)
        .expect("the Boot override PATCH is accepted");

    // 3. Power on over Redfish.
    let bound = RedfishBoot::for_node(bmc.clone(), BMC_PASS)
        .pin_cert_pem(server.cert_pem().as_bytes().to_vec());
    let machine = draupnir::Machine {
        id: bmc.system_id.clone(),
        spec_name: "iso-redfish-sim-red".into(),
        kind: draupnir::Kind::BareMetal,
        power: PowerState::Unknown,
    };
    bound
        .power_on(&machine)
        .expect("ComputerSystem.Reset {On} is accepted");

    // 4. OBSERVE EVERYTHING FIRST, then assert — so a sabotaged server is caught at
    //    BOTH levels (the spec it chose AND what the machine did) instead of the
    //    cheaper spec check hiding the console evidence behind an early panic.
    let applied = server.last_boot_spec().expect("the BMC booted something");
    let tray_still_loaded = server.inserted_image();
    // Wait on a deadline, never on exit.
    let seen = server
        .await_serial_marker(&proof_marker(), Duration::from_secs(30), Duration::from_millis(500))
        .expect("the guest is addressable");
    let console = server.serial_log().unwrap_or_default();
    dump_console("BLANK-DISK CONSOLE (hdd override)", &console);
    println!("SEEN: {}", seen.detail());
    let _ = server.machine().map(|m| bound.power_off(&m));

    // The spec the BMC actually handed KVM: Disk order, and NO medium.
    assert_emit!(
        "red-spec-detached",
        applied.boot_order == BootOrder::Disk && applied.medium_path().is_none(),
        "the Hdd override produced order={:?} medium={:?}",
        applied.boot_order,
        applied.medium_path()
    );
    // ...while the tray is STILL loaded. It is the boot that stopped using it.
    assert_emit!(
        "red-tray-still-loaded",
        tray_still_loaded.as_deref() == Some(iso.as_str()),
        "VirtualMedia.Inserted stayed true: {tray_still_loaded:?}"
    );

    // The detail reads truthfully in BOTH directions: a green row says what was
    // absent, a red row says what appeared. A row whose detail only made sense when
    // it failed would be a row nobody could read when it passed.
    assert_emit!(
        "red-no-appliance",
        !seen.saw_marker(),
        "with the Hdd override the appliance marker `{}` {}",
        proof_marker(),
        if seen.saw_marker() {
            format!("APPEARED — the override was not applied and the green leg proves nothing: {}", seen.detail())
        } else {
            format!("never appeared: {}", seen.detail())
        }
    );
    assert_emit!(
        "red-no-medium-line",
        !console.contains(MEDIUM_MARKER),
        "the firmware {} name `{MEDIUM_MARKER}` — the medium {} detached by the override",
        if console.contains(MEDIUM_MARKER) { "STILL did" } else { "never did" },
        if console.contains(MEDIUM_MARKER) { "was NOT" } else { "was" }
    );
    assert_emit!(
        "red-no-banner",
        !console.contains("GUNNAR APPLIANCE"),
        "the blank disk {} the appliance banner",
        if console.contains("GUNNAR APPLIANCE") { "PRINTED" } else { "never printed" }
    );
    let _ = std::fs::remove_file(&disk);
}

/// **RED — a `ResetType` the BMC does not advertise is refused BY NAME, and no
/// machine starts.** Needs no KVM: the refusal happens before any backend is touched,
/// and that is the point.
#[test]
fn a_bad_reset_type_is_refused_by_name_and_boots_no_machine() {
    let server = RedfishKvmServer::start(
        NodeConfig::new("System.Embedded.1").credentials(BMC_USER, BMC_PASS),
    )
    .expect("the BMC starts");
    let agent = pinned_agent(&server);
    let url = format!("{}{}", server.base_url(), wire::reset_path("System.Embedded.1"));
    let resp = agent
        .post(&url)
        .header("Authorization", &wire::basic_auth_header(BMC_USER, BMC_PASS))
        .send_json(wire::reset_body("Reboot"))
        .expect("the BMC answers");
    let status = resp.status().as_u16();
    let body: serde_json::Value = serde_json::from_str(
        &resp.into_body().read_to_string().unwrap_or_default(),
    )
    .unwrap_or(serde_json::Value::Null);

    assert_emit!(
        "red-bad-reset-type",
        status == 400
            && body["error"]["code"] == "Base.1.19.0.ActionParameterValueNotInList"
            && body["error"]["message"].as_str().unwrap_or_default().contains("'Reboot'"),
        "HTTP {status} {}",
        body["error"]["message"]
    );
    assert_emit!(
        "red-nothing-booted",
        server.machine().is_none() && server.last_boot_spec().is_none(),
        "no machine was started by the refused reset"
    );
}

/// **RED — `InsertMedia` naming an image that is not there is refused BY NAME**,
/// before anything is mounted. Needs no KVM.
#[test]
fn insert_media_for_an_iso_nobody_built_is_refused_by_name() {
    let server = RedfishKvmServer::start(
        NodeConfig::new("System.Embedded.1").credentials(BMC_USER, BMC_PASS),
    )
    .expect("the BMC starts");
    let ghost = "/nonexistent/never-built-by-anyone.iso";
    let err = pinned_client(&server)
        .insert_media(&server.bmc_endpoint(), ghost)
        .expect_err("an ISO that is not on disk cannot be inserted");
    let msg = err.to_string();
    assert_emit!(
        "red-ghost-media",
        msg.contains("400"),
        "the client saw the BMC's refusal: {msg}"
    );
    assert_emit!(
        "red-tray-empty",
        server.inserted_image().is_none(),
        "nothing was mounted by the refused insert"
    );
}

/// A `ureq` agent pinning the server's cert — used only where a raw status code is
/// the subject (draupnir's client maps a 4xx onto an opaque `Error::Backend`).
fn pinned_agent(server: &RedfishKvmServer) -> ureq::Agent {
    use ureq::tls::{Certificate, RootCerts, TlsConfig};
    let cert = Certificate::from_pem(server.cert_pem().as_bytes()).expect("a well-formed PEM");
    ureq::config::Config::builder()
        .tls_config(TlsConfig::builder().root_certs(RootCerts::from([cert])).build())
        .http_status_as_error(false)
        .build()
        .into()
}