manta_server/server/handlers/
mod.rs1use std::sync::Arc;
17
18use axum::{
19 Json,
20 extract::FromRequestParts,
21 http::{StatusCode, header, request::Parts},
22 response::IntoResponse,
23};
24use manta_backend_dispatcher::error::Error as BackendError;
25use serde::Serialize;
26use utoipa::{IntoParams, ToSchema};
27
28use super::ServerState;
29use super::common::app_context::InfraContext;
30
31mod analysis;
32mod auth;
33mod boot_parameters;
34mod cluster;
35mod configuration;
36mod console;
37mod ephemeral_env;
38mod group;
39mod hardware;
40mod hw_cluster;
41mod image;
42mod kernel_parameters;
43mod migrate;
44mod node;
45mod power;
46mod redfish_endpoints;
47mod runtime_configuration;
48mod sat_file;
49mod session;
50mod template;
51
52pub use analysis::*;
53pub use auth::*;
54pub use boot_parameters::*;
55pub use cluster::*;
56pub use configuration::*;
57pub use console::*;
58pub use ephemeral_env::*;
59pub use group::*;
60pub use hardware::*;
61pub use hw_cluster::*;
62pub use image::*;
63pub use kernel_parameters::*;
64pub use migrate::*;
65pub use node::*;
66pub use power::*;
67pub use redfish_endpoints::*;
68pub use runtime_configuration::*;
69pub use sat_file::*;
70pub use session::*;
71pub use template::*;
72
73pub struct BearerToken(pub String);
79
80impl<S: Send + Sync> FromRequestParts<S> for BearerToken {
81 type Rejection = (StatusCode, Json<ErrorResponse>);
82
83 async fn from_request_parts(
84 parts: &mut Parts,
85 _state: &S,
86 ) -> Result<Self, Self::Rejection> {
87 let auth_header = parts
88 .headers
89 .get(header::AUTHORIZATION)
90 .and_then(|v| v.to_str().ok())
91 .ok_or_else(|| {
92 (
93 StatusCode::UNAUTHORIZED,
94 Json(ErrorResponse {
95 error: "Missing Authorization header".to_string(),
96 }),
97 )
98 })?;
99
100 let token = auth_header
101 .strip_prefix("Bearer ")
102 .or_else(|| auth_header.strip_prefix("bearer "))
103 .ok_or_else(|| {
104 (
105 StatusCode::UNAUTHORIZED,
106 Json(ErrorResponse {
107 error: "Authorization header must use Bearer scheme".to_string(),
108 }),
109 )
110 })?;
111
112 Ok(BearerToken(token.to_string()))
113 }
114}
115
116pub struct SiteName(pub String);
121
122impl<S: Send + Sync> FromRequestParts<S> for SiteName {
123 type Rejection = (StatusCode, Json<ErrorResponse>);
124
125 async fn from_request_parts(
126 parts: &mut Parts,
127 _state: &S,
128 ) -> Result<Self, Self::Rejection> {
129 let site = parts
130 .headers
131 .get("X-Manta-Site")
132 .and_then(|v| v.to_str().ok())
133 .ok_or_else(|| {
134 (
135 StatusCode::BAD_REQUEST,
136 Json(ErrorResponse {
137 error: "Missing X-Manta-Site header".to_string(),
138 }),
139 )
140 })?;
141 Ok(SiteName(site.to_string()))
142 }
143}
144
145#[derive(IntoParams)]
153#[into_params(parameter_in = Header)]
154#[allow(dead_code)]
155pub struct SiteHeader {
156 #[param(required = true, rename = "X-Manta-Site")]
158 pub x_manta_site: String,
159}
160
161pub struct RequestCtx {
188 pub state: Arc<ServerState>,
191 pub token: String,
193 pub site_name: String,
196}
197
198impl FromRequestParts<Arc<ServerState>> for RequestCtx {
199 type Rejection = (StatusCode, Json<ErrorResponse>);
200
201 async fn from_request_parts(
202 parts: &mut Parts,
203 state: &Arc<ServerState>,
204 ) -> Result<Self, Self::Rejection> {
205 let BearerToken(token) =
206 BearerToken::from_request_parts(parts, state).await?;
207 let SiteName(site_name) =
208 SiteName::from_request_parts(parts, state).await?;
209 state.infra_context(&site_name).map_err(to_handler_error)?;
214 Ok(Self {
215 state: Arc::clone(state),
216 token,
217 site_name,
218 })
219 }
220}
221
222impl RequestCtx {
223 pub fn infra(&self) -> InfraContext<'_> {
228 self
229 .state
230 .infra_context(&self.site_name)
231 .expect("site validated during RequestCtx extraction")
232 }
233}
234
235fn format_with_causes(e: &(dyn std::error::Error + 'static)) -> String {
243 let mut out = e.to_string();
244 let mut src = e.source();
245 while let Some(cause) = src {
246 out.push_str("\n caused by: ");
247 out.push_str(&cause.to_string());
248 src = cause.source();
249 }
250 out
251}
252
253#[allow(clippy::needless_pass_by_value)]
282pub fn to_handler_error(e: BackendError) -> (StatusCode, Json<ErrorResponse>) {
283 let status = match &e {
284 BackendError::NotFound(_)
285 | BackendError::SessionNotFound
286 | BackendError::ConfigurationNotFound => StatusCode::NOT_FOUND,
287 BackendError::Conflict(_)
288 | BackendError::ConfigurationAlreadyExistsError(_) => StatusCode::CONFLICT,
289 BackendError::BadRequest(_)
290 | BackendError::InvalidPattern(_)
291 | BackendError::UnsupportedBackend(_)
292 | BackendError::InvalidNodeId(_) => StatusCode::BAD_REQUEST,
293 BackendError::AuthenticationTokenNotFound(_)
294 | BackendError::JwtMalformed(_) => StatusCode::UNAUTHORIZED,
295 BackendError::InsufficientResources(_) => StatusCode::UNPROCESSABLE_ENTITY,
296 BackendError::CsmError { status, .. } => {
303 StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
304 }
305 BackendError::NetError(rqe) if rqe.is_timeout() => {
309 StatusCode::GATEWAY_TIMEOUT
310 }
311 _ => StatusCode::INTERNAL_SERVER_ERROR,
312 };
313 let chain = format_with_causes(&e);
314 if status == StatusCode::INTERNAL_SERVER_ERROR {
315 tracing::error!("Internal error: {}", chain);
316 } else {
317 tracing::debug!("Service error {}: {}", status, chain);
318 }
319 let error_body = categorise_backend_error_body(&e);
320 (status, Json(ErrorResponse { error: error_body }))
321}
322
323fn categorise_backend_error_body(e: &BackendError) -> String {
328 match e {
329 BackendError::NetError(rqe) if rqe.is_timeout() => {
330 format!(
331 "manta-server -> CSM call timed out (csm-rs reqwest \
332 HTTP_REQUEST_TIMEOUT, default 15 min). CSM did not send \
333 response headers in time. Original: {rqe}"
334 )
335 }
336 BackendError::NetError(rqe) if rqe.is_connect() => {
337 format!(
338 "manta-server -> CSM connect failed. Could not establish a \
339 TCP/TLS connection to the configured CSM endpoint. Check \
340 the site's backend URL and network reachability. Original: {rqe}"
341 )
342 }
343 _ => e.to_string(),
344 }
345}
346
347pub(super) fn serialize_or_500<T: Serialize>(
348 v: &T,
349) -> Result<serde_json::Value, (StatusCode, Json<ErrorResponse>)> {
350 serde_json::to_value(v).map_err(|e| {
351 let chain = format_with_causes(&e);
352 tracing::error!("Failed to serialize: {}", chain);
353 (
354 StatusCode::INTERNAL_SERVER_ERROR,
355 Json(ErrorResponse {
356 error: format!("Failed to serialize: {e}"),
357 }),
358 )
359 })
360}
361
362pub(super) fn require_vault(
363 url: Option<&str>,
364) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
365 require_url(url, "vault_base_url")
366}
367
368pub(super) fn require_k8s_url(
369 url: Option<&str>,
370) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
371 require_url(url, "k8s_api_url")
372}
373
374fn require_url<'a>(
375 url: Option<&'a str>,
376 field: &str,
377) -> Result<&'a str, (StatusCode, Json<ErrorResponse>)> {
378 url.ok_or_else(|| {
379 (
380 StatusCode::NOT_IMPLEMENTED,
381 Json(ErrorResponse {
382 error: format!("{field} not configured on this server"),
383 }),
384 )
385 })
386}
387
388pub(super) fn validate_repo_list_lengths(
389 repo_names: &[String],
390 repo_last_commit_ids: &[String],
391) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
392 if repo_names.len() != repo_last_commit_ids.len() {
393 return Err((
394 StatusCode::BAD_REQUEST,
395 Json(ErrorResponse {
396 error: format!(
397 "repo_names ({}) and repo_last_commit_ids ({}) must have the same length",
398 repo_names.len(),
399 repo_last_commit_ids.len()
400 ),
401 }),
402 ));
403 }
404 Ok(())
405}
406
407pub(super) fn parse_iso_datetime(
408 field: &str,
409 value: &str,
410) -> Result<chrono::NaiveDateTime, (StatusCode, Json<ErrorResponse>)> {
411 chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S").map_err(
412 |e| {
413 (
414 StatusCode::BAD_REQUEST,
415 Json(ErrorResponse {
416 error: format!("Invalid '{field}' datetime '{value}': {e}"),
417 }),
418 )
419 },
420 )
421}
422
423#[derive(Serialize, ToSchema)]
429pub struct ErrorResponse {
430 pub error: String,
433}
434
435#[utoipa::path(get, path = "/health", tag = "system",
441 responses(
442 (status = 200, description = "Server is healthy"),
443 )
444)]
445#[tracing::instrument(skip_all)]
446pub async fn health() -> impl IntoResponse {
447 Json(serde_json::json!({ "status": "ok" }))
448}
449
450async fn resolve_xnames_from_request(
457 infra: &crate::server::common::app_context::InfraContext<'_>,
458 token: &str,
459 xnames_expression: Option<&str>,
460 group_name_opt: Option<&str>,
461) -> Result<Vec<String>, (StatusCode, Json<ErrorResponse>)> {
462 if let Some(expr) = xnames_expression
463 && !expr.is_empty()
464 {
465 return crate::service::node_ops::from_user_hosts_expression_to_xname_vec(
466 infra, token, expr, false,
467 )
468 .await
469 .map_err(to_handler_error);
470 }
471 if let Some(group) = group_name_opt {
472 return crate::service::node_ops::resolve_target_nodes(
473 infra,
474 token,
475 None,
476 Some(group),
477 None,
478 )
479 .await
480 .map_err(to_handler_error);
481 }
482 Err((
483 StatusCode::BAD_REQUEST,
484 Json(ErrorResponse {
485 error: "At least one of 'xnames' or 'hsm_group' must be provided"
486 .to_string(),
487 }),
488 ))
489}
490
491#[cfg(test)]
492mod tests {
493 use super::format_with_causes;
498 use std::error::Error;
499 use std::fmt;
500
501 #[derive(Debug)]
504 struct Chain {
505 msg: &'static str,
506 src: Option<Box<Chain>>,
507 }
508 impl fmt::Display for Chain {
509 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510 f.write_str(self.msg)
511 }
512 }
513 impl Error for Chain {
514 fn source(&self) -> Option<&(dyn Error + 'static)> {
515 self.src.as_deref().map(|s| s as &(dyn Error + 'static))
516 }
517 }
518
519 #[test]
520 fn format_with_causes_single_error_has_no_caused_by() {
521 let e = Chain {
522 msg: "boom",
523 src: None,
524 };
525 assert_eq!(format_with_causes(&e), "boom");
526 }
527
528 #[test]
529 fn format_with_causes_two_level_chain_is_indented() {
530 let e = Chain {
531 msg: "outer",
532 src: Some(Box::new(Chain {
533 msg: "inner",
534 src: None,
535 })),
536 };
537 assert_eq!(format_with_causes(&e), "outer\n caused by: inner");
538 }
539
540 #[test]
541 fn format_with_causes_walks_to_the_root() {
542 let e = Chain {
544 msg: "top",
545 src: Some(Box::new(Chain {
546 msg: "middle",
547 src: Some(Box::new(Chain {
548 msg: "root",
549 src: None,
550 })),
551 })),
552 };
553 assert_eq!(
554 format_with_causes(&e),
555 "top\n caused by: middle\n caused by: root"
556 );
557 }
558}