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