//! **Redfish backend** — provision **bare metal** out-of-band through a BMC.
//!
//! This is the capability Draupnir uniquely owns. A bare-metal node is booted
//! *without* touching its OS: through the node's BMC (iLO / iDRAC / OpenBMC)
//! DMTF **Redfish** REST API we
//!
//! 1. **insert** a bootable ISO as **virtual media** ([`VirtualMedia::insert_media`]),
//! 2. **set the one-time boot override** to that media ([`VirtualMedia::set_boot_override`]),
//! 3. **power the node on** ([`Lifecycle::power_on`], via `ComputerSystem.Reset`).
//!
//! Draupnir does not hand-roll a heavyweight client: the transport is **`ureq`**
//! over **rustls** (pure Rust — no openssl/native-tls, per the charter), driving the
//! handful of Redfish endpoints we need. It sits behind the `backend-redfish`
//! feature so the default build stays pure-std and offline-green.
//!
//! ## Testability
//!
//! The **request shaping** — the resource URLs and the JSON action bodies — is
//! pure and [fixture-tested](tests) with no BMC. Only the actual HTTP send needs a
//! live endpoint; unit tests never touch the network. BMCs almost always ship
//! self-signed certs; the *secure* way to trust one is to **pin** it with
//! [`RedfishBoot::pin_cert_pem`] (rustls verification stays ON, only that cert is
//! trusted). [`RedfishBoot::insecure`] — which disables verification entirely — is a
//! last-resort fall-back only. The BMC secret is also **redacted** in the
//! [`Debug`](std::fmt::Debug) impl so it never leaks to a log.
use crate::{
BmcEndpoint, Boot, BootSpec, BootTarget, Error, Lifecycle, Machine, PowerState,
Result, VirtualMedia,
};
/// The default Redfish `VirtualMedia` resource id an ISO is inserted into (the
/// virtual CD/DVD slot). Overridable via [`RedfishBoot::media_id`].
pub const DEFAULT_MEDIA_ID: &str = "CD";
/// The Redfish bare-metal boot backend.
///
/// Constructed either bare ([`new`](Self::new), then [`with_password`](Self::with_password))
/// or bound to a node ([`for_node`](Self::for_node)). The BMC secret is supplied
/// here **at drive time** — it is deliberately never a field on [`BootSpec`].
///
/// ## TLS trust
///
/// The BMC password is only ever sent over **https** — [`require_https`] rejects a
/// non-https [`BmcEndpoint::host`] *before* the Basic-auth header is attached, so a
/// credential can never leak in cleartext (or to an `http://` typo). BMCs almost
/// always present a self-signed cert; the secure way to trust it is to **pin** that
/// cert with [`pin_cert_pem`](Self::pin_cert_pem) (verification stays ON, only that
/// one cert is trusted). [`insecure`](Self::insecure) — which disables *all* cert
/// verification and thus offers no MITM protection — remains only as a last resort
/// when no pinned cert is obtainable.
#[derive(Default, Clone)]
pub struct RedfishBoot {
/// The BMC account password, supplied at drive time (never in a [`BootSpec`]).
password: String,
/// Last-resort escape hatch: skip **all** TLS certificate verification. Prefer
/// [`pin_cert_pem`](Self::pin_cert_pem). Ignored when a pinned cert is set.
insecure: bool,
/// A pinned BMC certificate (PEM). When set, TLS verification stays ON and this
/// cert is the *sole* trusted root — the secure way to accept a self-signed BMC.
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
pinned_cert_pem: Option<Vec<u8>>,
/// The `VirtualMedia` slot id (defaults to [`DEFAULT_MEDIA_ID`]).
media_id: Option<String>,
/// The node this backend drives, for the [`Lifecycle`] methods (which only get
/// a [`Machine`], not a [`BmcEndpoint`]). `boot()` uses the spec's BMC directly.
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
endpoint: Option<BmcEndpoint>,
}
// Hand-rolled Debug: never render the BMC password or the raw pinned-cert bytes.
// The derived Debug would print the secret verbatim (SECURITY: LENS-4 finding).
impl std::fmt::Debug for RedfishBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedfishBoot")
.field(
"password",
&if self.password.is_empty() {
"<unset>"
} else {
"<redacted>"
},
)
.field("insecure", &self.insecure)
.field(
"pinned_cert_pem",
&self.pinned_cert_pem.as_ref().map(|_| "<pinned>"),
)
.field("media_id", &self.media_id)
.field("endpoint", &self.endpoint)
.finish()
}
}
impl RedfishBoot {
/// Construct the Redfish backend (no credential/endpoint yet).
pub fn new() -> Self {
Self::default()
}
/// Bind the backend to a node + its BMC password so the [`Lifecycle`] methods
/// (which receive only a [`Machine`]) can address it.
pub fn for_node(bmc: BmcEndpoint, password: impl Into<String>) -> Self {
Self {
password: password.into(),
insecure: false,
pinned_cert_pem: None,
media_id: None,
endpoint: Some(bmc),
}
}
/// Set the BMC account password (builder style).
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = password.into();
self
}
/// **Pin** the BMC's certificate (PEM) as the sole trusted root — the *secure*
/// way to accept the self-signed cert BMCs ship: TLS verification stays ON and
/// only this exact cert is trusted, so the connection is still MITM-resistant.
/// Prefer this over [`insecure`](Self::insecure). Takes precedence over it.
pub fn pin_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.pinned_cert_pem = Some(pem.into());
self
}
/// **Last-resort** escape hatch: skip *all* TLS certificate verification. This
/// removes MITM protection — the BMC Basic-auth secret is exposed to any
/// on-path attacker who can present a cert. Prefer [`pin_cert_pem`](Self::pin_cert_pem);
/// a pinned cert overrides this flag. The transport still requires **https**
/// regardless, so the credential is never sent in cleartext.
pub fn insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
/// Override the `VirtualMedia` slot id (default [`DEFAULT_MEDIA_ID`]).
pub fn media_id(mut self, id: impl Into<String>) -> Self {
self.media_id = Some(id.into());
self
}
/// The virtual-media slot this backend inserts into.
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
fn slot(&self) -> &str {
self.media_id.as_deref().unwrap_or(DEFAULT_MEDIA_ID)
}
/// The BMC endpoint + ISO a Redfish spec targets (both required).
fn target<'a>(&self, spec: &'a BootSpec) -> Result<(&'a BmcEndpoint, &'a str)> {
let bmc = spec
.bmc
.as_ref()
.ok_or_else(|| Error::Spec("Redfish boot needs a BMC endpoint".into()))?;
// Read the medium through the ONE shared accessor, so "the medium" means the
// same thing here as it does in the KVM backend: an explicit
// `BootSpec::medium` wins, else an `ImageSource::Iso` payload. Without this
// an `iso_boot(..).on_metal(bmc)` spec — the whole of honesty rule 2 — would
// be understood by one backend and rejected by the other.
match spec.medium_path() {
Some(iso) => Ok((bmc, iso)),
None => Err(Error::Spec(format!(
"Redfish backend boots an ISO medium, got {:?} with no medium",
spec.image
))),
}
}
/// The node the [`Lifecycle`] methods act on (the one bound via
/// [`for_node`](Self::for_node)).
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
fn bound(&self) -> Result<&BmcEndpoint> {
self.endpoint.as_ref().ok_or_else(|| {
Error::Backend(
"no BMC endpoint bound: construct with RedfishBoot::for_node(bmc, password)".into(),
)
})
}
}
impl Boot for RedfishBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
let (bmc, iso) = self.target(spec)?;
#[cfg(feature = "backend-redfish")]
{
// The full bare-metal boot sequence: insert the ISO, override next boot
// to it, power the node on — each an out-of-band Redfish action.
self.insert_media(bmc, iso)?;
// The spec's boot order, translated into Redfish's vocabulary by the
// shared `BootOrder::redfish_target()` — the same field the KVM backend
// renders as `-boot d`. `Auto` (every spec written before boot order
// existed, including `BootSpec::redfish_iso`) keeps the long-standing
// one-time `Cd` override, so this call is unchanged for them.
let target = spec.boot_order.redfish_target().unwrap_or(BootTarget::Cd);
self.set_boot_override(bmc, target)?;
self.reset(bmc, "On")?;
Ok(Machine::started(&bmc.system_id, spec))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (bmc, iso);
Err(Error::Unsupported(
"redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
.into(),
))
}
}
}
impl VirtualMedia for RedfishBoot {
fn insert_media(&self, node: &BmcEndpoint, iso: &str) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
let url = virtual_media_action_url(node, self.slot(), "InsertMedia");
self.post(node, &url, &insert_media_body(iso))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (node, iso);
Err(unsupported())
}
}
fn eject_media(&self, node: &BmcEndpoint) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
let url = virtual_media_action_url(node, self.slot(), "EjectMedia");
self.post(node, &url, &serde_json::json!({}))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = node;
Err(unsupported())
}
}
fn set_boot_override(&self, node: &BmcEndpoint, target: BootTarget) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
self.patch(node, &system_url(node), &boot_override_body(target))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (node, target);
Err(unsupported())
}
}
}
impl Lifecycle for RedfishBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.reset(self.bound()?, "On")
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
fn power_off(&self, machine: &Machine) -> Result<()> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.reset(self.bound()?, "ForceOff")
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.query_power(self.bound()?)
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
}
/// The honest "feature not compiled in" error for the un-gated trait methods.
#[cfg(not(feature = "backend-redfish"))]
fn unsupported() -> Error {
Error::Unsupported(
"redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
.into(),
)
}
// ---------------------------------------------------------------------------
// Request shaping — PURE: resource URLs + JSON action bodies. Fixture-tested.
//
// The URL and body vocabulary now lives in the shared [`wire`] module, because
// draupnir owns BOTH ends of this protocol (this client and, behind
// `redfish-server`, [`crate::redfish_server`]). The functions below are the
// client's thin `host` + `path` composition on top of it.
// ---------------------------------------------------------------------------
pub mod wire;
/// The trailing-slash-trimmed Redfish service base for `bmc`.
#[cfg(feature = "backend-redfish")]
fn base(bmc: &BmcEndpoint) -> &str {
bmc.host.trim_end_matches('/')
}
/// The `ComputerSystem` resource URL: `{host}/redfish/v1/Systems/{system_id}`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn system_url(bmc: &BmcEndpoint) -> String {
format!("{}{}", base(bmc), wire::system_path(&bmc.system_id))
}
/// A `VirtualMedia` action URL, e.g.
/// `{host}/redfish/v1/Systems/{system_id}/VirtualMedia/{slot}/Actions/VirtualMedia.{action}`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn virtual_media_action_url(bmc: &BmcEndpoint, slot: &str, action: &str) -> String {
format!(
"{}{}",
base(bmc),
wire::virtual_media_action_path(&bmc.system_id, slot, action)
)
}
/// The `ComputerSystem.Reset` action URL.
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_url(bmc: &BmcEndpoint) -> String {
format!("{}{}", base(bmc), wire::reset_path(&bmc.system_id))
}
/// The `VirtualMedia.InsertMedia` request body — mount `iso` read-only.
#[cfg(feature = "backend-redfish")]
pub(crate) fn insert_media_body(iso: &str) -> serde_json::Value {
wire::insert_media_body(iso)
}
/// The one-time `Boot` override PATCH body for `target`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn boot_override_body(target: BootTarget) -> serde_json::Value {
wire::boot_override_body(target)
}
/// The `ComputerSystem.Reset` body for a Redfish `ResetType` (`On` / `ForceOff` / …).
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_body(reset_type: &str) -> serde_json::Value {
wire::reset_body(reset_type)
}
// The `BootTarget` → `BootSourceOverrideTarget` mapping is `wire::target_str`; the
// client has no wrapper of its own, so there is exactly one place that decides what
// `Cd` is called on the wire.
// ---------------------------------------------------------------------------
// Transport — ureq/rustls (feature `backend-redfish`).
// ---------------------------------------------------------------------------
#[cfg(feature = "backend-redfish")]
impl RedfishBoot {
/// Build a ureq agent honouring the configured TLS trust (pure rustls — no
/// openssl/native-tls). Precedence, most-secure first:
///
/// 1. **pinned cert** ([`pin_cert_pem`](Self::pin_cert_pem)) — verification stays
/// ON and the pinned cert is the *sole* trusted root (MITM-resistant). A
/// malformed pinned PEM is a hard error here rather than a silent fall-back.
/// 2. **insecure** ([`insecure`](Self::insecure)) — all cert verification off
/// (last resort; a pinned cert overrides this).
/// 3. default — the platform/webpki roots (a BMC with a CA-signed cert).
fn agent(&self) -> Result<ureq::Agent> {
use ureq::tls::TlsConfig;
if let Some(pem) = &self.pinned_cert_pem {
use ureq::tls::{Certificate, RootCerts};
let cert = Certificate::from_pem(pem).map_err(|e| {
Error::Backend(format!("redfish: invalid pinned BMC cert PEM: {e}"))
})?;
let tls = TlsConfig::builder()
.root_certs(RootCerts::from([cert]))
.build();
Ok(ureq::config::Config::builder()
.tls_config(tls)
.build()
.into())
} else if self.insecure {
let tls = TlsConfig::builder().disable_verification(true).build();
Ok(ureq::config::Config::builder()
.tls_config(tls)
.build()
.into())
} else {
Ok(ureq::Agent::new_with_defaults())
}
}
/// The HTTP Basic `Authorization` header value for `bmc` + the drive-time secret.
fn auth(&self, bmc: &BmcEndpoint) -> String {
format!(
"Basic {}",
base64(format!("{}:{}", bmc.username, self.password).as_bytes())
)
}
/// POST a Redfish action body to `url`.
fn post(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
self.agent()?
.post(url)
.header("Authorization", &self.auth(bmc))
.send_json(body)
.map_err(|e| Error::Backend(format!("redfish POST {url}: {e}")))?;
Ok(())
}
/// PATCH a Redfish resource at `url`.
fn patch(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
self.agent()?
.patch(url)
.header("Authorization", &self.auth(bmc))
.send_json(body)
.map_err(|e| Error::Backend(format!("redfish PATCH {url}: {e}")))?;
Ok(())
}
/// Drive a `ComputerSystem.Reset` with `reset_type` (`On`/`ForceOff`/…).
fn reset(&self, bmc: &BmcEndpoint, reset_type: &str) -> Result<()> {
self.post(bmc, &reset_url(bmc), &reset_body(reset_type))
}
/// GET the `ComputerSystem` and map its `PowerState` onto [`PowerState`].
fn query_power(&self, bmc: &BmcEndpoint) -> Result<PowerState> {
let url = system_url(bmc);
let mut resp = self
.agent()?
.get(&url)
.header("Authorization", &self.auth(bmc))
.call()
.map_err(|e| Error::Backend(format!("redfish GET {url}: {e}")))?;
let v: serde_json::Value = resp
.body_mut()
.read_json()
.map_err(|e| Error::Backend(format!("redfish decode {url}: {e}")))?;
Ok(match v.get("PowerState").and_then(|p| p.as_str()) {
Some("On") => PowerState::On,
Some("Off") => PowerState::Off,
_ => PowerState::Unknown,
})
}
}
/// Minimal std-only base64 for the Basic-auth header. The implementation lives in
/// [`wire::base64_encode`] alongside its inverse, because the SERVER has to decode
/// exactly what this encodes — an encoder here and a decoder there would be two
/// halves of one alphabet maintained in two places.
#[cfg(feature = "backend-redfish")]
fn base64(input: &[u8]) -> String {
wire::base64_encode(input)
}
#[cfg(all(test, feature = "backend-redfish"))]
mod tests {
use super::*;
fn bmc() -> BmcEndpoint {
BmcEndpoint {
host: "https://bmc-42.dc.example/".into(), // trailing slash to prove trim
username: "admin".into(),
system_id: "System.Embedded.1".into(),
}
}
#[test]
fn system_and_reset_urls_are_well_formed() {
let b = bmc();
assert_eq!(
system_url(&b),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1"
);
assert_eq!(
reset_url(&b),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
);
}
#[test]
fn virtual_media_insert_url_targets_the_cd_slot() {
assert_eq!(
virtual_media_action_url(&bmc(), "CD", "InsertMedia"),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia"
);
}
#[test]
fn insert_media_body_mounts_the_iso_read_only() {
let body = insert_media_body("http://srv/installer.iso");
assert_eq!(body["Image"], "http://srv/installer.iso");
assert_eq!(body["Inserted"], true);
assert_eq!(body["WriteProtected"], true);
}
#[test]
fn boot_override_body_is_one_time_to_cd() {
let body = boot_override_body(BootTarget::Cd);
assert_eq!(body["Boot"]["BootSourceOverrideEnabled"], "Once");
assert_eq!(body["Boot"]["BootSourceOverrideTarget"], "Cd");
}
#[test]
fn boot_targets_map_to_redfish_tokens() {
assert_eq!(wire::target_str(BootTarget::Cd), "Cd");
assert_eq!(wire::target_str(BootTarget::Pxe), "Pxe");
assert_eq!(wire::target_str(BootTarget::Hdd), "Hdd");
assert_eq!(wire::target_str(BootTarget::BiosSetup), "BiosSetup");
}
#[test]
fn reset_body_carries_the_reset_type() {
assert_eq!(reset_body("On")["ResetType"], "On");
assert_eq!(reset_body("ForceOff")["ResetType"], "ForceOff");
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"admin:secret"), "YWRtaW46c2VjcmV0");
}
#[test]
fn auth_header_is_basic_base64_of_user_colon_password() {
let rf = RedfishBoot::new().with_password("secret");
assert_eq!(rf.auth(&bmc()), "Basic YWRtaW46c2VjcmV0");
}
// A well-formed self-signed X509 cert (PEM) — only its *shape* matters here:
// `pin_cert_pem` parses it into a trusted root; no network/validity check runs.
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIUHkyHfhWhNsGjKB2/NlJVedpan9QwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTYm1jLXRlc3QuZGMuZXhhbXBsZTAeFw0yNjA3MTQxNjAw
MTdaFw0zNjA3MTExNjAwMTdaMB4xHDAaBgNVBAMME2JtYy10ZXN0LmRjLmV4YW1w
bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD4nFGHKjOCUyDnJlkR
UHA2JCReXmGhLzLRvlj70Mcs48A5KRVsCPBfq/VlMVv92iSscGQ5Rnn2dpMLbIu0
F+T6zLl1+mAO8HLv+1BZYRhcHyA6slDKvg6K8G3gUNS9poQvzoFZEptacdn/dLSw
LSuEhTLdxHRzdWWxlIbmDTa2OZwhwjGD9KrKyT0ddg386e8KwcmuT+Yq3tbs7ceq
rblHgvd5k9l5e/fwFLpj1XtHb34XILR838Ba4MvL4Tmfjr2OdmqFCkNOOODew5PF
6MICrj5U92Xtq+WJQEIa2Ug8+EgAxOVrafdM84olvzt9+yAJj8YJVhvT8wyc36Sp
o6WZAgMBAAGjUzBRMB0GA1UdDgQWBBSIqmembS5QXMrP67M0oBYVvqdGCTAfBgNV
HSMEGDAWgBSIqmembS5QXMrP67M0oBYVvqdGCTAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCWy1V7aJ+E6E9XjozsqTw2jZ+xCjZ1iUyYmHc2wdOP
RHKcqfSwLwJRyFG5+z8StclGRFL6cR1B+WaZ2ffmUbrQZQBddeFhGQpqzEJ6ba6t
cu1sjQKU7d56MqgyJQ8Kqv09jDQAv6cct1wwjGUm/XqzssPR52diL027wCP9zqAC
Qm7HuDq6y2KiOwV23HB6Csx/DTvzco/q2RGFFod48Ey0SUPk6BTMl0EtipTj92w2
w5sUfZNCMzv/Fq9wEnU/UV6qE0n3HEsfbdqwyWJTkkb/orYoeqz6LXV53B2Z0BgD
VrHskWfBU45Hvxz5BWLMeDtGjgiRNF/WcfL6Ix4Aa5NC
-----END CERTIFICATE-----";
// SECURITY (LENS-4): the Debug impl must NEVER render the BMC secret. RED if the
// derived Debug (which prints `password` verbatim) is ever restored.
#[test]
fn debug_redacts_the_bmc_password() {
let rf = RedfishBoot::new().with_password("hunter2-super-secret");
let dbg = format!("{rf:?}");
assert!(
!dbg.contains("hunter2-super-secret"),
"Debug leaked the BMC password: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"Debug should mark the set password redacted: {dbg}"
);
// An unset password reads as <unset>, still never the empty-string secret.
assert!(format!("{:?}", RedfishBoot::new()).contains("<unset>"));
}
// SECURITY: a pinned cert's raw bytes must not appear in Debug either.
#[test]
fn debug_does_not_dump_the_pinned_cert_bytes() {
let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
let dbg = format!("{rf:?}");
assert!(
dbg.contains("<pinned>"),
"Debug should mark the pinned cert present: {dbg}"
);
assert!(
!dbg.contains("BEGIN CERTIFICATE"),
"Debug dumped raw pinned-cert bytes: {dbg}"
);
}
// A valid pinned cert builds a real agent (verification stays ON, cert trusted).
#[test]
fn pinned_cert_builds_an_agent() {
let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
assert!(
rf.agent().is_ok(),
"a well-formed pinned PEM should build an agent"
);
}
// RED-when-broken: a bogus/mismatched pinned PEM must be REJECTED, not silently
// ignored. If `agent()` ever stops actually parsing the pinned cert (the raw
// salvage no-op), this passes garbage and the test fails.
#[test]
fn a_malformed_pinned_cert_is_rejected() {
let rf = RedfishBoot::new().pin_cert_pem(
b"-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n-----END CERTIFICATE-----".to_vec(),
);
assert!(
matches!(rf.agent(), Err(Error::Backend(_))),
"a malformed pinned cert must be rejected"
);
// Empty PEM: no cert found → also rejected.
assert!(RedfishBoot::new().pin_cert_pem(Vec::new()).agent().is_err());
}
// Pinning takes precedence over insecure (the secure path wins the escape hatch).
#[test]
fn pinned_cert_takes_precedence_over_insecure() {
// insecure alone builds fine...
assert!(RedfishBoot::new().insecure(true).agent().is_ok());
// ...but with a pin ALSO set, the pin path runs — so a bad pin still errors
// even though `insecure(true)` would otherwise have skipped verification.
let rf = RedfishBoot::new()
.insecure(true)
.pin_cert_pem(b"garbage".to_vec());
assert!(
rf.agent().is_err(),
"a pinned cert must take precedence over insecure()"
);
}
#[test]
fn lifecycle_without_a_bound_node_is_an_honest_error() {
let rf = RedfishBoot::new().with_password("x");
let m = Machine {
id: "System.Embedded.1".into(),
spec_name: "n".into(),
kind: crate::Kind::BareMetal,
power: PowerState::Unknown,
};
assert!(matches!(rf.power_on(&m), Err(Error::Backend(_))));
}
// ── THE FLEET WIRE-SHAPE PROOF (appliance brief §4) ──────────────────────
// "A per-site installer ISO built by the Build Thing → N machines booted
// from it": plan_fleet() fans ONE BootSpec into N members, the caller
// binds each member to ITS OWN BMC (target() reads spec.bmc per call —
// plan_fleet clones, it does not bind), and for every member the THREE
// requests the backend would send are exactly the fixtures the seam above
// already proves one at a time: InsertMedia mounting THE per-site ISO
// read-only, a one-time boot override to Cd, and a power-on reset. No BMC
// and no Redfish HTTP mock is reachable on this box (measured 2026-08-13),
// so the live HTTP exchange is deliberately NOT faked here — this pins the
// fleet-level composition of the request shapes, and the doc
// (.nornir/iso-to-fleet.md) states the wall verbatim.
#[cfg(feature = "backend-redfish")]
#[test]
fn a_fleet_of_three_shapes_the_same_burn_against_three_distinct_bmcs() {
use crate::{plan_fleet, BootSpec};
// The per-site ISO the Build Thing mastered — the BMC PULLS a URL
// (Redfish virtual media is a network fetch, never a local path), so
// the operator serves .iso-out/ over http(s) and THIS string is what
// every BMC in the fleet mounts.
let iso_url = "https://depot.site7.example/holger-installer.iso";
let spec = BootSpec::redfish_iso("site7-holger", iso_url, bmc());
let mut members = plan_fleet(&spec, 3);
assert_eq!(members.len(), 3);
// plan_fleet CLONES — every member starts with the template's BMC.
// Distinct machines need distinct BMCs, and that binding is the
// CALLER's move, made explicit here:
for (i, m) in members.iter_mut().enumerate() {
m.bmc = Some(BmcEndpoint {
host: format!("https://bmc-{}.dc.example", i + 1),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
});
}
let rf = RedfishBoot::new().with_password("per-site-secret");
let mut seen_hosts = std::collections::BTreeSet::new();
for (i, member) in members.iter().enumerate() {
// Names stay distinct (the fan-out's own contract).
assert_eq!(member.name, format!("site7-holger-{}", i + 1));
// target() resolves THIS member's BMC + the shared ISO.
let (bmc, iso) = rf.target(member).expect("a bound member resolves");
assert_eq!(iso, iso_url, "every machine burns the SAME per-site ISO");
assert!(seen_hosts.insert(bmc.host.clone()), "each member hits its OWN BMC");
// The three requests, byte-shaped as the seam's fixtures prove:
let insert = virtual_media_action_url(bmc, DEFAULT_MEDIA_ID, "InsertMedia");
assert_eq!(
insert,
format!(
"https://bmc-{}.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia",
i + 1
)
);
let body = insert_media_body(iso);
assert_eq!(body["Image"], iso_url);
assert_eq!(body["WriteProtected"], true, "the install medium is read-only");
let ovr = boot_override_body(BootTarget::Cd);
assert_eq!(ovr["Boot"]["BootSourceOverrideEnabled"], "Once");
assert_eq!(ovr["Boot"]["BootSourceOverrideTarget"], "Cd");
assert_eq!(reset_body("On")["ResetType"], "On");
}
assert_eq!(seen_hosts.len(), 3, "three machines, three BMCs");
}
/// RED: a fanned member whose BMC binding was FORGOTTEN refuses by name —
/// the fleet path must never quietly burn machine 3's ISO into machine 1.
#[cfg(feature = "backend-redfish")]
#[test]
fn a_member_without_its_own_bmc_refuses_instead_of_borrowing_one() {
use crate::{plan_fleet, BootSpec};
let mut spec = BootSpec::redfish_iso("site7-holger", "https://depot/holger.iso", bmc());
spec.bmc = None; // the template never carried a binding
let members = plan_fleet(&spec, 2);
let rf = RedfishBoot::new().with_password("x");
for m in &members {
let err = rf.target(m).unwrap_err();
assert!(
err.to_string().contains("BMC"),
"the refusal names the missing binding: {err}"
);
}
}
}