use anyhow::{Context, bail};
use serde::de::DeserializeOwned;
mod auth;
mod boot_parameters;
mod clusters;
mod configurations;
mod console;
mod ephemeral_env;
mod groups;
mod hardware;
mod hw_cluster;
mod images;
mod kernel_parameters;
mod migrate;
mod nodes;
mod power;
mod redfish_endpoints;
mod sat_file;
mod sessions;
mod templates;
#[derive(Debug)]
pub struct MantaClient {
client: reqwest::Client,
base_url: String,
site_name: String,
}
impl MantaClient {
pub fn new(server_url: &str, site_name: &str) -> anyhow::Result<Self> {
let normalized = if server_url.starts_with("http://")
|| server_url.starts_with("https://")
{
server_url.to_owned()
} else {
format!("http://{server_url}")
};
let client = reqwest::Client::builder()
.build()
.context("Failed to build HTTP client")?;
Ok(Self {
client,
base_url: format!("{}/api/v1", normalized.trim_end_matches('/')),
site_name: site_name.to_owned(),
})
}
pub(super) fn log_request_as_curl(builder: &reqwest::RequestBuilder) {
if !tracing::enabled!(tracing::Level::DEBUG) {
return;
}
let Some(cloned) = builder.try_clone() else {
return;
};
let Ok(req) = cloned.build() else {
return;
};
tracing::debug!(
"curl equivalent (secrets replaced with <REDACTED>):\n{}",
format_request_as_curl(&req)
);
}
pub(super) async fn parse_json<T: DeserializeOwned>(
resp: reqwest::Response,
) -> anyhow::Result<T> {
if resp.status().is_success() {
resp
.json::<T>()
.await
.context("Failed to parse response JSON")
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Server returned {status}: {body}")
}
}
pub(super) async fn parse_no_content(
resp: reqwest::Response,
) -> anyhow::Result<()> {
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Server returned {status}: {body}")
}
}
pub(super) async fn get_json<T: DeserializeOwned>(
&self,
token: &str,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.get(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.query(query);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP GET failed")?;
Self::parse_json(resp).await
}
pub(super) async fn post_json<T: DeserializeOwned>(
&self,
token: &str,
path: &str,
body: &impl serde::Serialize,
) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.post(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.json(body);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP POST failed")?;
Self::parse_json(resp).await
}
pub(super) async fn put_no_content(
&self,
token: &str,
path: &str,
body: &impl serde::Serialize,
) -> anyhow::Result<()> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.put(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.json(body);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP PUT failed")?;
Self::parse_no_content(resp).await
}
pub(super) async fn delete_no_content(
&self,
token: &str,
path: &str,
) -> anyhow::Result<()> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.delete(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP DELETE failed")?;
Self::parse_no_content(resp).await
}
pub(super) async fn delete_no_content_with_query(
&self,
token: &str,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<()> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.delete(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.query(query);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP DELETE failed")?;
Self::parse_no_content(resp).await
}
pub(super) async fn delete_no_content_with_body(
&self,
token: &str,
path: &str,
body: &impl serde::Serialize,
) -> anyhow::Result<()> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.delete(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.json(body);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP DELETE failed")?;
Self::parse_no_content(resp).await
}
pub(super) async fn delete_json_with_body<T: DeserializeOwned>(
&self,
token: &str,
path: &str,
body: &impl serde::Serialize,
) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.delete(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.json(body);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP DELETE failed")?;
Self::parse_json(resp).await
}
pub(super) async fn delete_json_with_query<T: DeserializeOwned>(
&self,
token: &str,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let builder = self
.client
.delete(&url)
.bearer_auth(token)
.header("X-Manta-Site", &self.site_name)
.query(query);
Self::log_request_as_curl(&builder);
let resp = builder.send().await.context("HTTP DELETE failed")?;
Self::parse_json(resp).await
}
pub(super) fn http_client(&self) -> &reqwest::Client {
&self.client
}
pub(super) fn base_url(&self) -> &str {
&self.base_url
}
pub(super) fn site_name(&self) -> &str {
&self.site_name
}
}
#[derive(Default)]
pub(super) struct QueryBuilder {
pairs: Vec<(&'static str, String)>,
}
impl QueryBuilder {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn opt(
mut self,
name: &'static str,
value: &Option<String>,
) -> Self {
if let Some(v) = value {
self.pairs.push((name, v.clone()));
}
self
}
pub(super) fn opt_display<T: ToString>(
mut self,
name: &'static str,
value: &Option<T>,
) -> Self {
if let Some(v) = value {
self.pairs.push((name, v.to_string()));
}
self
}
pub(super) fn vec(mut self, name: &'static str, items: &[String]) -> Self {
if !items.is_empty() {
self.pairs.push((name, items.join(",")));
}
self
}
pub(super) fn flag(mut self, name: &'static str, value: bool) -> Self {
if value {
self.pairs.push((name, "true".to_string()));
}
self
}
pub(super) fn pair(mut self, name: &'static str, value: String) -> Self {
self.pairs.push((name, value));
self
}
pub(super) fn build(self) -> Vec<(&'static str, String)> {
self.pairs
}
}
fn format_request_as_curl(req: &reqwest::Request) -> String {
let mut out = format!(" curl -k -X {} '{}'", req.method(), req.url());
for (name, value) in req.headers() {
let raw = value.to_str().unwrap_or("<binary>");
let rendered = if name == reqwest::header::AUTHORIZATION {
if raw.starts_with("Bearer ") {
"Bearer <REDACTED>".to_string()
} else {
"<REDACTED>".to_string()
}
} else {
raw.to_string()
};
out.push_str(&format!(" \\\n -H '{name}: {rendered}'"));
}
if let Some(body_bytes) = req.body().and_then(reqwest::Body::as_bytes) {
let body_str = std::str::from_utf8(body_bytes).unwrap_or("<binary>");
let redacted = redact_json_secrets(body_str);
out.push_str(&format!(" \\\n --data-raw '{redacted}'"));
}
out
}
fn redact_json_secrets(body: &str) -> String {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
};
redact_value(&mut value);
serde_json::to_string(&value).unwrap_or_else(|_| body.to_string())
}
fn redact_value(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(map) => {
for (k, val) in map.iter_mut() {
if matches!(k.as_str(), "password" | "token") {
*val = serde_json::Value::String("<REDACTED>".to_string());
} else {
redact_value(val);
}
}
}
serde_json::Value::Array(arr) => {
for item in arr {
redact_value(item);
}
}
_ => {}
}
}
pub(super) fn ws_base_url(http_url: &str) -> String {
if let Some(rest) = http_url.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = http_url.strip_prefix("http://") {
format!("ws://{rest}")
} else {
http_url.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_base_url_promotes_https_to_wss() {
assert_eq!(
ws_base_url("https://manta.example:8443"),
"wss://manta.example:8443"
);
}
#[test]
fn ws_base_url_promotes_http_to_ws() {
assert_eq!(ws_base_url("http://localhost:8080"), "ws://localhost:8080");
}
#[test]
fn ws_base_url_passes_through_unknown_scheme() {
assert_eq!(ws_base_url("ftp://example"), "ftp://example");
assert_eq!(ws_base_url(""), "");
}
#[test]
fn ws_base_url_preserves_path_and_query() {
assert_eq!(
ws_base_url("https://h.example/api/v1?x=1"),
"wss://h.example/api/v1?x=1"
);
}
#[test]
fn redact_replaces_password_and_token_at_top_level() {
let body = r#"{"username":"alice","password":"hunter2"}"#;
let out = redact_json_secrets(body);
assert!(out.contains("\"username\":\"alice\""));
assert!(out.contains("\"password\":\"<REDACTED>\""));
assert!(!out.contains("hunter2"));
}
#[test]
fn redact_replaces_token_field() {
let body = r#"{"token":"eyJhbGciOi..."}"#;
let out = redact_json_secrets(body);
assert!(out.contains("\"token\":\"<REDACTED>\""));
assert!(!out.contains("eyJ"));
}
#[test]
fn redact_walks_into_nested_objects() {
let body = r#"{"outer":{"password":"x"},"inner":{"deep":{"token":"y"}}}"#;
let out = redact_json_secrets(body);
assert!(!out.contains("\"x\""));
assert!(!out.contains("\"y\""));
assert_eq!(out.matches("<REDACTED>").count(), 2);
}
#[test]
fn redact_walks_through_arrays() {
let body = r#"{"creds":[{"password":"a"},{"password":"b"}]}"#;
let out = redact_json_secrets(body);
assert!(!out.contains("\"a\""));
assert!(!out.contains("\"b\""));
assert_eq!(out.matches("<REDACTED>").count(), 2);
}
#[test]
fn redact_leaves_unrelated_fields_alone() {
let body = r#"{"a":1,"b":"x","c":{"d":[1,2,3]}}"#;
let out = redact_json_secrets(body);
assert!(!out.contains("<REDACTED>"));
}
#[test]
fn redact_passes_through_non_json_unchanged() {
let body = "plain text body";
assert_eq!(redact_json_secrets(body), "plain text body");
}
#[test]
fn query_builder_empty_returns_no_pairs() {
assert!(QueryBuilder::new().build().is_empty());
}
#[test]
fn query_builder_opt_skips_none_includes_some() {
let q = QueryBuilder::new()
.opt("a", &None)
.opt("b", &Some("x".to_string()))
.build();
assert_eq!(q, vec![("b", "x".to_string())]);
}
#[test]
fn query_builder_opt_display_skips_none_includes_some() {
let q = QueryBuilder::new()
.opt_display::<u32>("limit", &None)
.opt_display("offset", &Some(42u32))
.build();
assert_eq!(q, vec![("offset", "42".to_string())]);
}
#[test]
fn query_builder_vec_skips_empty_joins_with_comma() {
let empty: &[String] = &[];
let q1 = QueryBuilder::new().vec("xnames", empty).build();
assert!(q1.is_empty(), "empty vec must produce no pair");
let items = vec!["x1".to_string(), "x2".to_string(), "x3".to_string()];
let q2 = QueryBuilder::new().vec("xnames", &items).build();
assert_eq!(q2, vec![("xnames", "x1,x2,x3".to_string())]);
}
#[test]
fn query_builder_flag_only_emits_when_true() {
let q1 = QueryBuilder::new().flag("verbose", false).build();
assert!(q1.is_empty());
let q2 = QueryBuilder::new().flag("verbose", true).build();
assert_eq!(q2, vec![("verbose", "true".to_string())]);
}
#[test]
fn query_builder_pair_always_emits() {
let q = QueryBuilder::new()
.pair("site", "alps".to_string())
.pair("kind", "node".to_string())
.build();
assert_eq!(
q,
vec![("site", "alps".to_string()), ("kind", "node".to_string()),]
);
}
#[test]
fn query_builder_preserves_insertion_order() {
let q = QueryBuilder::new()
.pair("a", "1".into())
.pair("b", "2".into())
.pair("c", "3".into())
.build();
assert_eq!(
q.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
}
#[test]
fn query_builder_chains_mixed_methods() {
let q = QueryBuilder::new()
.opt("site", &Some("alps".into()))
.opt("group", &None)
.vec(
"xnames",
&["x3000c0s1b0n0".to_string(), "x3000c0s2b0n0".to_string()],
)
.flag("output_pretty", true)
.build();
assert_eq!(
q,
vec![
("site", "alps".to_string()),
("xnames", "x3000c0s1b0n0,x3000c0s2b0n0".to_string()),
("output_pretty", "true".to_string()),
]
);
}
}