use std::sync::Arc;
use axum::{
Json,
extract::FromRequestParts,
http::{StatusCode, header, request::Parts},
response::IntoResponse,
};
use manta_backend_dispatcher::error::Error as BackendError;
use serde::Serialize;
use utoipa::{IntoParams, ToSchema};
use super::ServerState;
use super::common::app_context::InfraContext;
mod analysis;
mod auth;
mod boot_parameters;
mod cluster;
mod configuration;
mod console;
mod ephemeral_env;
mod group;
mod hardware;
mod hw_cluster;
mod image;
mod kernel_parameters;
mod migrate;
mod node;
mod power;
mod redfish_endpoints;
mod sat_file;
mod session;
mod template;
pub use analysis::*;
pub use auth::*;
pub use boot_parameters::*;
pub use cluster::*;
pub use configuration::*;
pub use console::*;
pub use ephemeral_env::*;
pub use group::*;
pub use hardware::*;
pub use hw_cluster::*;
pub use image::*;
pub use kernel_parameters::*;
pub use migrate::*;
pub use node::*;
pub use power::*;
pub use redfish_endpoints::*;
pub use sat_file::*;
pub use session::*;
pub use template::*;
pub struct BearerToken(pub String);
impl<S: Send + Sync> FromRequestParts<S> for BearerToken {
type Rejection = (StatusCode, Json<ErrorResponse>);
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Missing Authorization header".to_string(),
}),
)
})?;
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Authorization header must use Bearer scheme".to_string(),
}),
)
})?;
Ok(BearerToken(token.to_string()))
}
}
pub struct SiteName(pub String);
impl<S: Send + Sync> FromRequestParts<S> for SiteName {
type Rejection = (StatusCode, Json<ErrorResponse>);
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let site = parts
.headers
.get("X-Manta-Site")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Missing X-Manta-Site header".to_string(),
}),
)
})?;
Ok(SiteName(site.to_string()))
}
}
#[derive(IntoParams)]
#[into_params(parameter_in = Header)]
#[allow(dead_code)]
pub struct SiteHeader {
#[param(required = true, rename = "X-Manta-Site")]
pub x_manta_site: String,
}
pub struct RequestCtx {
pub state: Arc<ServerState>,
pub token: String,
pub site_name: String,
}
impl FromRequestParts<Arc<ServerState>> for RequestCtx {
type Rejection = (StatusCode, Json<ErrorResponse>);
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<ServerState>,
) -> Result<Self, Self::Rejection> {
let BearerToken(token) =
BearerToken::from_request_parts(parts, state).await?;
let SiteName(site_name) =
SiteName::from_request_parts(parts, state).await?;
state.infra_context(&site_name).map_err(to_handler_error)?;
Ok(Self {
state: Arc::clone(state),
token,
site_name,
})
}
}
impl RequestCtx {
pub fn infra(&self) -> InfraContext<'_> {
self
.state
.infra_context(&self.site_name)
.expect("site validated during RequestCtx extraction")
}
}
fn format_with_causes(e: &(dyn std::error::Error + 'static)) -> String {
let mut out = e.to_string();
let mut src = e.source();
while let Some(cause) = src {
out.push_str("\n caused by: ");
out.push_str(&cause.to_string());
src = cause.source();
}
out
}
#[allow(clippy::needless_pass_by_value)]
pub fn to_handler_error(e: BackendError) -> (StatusCode, Json<ErrorResponse>) {
let status = match &e {
BackendError::NotFound(_)
| BackendError::SessionNotFound
| BackendError::ConfigurationNotFound => StatusCode::NOT_FOUND,
BackendError::Conflict(_)
| BackendError::ConfigurationAlreadyExistsError(_) => StatusCode::CONFLICT,
BackendError::BadRequest(_)
| BackendError::InvalidPattern(_)
| BackendError::UnsupportedBackend(_)
| BackendError::InvalidNodeId(_) => StatusCode::BAD_REQUEST,
BackendError::AuthenticationTokenNotFound(_)
| BackendError::JwtMalformed(_) => StatusCode::UNAUTHORIZED,
BackendError::InsufficientResources(_) => StatusCode::UNPROCESSABLE_ENTITY,
BackendError::CsmError { status, .. } => {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
}
BackendError::NetError(rqe) if rqe.is_timeout() => {
StatusCode::GATEWAY_TIMEOUT
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let chain = format_with_causes(&e);
if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!("Internal error: {}", chain);
} else {
tracing::debug!("Service error {}: {}", status, chain);
}
let error_body = categorise_backend_error_body(&e);
(status, Json(ErrorResponse { error: error_body }))
}
fn categorise_backend_error_body(e: &BackendError) -> String {
match e {
BackendError::NetError(rqe) if rqe.is_timeout() => {
format!(
"manta-server -> CSM call timed out (csm-rs reqwest \
HTTP_REQUEST_TIMEOUT, default 15 min). CSM did not send \
response headers in time. Original: {rqe}"
)
}
BackendError::NetError(rqe) if rqe.is_connect() => {
format!(
"manta-server -> CSM connect failed. Could not establish a \
TCP/TLS connection to the configured CSM endpoint. Check \
the site's backend URL and network reachability. Original: {rqe}"
)
}
_ => e.to_string(),
}
}
pub(super) fn serialize_or_500<T: Serialize>(
v: &T,
) -> Result<serde_json::Value, (StatusCode, Json<ErrorResponse>)> {
serde_json::to_value(v).map_err(|e| {
let chain = format_with_causes(&e);
tracing::error!("Failed to serialize: {}", chain);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to serialize: {e}"),
}),
)
})
}
pub(super) fn require_vault(
url: Option<&str>,
) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
url.ok_or_else(|| {
(
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
error: "vault_base_url not configured on this server".into(),
}),
)
})
}
pub(super) fn require_k8s_url(
url: Option<&str>,
) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
url.ok_or_else(|| {
(
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
error: "k8s_api_url not configured on this server".into(),
}),
)
})
}
pub(super) fn validate_repo_list_lengths(
repo_names: &[String],
repo_last_commit_ids: &[String],
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if repo_names.len() != repo_last_commit_ids.len() {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!(
"repo_names ({}) and repo_last_commit_ids ({}) must have the same length",
repo_names.len(),
repo_last_commit_ids.len()
),
}),
));
}
Ok(())
}
pub(super) fn parse_iso_datetime(
field: &str,
value: &str,
) -> Result<chrono::NaiveDateTime, (StatusCode, Json<ErrorResponse>)> {
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S").map_err(
|e| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid '{field}' datetime '{value}': {e}"),
}),
)
},
)
}
#[derive(Serialize, ToSchema)]
pub struct ErrorResponse {
pub error: String,
}
#[utoipa::path(get, path = "/health", tag = "system",
responses(
(status = 200, description = "Server is healthy"),
)
)]
#[tracing::instrument(skip_all)]
pub async fn health() -> impl IntoResponse {
Json(serde_json::json!({ "status": "ok" }))
}
async fn resolve_xnames_from_request(
infra: &crate::server::common::app_context::InfraContext<'_>,
token: &str,
xnames_expression: Option<&str>,
group_name_opt: Option<&str>,
) -> Result<Vec<String>, (StatusCode, Json<ErrorResponse>)> {
if let Some(expr) = xnames_expression
&& !expr.is_empty()
{
return crate::service::node_ops::from_user_hosts_expression_to_xname_vec(
infra, token, expr, false,
)
.await
.map_err(to_handler_error);
}
if let Some(group) = group_name_opt {
return crate::service::node_ops::resolve_target_nodes(
infra,
token,
None,
Some(group),
None,
)
.await
.map_err(to_handler_error);
}
Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "At least one of 'xnames' or 'hsm_group' must be provided"
.to_string(),
}),
))
}
#[cfg(test)]
mod tests {
use super::format_with_causes;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Chain {
msg: &'static str,
src: Option<Box<Chain>>,
}
impl fmt::Display for Chain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.msg)
}
}
impl Error for Chain {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.src.as_deref().map(|s| s as &(dyn Error + 'static))
}
}
#[test]
fn format_with_causes_single_error_has_no_caused_by() {
let e = Chain {
msg: "boom",
src: None,
};
assert_eq!(format_with_causes(&e), "boom");
}
#[test]
fn format_with_causes_two_level_chain_is_indented() {
let e = Chain {
msg: "outer",
src: Some(Box::new(Chain {
msg: "inner",
src: None,
})),
};
assert_eq!(format_with_causes(&e), "outer\n caused by: inner");
}
#[test]
fn format_with_causes_walks_to_the_root() {
let e = Chain {
msg: "top",
src: Some(Box::new(Chain {
msg: "middle",
src: Some(Box::new(Chain {
msg: "root",
src: None,
})),
})),
};
assert_eq!(
format_with_causes(&e),
"top\n caused by: middle\n caused by: root"
);
}
}