372 lines
12 KiB
Rust
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();
|
|
}
|
|
}
|