use crate::{
Boot, BmcEndpoint, BootSpec, BootTarget, Error, ImageSource, Lifecycle, Machine, PowerState,
Result, VirtualMedia,
};
pub const DEFAULT_MEDIA_ID: &str = "CD";
#[derive(Debug, Default, Clone)]
pub struct RedfishBoot {
password: String,
insecure: bool,
media_id: Option<String>,
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
endpoint: Option<BmcEndpoint>,
}
impl RedfishBoot {
pub fn new() -> Self {
Self::default()
}
pub fn for_node(bmc: BmcEndpoint, password: impl Into<String>) -> Self {
Self {
password: password.into(),
insecure: false,
media_id: None,
endpoint: Some(bmc),
}
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = password.into();
self
}
pub fn insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn media_id(mut self, id: impl Into<String>) -> Self {
self.media_id = Some(id.into());
self
}
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
fn slot(&self) -> &str {
self.media_id.as_deref().unwrap_or(DEFAULT_MEDIA_ID)
}
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()))?;
match &spec.image {
ImageSource::Iso(iso) => Ok((bmc, iso.as_str())),
other => Err(Error::Spec(format!(
"Redfish backend boots an ISO, got {other:?}"
))),
}
}
#[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")]
{
self.insert_media(bmc, iso)?;
self.set_boot_override(bmc, BootTarget::Cd)?;
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())
}
}
}
#[cfg(not(feature = "backend-redfish"))]
fn unsupported() -> Error {
Error::Unsupported(
"redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
.into(),
)
}
#[cfg(feature = "backend-redfish")]
fn base(bmc: &BmcEndpoint) -> &str {
bmc.host.trim_end_matches('/')
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn system_url(bmc: &BmcEndpoint) -> String {
format!("{}/redfish/v1/Systems/{}", base(bmc), bmc.system_id)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn virtual_media_action_url(bmc: &BmcEndpoint, slot: &str, action: &str) -> String {
format!(
"{}/redfish/v1/Systems/{}/VirtualMedia/{}/Actions/VirtualMedia.{}",
base(bmc),
bmc.system_id,
slot,
action
)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_url(bmc: &BmcEndpoint) -> String {
format!(
"{}/redfish/v1/Systems/{}/Actions/ComputerSystem.Reset",
base(bmc),
bmc.system_id
)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn insert_media_body(iso: &str) -> serde_json::Value {
serde_json::json!({ "Image": iso, "Inserted": true, "WriteProtected": true })
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn boot_override_body(target: BootTarget) -> serde_json::Value {
serde_json::json!({
"Boot": {
"BootSourceOverrideEnabled": "Once",
"BootSourceOverrideTarget": target_str(target),
}
})
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_body(reset_type: &str) -> serde_json::Value {
serde_json::json!({ "ResetType": reset_type })
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn target_str(target: BootTarget) -> &'static str {
match target {
BootTarget::Cd => "Cd",
BootTarget::Pxe => "Pxe",
BootTarget::Hdd => "Hdd",
BootTarget::BiosSetup => "BiosSetup",
}
}
#[cfg(feature = "backend-redfish")]
impl RedfishBoot {
fn agent(&self) -> ureq::Agent {
if self.insecure {
use ureq::tls::TlsConfig;
ureq::config::Config::builder()
.tls_config(TlsConfig::builder().disable_verification(true).build())
.build()
.into()
} else {
ureq::Agent::new_with_defaults()
}
}
fn auth(&self, bmc: &BmcEndpoint) -> String {
format!("Basic {}", base64(format!("{}:{}", bmc.username, self.password).as_bytes()))
}
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(())
}
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(())
}
fn reset(&self, bmc: &BmcEndpoint, reset_type: &str) -> Result<()> {
self.post(bmc, &reset_url(bmc), &reset_body(reset_type))
}
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,
})
}
}
#[cfg(feature = "backend-redfish")]
fn base64(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
out.push(T[((n >> 18) & 63) as usize] as char);
out.push(T[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
#[cfg(all(test, feature = "backend-redfish"))]
mod tests {
use super::*;
fn bmc() -> BmcEndpoint {
BmcEndpoint {
host: "https://bmc-42.dc.example/".into(), 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!(target_str(BootTarget::Cd), "Cd");
assert_eq!(target_str(BootTarget::Pxe), "Pxe");
assert_eq!(target_str(BootTarget::Hdd), "Hdd");
assert_eq!(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");
}
#[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(),
backend: crate::Backend::Redfish,
power: PowerState::Unknown,
};
assert!(matches!(rf.power_on(&m), Err(Error::Backend(_))));
}
}