use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::error::LatticeError;
use crate::traits::{InfrastructureService, NodeHealthReport};
use crate::types::NodeId;
#[derive(Debug, Clone)]
pub struct OpenChamiConfig {
pub smd_base_url: String,
pub bss_base_url: String,
pub auth_token: Option<String>,
pub timeout_secs: u64,
}
pub struct OpenChamiClient {
http: Client,
config: OpenChamiConfig,
}
impl OpenChamiClient {
pub fn new(config: OpenChamiConfig) -> Result<Self, LatticeError> {
let mut builder =
Client::builder().timeout(std::time::Duration::from_secs(config.timeout_secs));
if let Some(ref token) = config.auth_token {
let mut headers = reqwest::header::HeaderMap::new();
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| LatticeError::ConfigError(format!("invalid auth token: {e}")))?;
headers.insert(reqwest::header::AUTHORIZATION, value);
builder = builder.default_headers(headers);
}
let http = builder
.build()
.map_err(|e| LatticeError::Internal(format!("failed to build HTTP client: {e}")))?;
Ok(Self { http, config })
}
async fn get_component_state(&self, node_id: &NodeId) -> Result<SmdComponent, LatticeError> {
let url = format!(
"{}/hsm/v2/State/Components/{}",
self.config.smd_base_url, node_id
);
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| LatticeError::Internal(format!("SMD request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LatticeError::Internal(format!(
"SMD returned {status}: {body}"
)));
}
resp.json::<SmdComponent>()
.await
.map_err(|e| LatticeError::Internal(format!("failed to parse SMD response: {e}")))
}
async fn set_boot_params(
&self,
node_id: &NodeId,
params: &BssBootParams,
) -> Result<(), LatticeError> {
let url = format!("{}/boot/v1/bootparameters", self.config.bss_base_url);
let request = BssBootParamsRequest {
hosts: vec![node_id.clone()],
params: params.clone(),
};
let resp = self
.http
.put(&url)
.json(&request)
.send()
.await
.map_err(|e| LatticeError::Internal(format!("BSS request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LatticeError::Internal(format!(
"BSS returned {status}: {body}"
)));
}
Ok(())
}
async fn power_cycle_node(&self, node_id: &NodeId) -> Result<(), LatticeError> {
let url = format!(
"{}/hsm/v2/State/Components/{}/Actions/PowerCycle",
self.config.smd_base_url, node_id
);
let resp = self
.http
.post(&url)
.json(&serde_json::json!({"ResetType": "ForceRestart"}))
.send()
.await
.map_err(|e| LatticeError::Internal(format!("power cycle request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LatticeError::Internal(format!(
"power cycle returned {status}: {body}"
)));
}
Ok(())
}
}
#[async_trait]
impl InfrastructureService for OpenChamiClient {
async fn boot_node(&self, node_id: &NodeId, image: &str) -> Result<(), LatticeError> {
let params = BssBootParams {
kernel: format!("s3://boot-images/{image}/vmlinuz"),
initrd: format!("s3://boot-images/{image}/initrd.img"),
params: format!("root=live:s3://boot-images/{image}/rootfs quiet"),
};
self.set_boot_params(node_id, ¶ms).await?;
self.power_cycle_node(node_id).await?;
tracing::info!(node = %node_id, image = %image, "initiated node boot via OpenCHAMI");
Ok(())
}
async fn wipe_node(&self, node_id: &NodeId) -> Result<(), LatticeError> {
let wipe_image = "secure-wipe-v1";
self.boot_node(node_id, wipe_image).await?;
tracing::info!(node = %node_id, "initiated secure wipe via OpenCHAMI");
Ok(())
}
async fn query_node_health(&self, node_id: &NodeId) -> Result<NodeHealthReport, LatticeError> {
let component = self.get_component_state(node_id).await?;
let mut issues = Vec::new();
let healthy = match component.state.as_str() {
"Ready" | "On" => true,
"Off" => {
issues.push("node is powered off".to_string());
false
}
"Standby" => {
issues.push("node is in standby".to_string());
false
}
other => {
issues.push(format!("unexpected SMD state: {other}"));
false
}
};
if let Some(ref flag) = component.flag {
if flag != "OK" {
issues.push(format!("SMD flag: {flag}"));
}
}
Ok(NodeHealthReport { healthy, issues })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SmdComponent {
#[serde(rename = "ID")]
pub id: String,
pub state: String,
pub flag: Option<String>,
pub role: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BssBootParams {
pub kernel: String,
pub initrd: String,
pub params: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BssBootParamsRequest {
pub hosts: Vec<String>,
pub params: BssBootParams,
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_client(server: &MockServer) -> OpenChamiClient {
let config = OpenChamiConfig {
smd_base_url: server.uri(),
bss_base_url: server.uri(),
auth_token: None,
timeout_secs: 5,
};
OpenChamiClient::new(config).unwrap()
}
#[tokio::test]
async fn query_node_health_returns_healthy_for_ready_state() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hsm/v2/State/Components/x1000c0s0b0n0"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ID": "x1000c0s0b0n0",
"State": "Ready",
"Flag": "OK",
"Role": "Compute",
"Enabled": true
})))
.mount(&server)
.await;
let client = mock_client(&server);
let report = client
.query_node_health(&"x1000c0s0b0n0".to_string())
.await
.unwrap();
assert!(report.healthy);
assert!(report.issues.is_empty());
}
#[tokio::test]
async fn query_node_health_returns_unhealthy_for_off_state() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hsm/v2/State/Components/x1000c0s0b0n0"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ID": "x1000c0s0b0n0",
"State": "Off",
"Flag": "OK"
})))
.mount(&server)
.await;
let client = mock_client(&server);
let report = client
.query_node_health(&"x1000c0s0b0n0".to_string())
.await
.unwrap();
assert!(!report.healthy);
assert!(report.issues.iter().any(|i| i.contains("powered off")));
}
#[tokio::test]
async fn query_node_health_reports_warning_flag() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hsm/v2/State/Components/x1000c0s0b0n0"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ID": "x1000c0s0b0n0",
"State": "Ready",
"Flag": "Warning"
})))
.mount(&server)
.await;
let client = mock_client(&server);
let report = client
.query_node_health(&"x1000c0s0b0n0".to_string())
.await
.unwrap();
assert!(report.healthy);
assert!(report.issues.iter().any(|i| i.contains("Warning")));
}
#[tokio::test]
async fn query_node_health_handles_smd_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hsm/v2/State/Components/x1000c0s0b0n0"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let client = mock_client(&server);
let result = client.query_node_health(&"x1000c0s0b0n0".to_string()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("404"));
}
#[tokio::test]
async fn boot_node_sets_params_and_power_cycles() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/boot/v1/bootparameters"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path_regex(
r"/hsm/v2/State/Components/.*/Actions/PowerCycle",
))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server);
let result = client
.boot_node(&"x1000c0s0b0n0".to_string(), "ubuntu-22.04")
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn wipe_node_boots_with_secure_wipe_image() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/boot/v1/bootparameters"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path_regex(
r"/hsm/v2/State/Components/.*/Actions/PowerCycle",
))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server);
let result = client.wipe_node(&"x1000c0s0b0n0".to_string()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn client_with_auth_token_is_created_successfully() {
let config = OpenChamiConfig {
smd_base_url: "https://smd.example.com".to_string(),
bss_base_url: "https://bss.example.com".to_string(),
auth_token: Some("test-token-123".to_string()),
timeout_secs: 10,
};
let result = OpenChamiClient::new(config);
assert!(result.is_ok());
}
#[tokio::test]
async fn boot_node_fails_on_bss_error() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/boot/v1/bootparameters"))
.respond_with(ResponseTemplate::new(500).set_body_string("internal error"))
.mount(&server)
.await;
let client = mock_client(&server);
let result = client
.boot_node(&"x1000c0s0b0n0".to_string(), "ubuntu-22.04")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("500"));
}
}