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