use anyhow::{Context, Result}; use chainfire_client::Client; use deployer_types::{ClusterNodeRecord, InstallState, PowerState}; use reqwest::{Client as HttpClient, Url}; use serde::Deserialize; use serde_json::json; fn cluster_prefix(cluster_namespace: &str, cluster_id: &str) -> String { format!("{}/clusters/{}/", cluster_namespace, cluster_id) } fn key_node(cluster_namespace: &str, cluster_id: &str, node_id: &str) -> Vec { format!( "{}nodes/{}", cluster_prefix(cluster_namespace, cluster_id), node_id ) .into_bytes() } fn key_desired_system(cluster_namespace: &str, cluster_id: &str, node_id: &str) -> Vec { format!( "{}nodes/{}/desired-system", cluster_prefix(cluster_namespace, cluster_id), node_id ) .into_bytes() } fn key_observed_system(cluster_namespace: &str, cluster_id: &str, node_id: &str) -> Vec { format!( "{}nodes/{}/observed-system", cluster_prefix(cluster_namespace, cluster_id), node_id ) .into_bytes() } fn chainfire_endpoints(raw: &str) -> Vec { raw.split(',') .map(str::trim) .filter(|endpoint| !endpoint.is_empty()) .map(ToOwned::to_owned) .collect() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PowerAction { On, Off, Cycle, Refresh, } impl PowerAction { fn parse(value: &str) -> Result { match value { "on" => Ok(Self::On), "off" => Ok(Self::Off), "cycle" => Ok(Self::Cycle), "refresh" => Ok(Self::Refresh), other => Err(anyhow::anyhow!("unsupported power action {}", other)), } } fn reset_type(self) -> Option<&'static str> { match self { Self::On => Some("On"), Self::Off => Some("ForceOff"), Self::Cycle => Some("PowerCycle"), Self::Refresh => None, } } } #[derive(Debug)] struct RedfishTarget { resource_url: Url, username: Option, password: Option, insecure: bool, } #[derive(Debug, Deserialize)] struct RedfishSystemView { #[serde(rename = "PowerState")] power_state: Option, } impl RedfishTarget { fn parse(reference: &str) -> Result { let rewritten = if let Some(rest) = reference.strip_prefix("redfish+http://") { format!("http://{rest}") } else if let Some(rest) = reference.strip_prefix("redfish+https://") { format!("https://{rest}") } else if let Some(rest) = reference.strip_prefix("redfish://") { format!("https://{rest}") } else { return Err(anyhow::anyhow!( "unsupported BMC reference {}; expected redfish:// or redfish+http(s)://", reference )); }; let mut resource_url = Url::parse(&rewritten) .with_context(|| format!("failed to parse BMC reference {}", reference))?; let insecure = resource_url .query_pairs() .any(|(key, value)| key == "insecure" && (value == "1" || value == "true")); let username = if resource_url.username().is_empty() { None } else { Some(resource_url.username().to_string()) }; let password = resource_url.password().map(ToOwned::to_owned); let system_path = normalize_redfish_system_path(resource_url.path()); resource_url .set_username("") .map_err(|_| anyhow::anyhow!("failed to clear username from BMC reference"))?; resource_url .set_password(None) .map_err(|_| anyhow::anyhow!("failed to clear password from BMC reference"))?; resource_url.set_query(None); resource_url.set_path(&system_path); Ok(Self { resource_url, username, password, insecure, }) } fn action_url(&self) -> Result { let mut action_url = self.resource_url.clone(); let path = format!( "{}/Actions/ComputerSystem.Reset", self.resource_url.path().trim_end_matches('/') ); action_url.set_path(&path); Ok(action_url) } async fn perform(&self, action: PowerAction) -> Result { let client = HttpClient::builder() .danger_accept_invalid_certs(self.insecure) .build() .context("failed to create Redfish client")?; if let Some(reset_type) = action.reset_type() { let request = self .with_auth(client.post(self.action_url()?)) .json(&json!({ "ResetType": reset_type })); request .send() .await .context("failed to send Redfish reset request")? .error_for_status() .context("Redfish reset request failed")?; } match action { PowerAction::Cycle => Ok(PowerState::Cycling), PowerAction::On | PowerAction::Off | PowerAction::Refresh => self.refresh(&client).await, } } async fn refresh(&self, client: &HttpClient) -> Result { let response = self .with_auth(client.get(self.resource_url.clone())) .send() .await .context("failed to query Redfish system resource")? .error_for_status() .context("Redfish system query failed")?; let system: RedfishSystemView = response .json() .await .context("failed to decode Redfish system response")?; map_redfish_power_state(system.power_state.as_deref()) } fn with_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { match self.username.as_deref() { Some(username) => request.basic_auth(username, self.password.clone()), None => request, } } } fn normalize_redfish_system_path(path: &str) -> String { let trimmed = path.trim(); if trimmed.is_empty() || trimmed == "/" { return "/redfish/v1/Systems/System.Embedded.1".to_string(); } if trimmed.starts_with("/redfish/") { return trimmed.to_string(); } format!("/redfish/v1/Systems/{}", trimmed.trim_start_matches('/')) } fn map_redfish_power_state(value: Option<&str>) -> Result { match value.unwrap_or("Unknown").to_ascii_lowercase().as_str() { "on" => Ok(PowerState::On), "off" => Ok(PowerState::Off), "poweringon" | "poweringoff" | "cycling" => Ok(PowerState::Cycling), "unknown" => Ok(PowerState::Unknown), other => Err(anyhow::anyhow!("unsupported Redfish power state {}", other)), } } async fn load_node_record( endpoint: &str, cluster_namespace: &str, cluster_id: &str, node_id: &str, ) -> Result<(Client, ClusterNodeRecord, Vec)> { let endpoints = chainfire_endpoints(endpoint); let mut last_error = None; for endpoint in endpoints { match Client::connect(endpoint.clone()).await { Ok(mut client) => { let key = key_node(cluster_namespace, cluster_id, node_id); let Some(bytes) = client.get(&key).await? else { return Err(anyhow::anyhow!("node {} not found", node_id)); }; let node = serde_json::from_slice::(&bytes) .context("failed to decode node record")?; return Ok((client, node, key)); } Err(error) => last_error = Some(anyhow::Error::new(error)), } } Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no Chainfire endpoints configured"))) } pub async fn power_node( endpoint: &str, cluster_namespace: &str, cluster_id: &str, node_id: &str, action: &str, ) -> Result<()> { let action = PowerAction::parse(action)?; let (mut client, mut node, key) = load_node_record(endpoint, cluster_namespace, cluster_id, node_id).await?; let bmc_ref = node .bmc_ref .clone() .with_context(|| format!("node {} does not have a bmc_ref", node_id))?; let target = RedfishTarget::parse(&bmc_ref)?; let power_state = target.perform(action).await?; node.power_state = Some(power_state); client.put(&key, &serde_json::to_vec(&node)?).await?; println!("{}", serde_json::to_string_pretty(&node)?); Ok(()) } pub async fn request_reinstall( endpoint: &str, cluster_namespace: &str, cluster_id: &str, node_id: &str, power_cycle: bool, ) -> Result<()> { let (mut client, mut node, key) = load_node_record(endpoint, cluster_namespace, cluster_id, node_id).await?; node.state = Some("provisioning".to_string()); node.install_state = Some(InstallState::ReinstallRequested); if power_cycle { let bmc_ref = node .bmc_ref .clone() .with_context(|| format!("node {} does not have a bmc_ref", node_id))?; let target = RedfishTarget::parse(&bmc_ref)?; node.power_state = Some(target.perform(PowerAction::Cycle).await?); } client.put(&key, &serde_json::to_vec(&node)?).await?; client .delete(&key_desired_system(cluster_namespace, cluster_id, node_id)) .await?; client .delete(&key_observed_system(cluster_namespace, cluster_id, node_id)) .await?; println!("{}", serde_json::to_string_pretty(&node)?); Ok(()) } #[cfg(test)] mod tests { use super::*; use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; use serde_json::Value; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; #[test] fn parse_redfish_short_reference_defaults_to_https() { let parsed = RedfishTarget::parse("redfish://lab-bmc/node01").unwrap(); assert_eq!(parsed.resource_url.as_str(), "https://lab-bmc/redfish/v1/Systems/node01"); } #[test] fn parse_redfish_explicit_http_reference_keeps_query_flags_local() { let parsed = RedfishTarget::parse("redfish+http://user:pass@127.0.0.1/system-1?insecure=1").unwrap(); assert_eq!( parsed.resource_url.as_str(), "http://127.0.0.1/redfish/v1/Systems/system-1" ); assert_eq!(parsed.username.as_deref(), Some("user")); assert_eq!(parsed.password.as_deref(), Some("pass")); assert!(parsed.insecure); } #[tokio::test] async fn redfish_adapter_refreshes_and_resets_power() { #[derive(Clone, Default)] struct TestState { seen_payloads: Arc>>, } async fn system_handler() -> Json { Json(json!({ "PowerState": "On" })) } async fn reset_handler( State(state): State, Json(payload): Json, ) -> StatusCode { state .seen_payloads .lock() .unwrap() .push(payload.to_string()); StatusCode::NO_CONTENT } let state = TestState::default(); let app = Router::new() .route("/redfish/v1/Systems/node01", get(system_handler)) .route( "/redfish/v1/Systems/node01/Actions/ComputerSystem.Reset", post(reset_handler), ) .with_state(state.clone()); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let target = RedfishTarget::parse(&format!( "redfish+http://{}/redfish/v1/Systems/node01", addr )) .unwrap(); assert_eq!(target.perform(PowerAction::Refresh).await.unwrap(), PowerState::On); assert_eq!(target.perform(PowerAction::Off).await.unwrap(), PowerState::On); let payloads = state.seen_payloads.lock().unwrap().clone(); assert_eq!(payloads, vec![r#"{"ResetType":"ForceOff"}"#.to_string()]); server.abort(); } }