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//!   `display_error`, `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 auth;
32mod boot_parameters;
33mod cluster;
34mod configuration;
35mod console;
36mod ephemeral_env;
37mod group;
38mod hardware;
39mod hw_cluster;
40mod image;
41mod kernel_parameters;
42mod migrate;
43mod node;
44mod power;
45mod redfish_endpoints;
46mod sat_file;
47mod session;
48mod template;
49
50pub use auth::*;
51pub use boot_parameters::*;
52pub use cluster::*;
53pub use configuration::*;
54pub use console::*;
55pub use ephemeral_env::*;
56pub use group::*;
57pub use hardware::*;
58pub use hw_cluster::*;
59pub use image::*;
60pub use kernel_parameters::*;
61pub use migrate::*;
62pub use node::*;
63pub use power::*;
64pub use redfish_endpoints::*;
65pub use sat_file::*;
66pub use session::*;
67pub use template::*;
68
69// ---------------------------------------------------------------------------
70// Bearer-token extractor — eliminates token-extraction boilerplate
71// ---------------------------------------------------------------------------
72
73/// Axum extractor that pulls the token from `Authorization: Bearer <token>`.
74pub struct BearerToken(pub String);
75
76impl<S: Send + Sync> FromRequestParts<S> for BearerToken {
77  type Rejection = (StatusCode, Json<ErrorResponse>);
78
79  async fn from_request_parts(
80    parts: &mut Parts,
81    _state: &S,
82  ) -> Result<Self, Self::Rejection> {
83    let auth_header = parts
84      .headers
85      .get(header::AUTHORIZATION)
86      .and_then(|v| v.to_str().ok())
87      .ok_or_else(|| {
88        (
89          StatusCode::UNAUTHORIZED,
90          Json(ErrorResponse {
91            error: "Missing Authorization header".to_string(),
92          }),
93        )
94      })?;
95
96    let token = auth_header
97      .strip_prefix("Bearer ")
98      .or_else(|| auth_header.strip_prefix("bearer "))
99      .ok_or_else(|| {
100        (
101          StatusCode::UNAUTHORIZED,
102          Json(ErrorResponse {
103            error: "Authorization header must use Bearer scheme".to_string(),
104          }),
105        )
106      })?;
107
108    Ok(BearerToken(token.to_string()))
109  }
110}
111
112/// Axum extractor that reads the target site name from `X-Manta-Site`.
113///
114/// Every handler that touches backend APIs requires this header so the server
115/// knows which site's CA certificate, base URL, and credentials to use.
116pub struct SiteName(pub String);
117
118impl<S: Send + Sync> FromRequestParts<S> for SiteName {
119  type Rejection = (StatusCode, Json<ErrorResponse>);
120
121  async fn from_request_parts(
122    parts: &mut Parts,
123    _state: &S,
124  ) -> Result<Self, Self::Rejection> {
125    let site = parts
126      .headers
127      .get("X-Manta-Site")
128      .and_then(|v| v.to_str().ok())
129      .ok_or_else(|| {
130        (
131          StatusCode::BAD_REQUEST,
132          Json(ErrorResponse {
133            error: "Missing X-Manta-Site header".to_string(),
134          }),
135        )
136      })?;
137    Ok(SiteName(site.to_string()))
138  }
139}
140
141/// Required header parameter present on every authenticated endpoint.
142///
143/// Tells the server which cluster to route the request to.
144/// **Not** an authentication mechanism — documented as a plain header parameter.
145///
146/// The field is consumed by the `utoipa::IntoParams` derive macro at compile
147/// time to generate the OpenAPI spec; the runtime extractor is [`SiteName`].
148#[derive(IntoParams)]
149#[into_params(parameter_in = Header)]
150#[allow(dead_code)]
151pub struct SiteHeader {
152  /// Name of the target cluster (matches a site configured in the server).
153  #[param(required = true, rename = "X-Manta-Site")]
154  pub x_manta_site: String,
155}
156
157// ---------------------------------------------------------------------------
158// RequestCtx — bundles the State + BearerToken + SiteName extractors that
159// every authenticated handler opens with. Plus `infra()` for the
160// `state.infra_context(&site_name).map_err(to_handler_error)?` line that
161// follows. Each handler shrinks by 3-4 lines.
162// ---------------------------------------------------------------------------
163
164/// Bundled extractor for `State<Arc<ServerState>>` + `BearerToken` +
165/// `SiteName`. Use it in handler signatures instead of the three
166/// individual extractors when all three are needed (the typical case).
167///
168/// The unauthenticated `/auth/*` handlers and the health endpoint
169/// still use explicit extractors — they don't need a Bearer token.
170pub struct RequestCtx {
171  /// Shared server state (backend dispatcher, per-site config, TLS
172  /// material, optional Vault + k8s URLs).
173  pub state: Arc<ServerState>,
174  /// Bearer token extracted from the inbound `Authorization` header.
175  pub token: String,
176  /// Site name extracted from the inbound `X-Manta-Site` header;
177  /// used to pick the right `[sites.X]` entry from `state`.
178  pub site_name: String,
179}
180
181impl FromRequestParts<Arc<ServerState>> for RequestCtx {
182  type Rejection = (StatusCode, Json<ErrorResponse>);
183
184  async fn from_request_parts(
185    parts: &mut Parts,
186    state: &Arc<ServerState>,
187  ) -> Result<Self, Self::Rejection> {
188    let BearerToken(token) =
189      BearerToken::from_request_parts(parts, state).await?;
190    let SiteName(site_name) =
191      SiteName::from_request_parts(parts, state).await?;
192    // Validate the site resolves to a configured backend NOW, so the
193    // per-handler `ctx.infra()` call below cannot fail. Returning the
194    // 404-mapped error from extraction is the same shape the handler
195    // would have produced.
196    state.infra_context(&site_name).map_err(to_handler_error)?;
197    Ok(Self {
198      state: Arc::clone(state),
199      token,
200      site_name,
201    })
202  }
203}
204
205impl RequestCtx {
206  /// Borrow the per-site infrastructure (backend, base URLs, root
207  /// cert, optional Vault + k8s URLs). Infallible — the site was
208  /// validated during extraction; a missing site would have failed
209  /// the request before the handler body ran.
210  pub fn infra(&self) -> InfraContext<'_> {
211    self
212      .state
213      .infra_context(&self.site_name)
214      .expect("site validated during RequestCtx extraction")
215  }
216}
217
218/// Render an error and its `source()` chain as a multi-line string.
219///
220/// `thiserror`'s `Display` only emits the top-level message; nested
221/// errors reached via `std::error::Error::source()` are dropped. This
222/// walks the chain so the server log carries the full causal context
223/// (e.g. the underlying TLS / connect error behind a `reqwest::Error`).
224/// Works uniformly for thiserror-derived and `anyhow::Error` chains.
225fn format_with_causes(e: &(dyn std::error::Error + 'static)) -> String {
226  let mut out = e.to_string();
227  let mut src = e.source();
228  while let Some(cause) = src {
229    out.push_str("\n  caused by: ");
230    out.push_str(&cause.to_string());
231    src = cause.source();
232  }
233  out
234}
235
236/// Convert a `BackendError` into the best-fitting HTTP error response.
237///
238/// `pub` (rather than `pub(crate)`) so the integration tests in
239/// `crates/manta-server/tests/` can exercise the mapping directly.
240pub fn to_handler_error(e: BackendError) -> (StatusCode, Json<ErrorResponse>) {
241  let status = match &e {
242    BackendError::NotFound(_)
243    | BackendError::SessionNotFound
244    | BackendError::ConfigurationNotFound => StatusCode::NOT_FOUND,
245    BackendError::Conflict(_)
246    | BackendError::ConfigurationAlreadyExistsError(_) => StatusCode::CONFLICT,
247    BackendError::BadRequest(_)
248    | BackendError::InvalidPattern(_)
249    | BackendError::UnsupportedBackend(_)
250    | BackendError::InvalidNodeId(_) => StatusCode::BAD_REQUEST,
251    BackendError::AuthenticationTokenNotFound(_)
252    | BackendError::JwtMalformed(_) => StatusCode::UNAUTHORIZED,
253    BackendError::InsufficientResources(_) => StatusCode::UNPROCESSABLE_ENTITY,
254    _ => StatusCode::INTERNAL_SERVER_ERROR,
255  };
256  let chain = format_with_causes(&e);
257  if status == StatusCode::INTERNAL_SERVER_ERROR {
258    tracing::error!("Internal error: {}", chain);
259  } else {
260    tracing::debug!("Service error {}: {}", status, chain);
261  }
262  (
263    status,
264    Json(ErrorResponse {
265      error: e.to_string(),
266    }),
267  )
268}
269
270/// Convert any error (typically `BackendError` or `anyhow::Error`) into
271/// a 500 HTTP response while logging the full `source()` chain.
272///
273/// The response body carries only the top-level `Display` rendering;
274/// nested cause detail stays in the server log so it doesn't leak
275/// internals to clients.
276pub(super) fn display_error<E: std::error::Error + 'static>(
277  e: E,
278) -> (StatusCode, Json<ErrorResponse>) {
279  let body_msg = e.to_string();
280  tracing::error!("Internal error: {}", format_with_causes(&e));
281  (
282    StatusCode::INTERNAL_SERVER_ERROR,
283    Json(ErrorResponse { error: body_msg }),
284  )
285}
286
287pub(super) fn serialize_or_500<T: Serialize>(
288  v: &T,
289) -> Result<serde_json::Value, (StatusCode, Json<ErrorResponse>)> {
290  serde_json::to_value(v).map_err(|e| {
291    let chain = format_with_causes(&e);
292    tracing::error!("Failed to serialize: {}", chain);
293    (
294      StatusCode::INTERNAL_SERVER_ERROR,
295      Json(ErrorResponse {
296        error: format!("Failed to serialize: {e}"),
297      }),
298    )
299  })
300}
301
302pub(super) fn require_vault(
303  url: Option<&str>,
304) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
305  url.ok_or_else(|| {
306    (
307      StatusCode::NOT_IMPLEMENTED,
308      Json(ErrorResponse {
309        error: "vault_base_url not configured on this server".into(),
310      }),
311    )
312  })
313}
314
315pub(super) fn require_k8s_url(
316  url: Option<&str>,
317) -> Result<&str, (StatusCode, Json<ErrorResponse>)> {
318  url.ok_or_else(|| {
319    (
320      StatusCode::NOT_IMPLEMENTED,
321      Json(ErrorResponse {
322        error: "k8s_api_url not configured on this server".into(),
323      }),
324    )
325  })
326}
327
328pub(super) fn validate_repo_list_lengths(
329  repo_names: &[String],
330  repo_last_commit_ids: &[String],
331) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
332  if repo_names.len() != repo_last_commit_ids.len() {
333    return Err((
334      StatusCode::BAD_REQUEST,
335      Json(ErrorResponse {
336        error: format!(
337          "repo_names ({}) and repo_last_commit_ids ({}) must have the same length",
338          repo_names.len(),
339          repo_last_commit_ids.len()
340        ),
341      }),
342    ));
343  }
344  Ok(())
345}
346
347pub(super) fn default_true() -> bool {
348  true
349}
350
351pub(super) fn parse_iso_datetime(
352  field: &str,
353  value: &str,
354) -> Result<chrono::NaiveDateTime, (StatusCode, Json<ErrorResponse>)> {
355  chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S").map_err(
356    |e| {
357      (
358        StatusCode::BAD_REQUEST,
359        Json(ErrorResponse {
360          error: format!("Invalid '{field}' datetime '{value}': {e}"),
361        }),
362      )
363    },
364  )
365}
366
367// ---------------------------------------------------------------------------
368// Shared response types
369// ---------------------------------------------------------------------------
370
371/// Standard JSON error body returned by all failed endpoints.
372#[derive(Serialize, ToSchema)]
373pub struct ErrorResponse {
374  /// Human-readable explanation of the failure. Never includes
375  /// stack traces, credentials, or internal type names.
376  pub error: String,
377}
378
379// ---------------------------------------------------------------------------
380// Health check
381// ---------------------------------------------------------------------------
382
383/// GET /health — liveness probe; returns `{"status":"ok"}`.
384#[utoipa::path(get, path = "/health", tag = "system",
385  responses(
386    (status = 200, description = "Server is healthy"),
387  )
388)]
389#[tracing::instrument(skip_all)]
390pub async fn health() -> impl IntoResponse {
391  Json(serde_json::json!({ "status": "ok" }))
392}
393
394// ---------------------------------------------------------------------------
395// Shared helpers
396// ---------------------------------------------------------------------------
397
398/// Resolve target xnames from an explicit list or an HSM group name.
399/// Returns 400 if neither is provided.
400async fn resolve_xnames_from_request(
401  backend: &crate::manta_backend_dispatcher::StaticBackendDispatcher,
402  token: &str,
403  xnames_expression: Option<&str>,
404  hsm_group: Option<&str>,
405) -> Result<Vec<String>, (StatusCode, Json<ErrorResponse>)> {
406  if let Some(expr) = xnames_expression
407    && !expr.is_empty()
408  {
409    return crate::server::common::node_ops::resolve_hosts_expression(
410      backend, token, expr, false,
411    )
412    .await
413    .map_err(display_error);
414  }
415  if let Some(group) = hsm_group {
416    return crate::server::common::node_ops::resolve_target_nodes(
417      backend,
418      token,
419      None,
420      Some(group),
421      None,
422    )
423    .await
424    .map_err(display_error);
425  }
426  Err((
427    StatusCode::BAD_REQUEST,
428    Json(ErrorResponse {
429      error: "At least one of 'xnames' or 'hsm_group' must be provided"
430        .to_string(),
431    }),
432  ))
433}