photoncloud-monorepo/deployer/crates/deployer-ctl/src/power.rs

372 lines
12 KiB
Rust

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<u8> {
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<u8> {
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<u8> {
format!(
"{}nodes/{}/observed-system",
cluster_prefix(cluster_namespace, cluster_id),
node_id
)
.into_bytes()
}
fn chainfire_endpoints(raw: &str) -> Vec<String> {
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<Self> {
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<String>,
password: Option<String>,
insecure: bool,
}
#[derive(Debug, Deserialize)]
struct RedfishSystemView {
#[serde(rename = "PowerState")]
power_state: Option<String>,
}
impl RedfishTarget {
fn parse(reference: &str) -> Result<Self> {
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<Url> {
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<PowerState> {
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<PowerState> {
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<PowerState> {
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<u8>)> {
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::<ClusterNodeRecord>(&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<Mutex<Vec<String>>>,
}
async fn system_handler() -> Json<Value> {
Json(json!({ "PowerState": "On" }))
}
async fn reset_handler(
State(state): State<TestState>,
Json(payload): Json<Value>,
) -> 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();
}
}