manta_server/server/handlers/
mod.rs

1//! Top-level Axum handlers module.
2//!
3//! `mod.rs` keeps:
4//! - request extractors (`BearerToken`, `SiteName`, `RequestCtx`, `SiteHeader`)
5//! - the `ErrorResponse` body type + error mappers (`to_handler_error`,
6//!   `serialize_or_500`)
7//! - guard helpers (`require_vault`, `require_k8s_url`,
8//!   `validate_repo_list_lengths`, `parse_iso_datetime`)
9//! - the cross-handler `resolve_xnames_from_request` helper
10//! - the `health` endpoint
11//!
12//! Every other handler lives in a per-resource sub-module (mirroring
13//! the `service/` layout) and is re-exported here so `routes.rs` and
14//! `api_doc.rs` can keep referencing `handlers::X` unchanged.
15
16use 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
73// ---------------------------------------------------------------------------
74// Bearer-token extractor — eliminates token-extraction boilerplate
75// ---------------------------------------------------------------------------
76
77/// Axum extractor that pulls the token from `Authorization: Bearer <token>`.
78pub 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
116/// Axum extractor that reads the target site name from `X-Manta-Site`.
117///
118/// Every handler that touches backend APIs requires this header so the server
119/// knows which site's CA certificate, base URL, and credentials to use.
120pub 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/// Required header parameter present on every authenticated endpoint.
146///
147/// Tells the server which cluster to route the request to.
148/// **Not** an authentication mechanism — documented as a plain header parameter.
149///
150/// The field is consumed by the `utoipa::IntoParams` derive macro at compile
151/// time to generate the OpenAPI spec; the runtime extractor is [`SiteName`].
152#[derive(IntoParams)]
153#[into_params(parameter_in = Header)]
154#[allow(dead_code)]
155pub struct SiteHeader {
156  /// Name of the target cluster (matches a site configured in the server).
157  #[param(required = true, rename = "X-Manta-Site")]
158  pub x_manta_site: String,
159}
160
161// ---------------------------------------------------------------------------
162// RequestCtx — bundles the State + BearerToken + SiteName extractors that
163// every authenticated handler opens with. Plus `infra()` for the
164// `state.infra_context(&site_name).map_err(to_handler_error)?` line that
165// follows. Each handler shrinks by 3-4 lines.
166// ---------------------------------------------------------------------------
167
168/// Bundled extractor for `State<Arc<ServerState>>`, [`BearerToken`],
169/// and [`SiteName`]. Use it in handler signatures instead of the three
170/// individual extractors when all three are needed (the typical case).
171/// Extraction also validates that the `X-Manta-Site` value resolves to
172/// a configured [`super::SiteBackend`], so [`Self::infra`] inside the
173/// handler body is infallible.
174///
175/// The unauthenticated `/auth/*` handlers and the health endpoint
176/// still use explicit extractors — they don't need a Bearer token.
177///
178/// # Example
179///
180/// ```ignore
181/// pub async fn get_groups(ctx: RequestCtx) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
182///   let infra = ctx.infra();
183///   let groups = service::group::get_groups(&infra, &ctx.token).await.map_err(to_handler_error)?;
184///   Ok(Json(groups))
185/// }
186/// ```
187pub struct RequestCtx {
188  /// Shared server state (backend dispatcher, per-site config, TLS
189  /// material, optional Vault + k8s URLs).
190  pub state: Arc<ServerState>,
191  /// Bearer token extracted from the inbound `Authorization` header.
192  pub token: String,
193  /// Site name extracted from the inbound `X-Manta-Site` header;
194  /// used to pick the right `[sites.X]` entry from `state`.
195  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    // Validate the site resolves to a configured backend NOW, so the
210    // per-handler `ctx.infra()` call below cannot fail. Returning the
211    // 404-mapped error from extraction is the same shape the handler
212    // would have produced.
213    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  /// Borrow the per-site infrastructure (backend, base URLs, root
224  /// cert, optional Vault + k8s URLs). Infallible — the site was
225  /// validated during extraction; a missing site would have failed
226  /// the request before the handler body ran.
227  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
235/// Render an error and its `source()` chain as a multi-line string.
236///
237/// `thiserror`'s `Display` only emits the top-level message; nested
238/// errors reached via `std::error::Error::source()` are dropped. This
239/// walks the chain so the server log carries the full causal context
240/// (e.g. the underlying TLS / connect error behind a `reqwest::Error`).
241/// Works uniformly for thiserror-derived and `anyhow::Error` chains.
242fn 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/// Convert a [`BackendError`] (from the service layer) into the
254/// best-fitting `(StatusCode, Json<ErrorResponse>)` pair returned by
255/// every handler. The canonical call shape is
256/// `.map_err(to_handler_error)?` at the end of each service call.
257///
258/// Status mapping (most-specific first):
259/// - `NotFound`, `SessionNotFound`, `ConfigurationNotFound` → 404
260/// - `Conflict`, `ConfigurationAlreadyExistsError` → 409
261/// - `BadRequest`, `InvalidPattern`, `UnsupportedBackend`,
262///   `InvalidNodeId` → 400
263/// - `AuthenticationTokenNotFound`, `JwtMalformed` → 401
264/// - `InsufficientResources` → 422
265/// - `CsmError { status, .. }` → that backend status (verbatim) when
266///   it's a valid HTTP code, else 502 Bad Gateway
267/// - `NetError(rqe)` where `rqe.is_timeout()` → 504 Gateway Timeout
268///   (manta-server → CSM hop)
269/// - everything else → 500 Internal Server Error
270///
271/// `pub` (rather than `pub(crate)`) so the integration tests in
272/// `crates/manta-server/tests/` can exercise the mapping directly.
273//
274// `e` is consumed via `e.to_string()` at the end; technically it could
275// take `&BackendError`, but the canonical call shape is
276// `.map_err(to_handler_error)?` which threads the value through.
277// Switching to a reference would force every site to write
278// `.map_err(|e| to_handler_error(&e))?` — losing the point-free form
279// across hundreds of handler call sites is a worse trade than the
280// ineffectual `Drop` here.
281#[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    // Backend HTTP errors carry the originating status code (CSM or
297    // ochami). Propagate it verbatim when it's a valid HTTP status, so
298    // a 404 from the backend surfaces as a 404 from manta-server (not a
299    // generic 500). Fall back to 502 Bad Gateway if the embedded code
300    // is outside the HTTP status range — that's the canonical "upstream
301    // returned something nonsensical" signal.
302    BackendError::CsmError { status, .. } => {
303      StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
304    }
305    // The CSM-side reqwest client times out via `NetError(reqwest::Error)`;
306    // surface that as 504 Gateway Timeout so the CLI sees a distinct
307    // status (not a generic 500) and the body explicitly names the hop.
308    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
323/// Rewrite the error body when the underlying `BackendError` is a
324/// timeout or connect failure on the manta-server -> CSM hop. The
325/// rewritten body leads with which hop timed out so the operator
326/// (and the CLI's own `categorise_server_error`) can name it.
327fn 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// ---------------------------------------------------------------------------
424// Shared response types
425// ---------------------------------------------------------------------------
426
427/// Standard JSON error body returned by all failed endpoints.
428#[derive(Serialize, ToSchema)]
429pub struct ErrorResponse {
430  /// Human-readable explanation of the failure. Never includes
431  /// stack traces, credentials, or internal type names.
432  pub error: String,
433}
434
435// ---------------------------------------------------------------------------
436// Health check
437// ---------------------------------------------------------------------------
438
439/// GET /health — liveness probe; returns `{"status":"ok"}`.
440#[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
450// ---------------------------------------------------------------------------
451// Shared helpers
452// ---------------------------------------------------------------------------
453
454/// Resolve target xnames from an explicit list or an HSM group name.
455/// Returns 400 if neither is provided.
456async 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  //! Pure-logic locks for the helpers in this module that don't need
494  //! a live router. Route- and error-mapping coverage lives in
495  //! `crates/manta-server/tests/server_routes.rs`.
496
497  use super::format_with_causes;
498  use std::error::Error;
499  use std::fmt;
500
501  /// Toy error whose `source()` returns the inner error, so we can
502  /// build a fixed-depth `Display + Error` chain for the walk test.
503  #[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    // Deeply nested chain — emulates anyhow's `with_context()` stack.
543    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}