Skip to main content

manta_server/server/handlers/
sat_file.rs

1//! SAT-file HTTP handlers.
2//!
3//! Per-element endpoints. The CLI's `apply_sat_file` plan builder
4//! (in `manta-cli`, not reachable as an intra-doc link from here)
5//! produces a typed sequence of elements; its dispatcher walks the
6//! plan and POSTs each element to the section-specific endpoint here.
7//!
8//! Configuration + session-template entries take one call each:
9//!
10//! - `POST /v2/sat-file/configurations` →
11//!   [`post_sat_configuration`] — Body: [`PostSatConfigurationRequest`];
12//!   response: a `CfsConfigurationResponse` as JSON.
13//! - `POST /v2/sat-file/session-templates` →
14//!   [`post_sat_session_template`] — Body:
15//!   [`PostSatSessionTemplateRequest`]; response:
16//!   [`PostSatSessionTemplateResponse`].
17//!
18//! Image entries are split across three calls so the CLI can monitor
19//! the build instead of blocking on one long server round-trip:
20//!
21//! - `POST /v2/sat-file/images/cfs-session` →
22//!   [`post_sat_image_cfs_session`] — translate one `images[]` entry
23//!   into a CFS session payload and create it. Body:
24//!   [`CreateImageCfsSessionRequest`]; response: the freshly-created
25//!   [`CfsSessionGetResponse`] (still pending/running).
26//! - Monitor via the existing `GET /sessions?name=…` or
27//!   `GET /sessions/{name}/logs` (SSE) endpoints — the CLI picks
28//!   which based on `--watch-logs`.
29//! - `POST /v2/sat-file/images/stamp` → [`post_sat_image_stamp`] —
30//!   once the session is terminal-complete, the server fetches it,
31//!   derives `manta.image_session.{base,groups,configuration}`, and
32//!   PATCHes them onto the produced IMS image. Body:
33//!   [`StampImageFromSessionRequest`]; response: the patched [`Image`].
34//!   Fails fast with 400 when the session produced no `result_id`.
35//!
36//! The CLI deserialises each response and pretty-prints the assembled
37//! four-list summary, so any rename of a field on either side of the
38//! wire is user-visible. The wire-format-lock tests at the bottom of
39//! this module catch that drift; mirror them when you add a new field.
40//!
41//! Each handler calls the matching function in
42//! `crate::service::sat_file`, which enforces the CLAUDE.md boundary
43//! rule (handlers → service → backend).
44
45use axum::{Json, http::StatusCode, response::IntoResponse};
46use manta_backend_dispatcher::types::bos::session::{
47  BosSession, Operation as BosOperation,
48};
49use manta_backend_dispatcher::types::cfs::session::CfsSessionGetResponse;
50use manta_backend_dispatcher::types::ims::Image;
51
52use crate::server::audit::AuditNote;
53use crate::service::authorization::validate_user_group_vec_access;
54
55use super::{
56  ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
57  to_handler_error,
58};
59
60// ---------------------------------------------------------------------------
61// POST /v2/sat-file/configurations — Apply one SAT configuration entry
62// ---------------------------------------------------------------------------
63
64pub use manta_shared::types::api::sat_file::{
65  CreateImageCfsSessionRequest, PostSatConfigurationRequest,
66  PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
67  StampImageFromSessionRequest,
68};
69
70#[utoipa::path(post, path = "/sat-file/configurations", tag = "sat-file",
71  params(SiteHeader),
72  request_body = PostSatConfigurationRequest,
73  security(("bearerAuth" = [])),
74  responses(
75    // CfsConfigurationResponse lives in manta-backend-dispatcher (third-party,
76    // no ToSchema) — kept as Value until upstream derives it.
77    (status = 200, description = "Configuration applied",       body = serde_json::Value),
78    (status = 401, description = "Unauthorized",                body = ErrorResponse),
79    (status = 500, description = "Internal error",              body = ErrorResponse),
80    (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
81  )
82)]
83/// `POST /v2/sat-file/configurations` — apply a single SAT
84/// configuration entry. Returns the created `CfsConfigurationResponse`.
85#[tracing::instrument(skip_all)]
86pub async fn post_sat_configuration(
87  ctx: RequestCtx,
88  note: AuditNote,
89  Json(body): Json<PostSatConfigurationRequest>,
90) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
91  note
92    .action("sat_file.configuration.apply")
93    .targets("configuration", sat_element_name(&body.configuration))
94    .detail("overwrite", body.overwrite)
95    .dry_run(body.dry_run);
96
97  tracing::info!("post_sat_configuration dry_run={}", body.dry_run);
98  let infra = ctx.infra();
99
100  let vault_base_url = require_vault(infra.vault_base_url)?;
101  let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
102
103  let gitea_token =
104    crate::server::common::vault::http_client::get_shasta_vcs_token(
105      &ctx.token,
106      vault_base_url,
107      infra.site_name,
108    )
109    .await
110    .map_err(to_handler_error)?;
111
112  // CFS configurations are not HSM-group-scoped — the SAT
113  // `configurations[]` entry only carries name + layers (git URL,
114  // branch, playbook), with no group field. Access control here
115  // relies on the backend's RBAC layer (CSM/OCHAMI), matching the
116  // convention used for other non-group-scoped handlers (see
117  // ARCHITECTURE.md "Security model").
118  let cfg = crate::service::sat_file::apply_configuration(
119    &infra,
120    &ctx.token,
121    vault_base_url,
122    k8s_api_url,
123    &gitea_token,
124    body.configuration,
125    body.dry_run,
126    body.overwrite,
127  )
128  .await
129  .map_err(to_handler_error)?;
130
131  Ok(Json(cfg))
132}
133
134// ---------------------------------------------------------------------------
135// POST /v2/sat-file/images/cfs-session — Create the CFS session that
136// will build the image, but do not wait for it or stamp the result. The
137// CLI drives the monitor + stamp steps via the existing session endpoints
138// and the companion `/sat-file/images/stamp` endpoint below.
139// ---------------------------------------------------------------------------
140
141#[utoipa::path(post, path = "/sat-file/images/cfs-session", tag = "sat-file",
142  params(SiteHeader),
143  request_body = CreateImageCfsSessionRequest,
144  security(("bearerAuth" = [])),
145  responses(
146    // CfsSessionGetResponse lives in manta-backend-dispatcher (third-party,
147    // no ToSchema) — kept as Value until upstream derives it.
148    (status = 201, description = "CFS session created",         body = serde_json::Value),
149    (status = 401, description = "Unauthorized",                body = ErrorResponse),
150    (status = 500, description = "Internal error",              body = ErrorResponse),
151    (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
152  )
153)]
154/// `POST /v2/sat-file/images/cfs-session` — translate one SAT
155/// `images[]` entry into a CFS session payload and create it. Returns
156/// the freshly-created [`CfsSessionGetResponse`] so the CLI can drive
157/// the monitor + stamp steps itself.
158#[tracing::instrument(skip_all)]
159pub async fn post_sat_image_cfs_session(
160  ctx: RequestCtx,
161  note: AuditNote,
162  Json(body): Json<CreateImageCfsSessionRequest>,
163) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
164  note
165    .action("sat_file.image.build")
166    .targets("image", sat_element_name(&body.image))
167    .dry_run(body.dry_run);
168
169  tracing::info!("post_sat_image_cfs_session dry_run={}", body.dry_run);
170  let infra = ctx.infra();
171
172  let vault_base_url = require_vault(infra.vault_base_url)?;
173  let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
174
175  let target_groups =
176    crate::service::sat_groups::extract_image_groups(&body.image);
177
178  validate_user_group_vec_access(&infra, &ctx.token, &target_groups)
179    .await
180    .map_err(to_handler_error)?;
181
182  let session = crate::service::sat_file::create_image_cfs_session(
183    &infra,
184    &ctx.token,
185    vault_base_url,
186    k8s_api_url,
187    body.image,
188    body.ref_lookup,
189    body.ansible_verbosity,
190    body.ansible_passthrough.as_deref(),
191    body.dry_run,
192  )
193  .await
194  .map_err(to_handler_error)?;
195  if !body.dry_run {
196    note.produced("session", &session.name);
197  }
198
199  Ok((StatusCode::CREATED, Json::<CfsSessionGetResponse>(session)))
200}
201
202// ---------------------------------------------------------------------------
203// POST /v2/sat-file/images/stamp — Given a (terminal-complete) CFS
204// session name, fetch it, derive `manta.image_session.{base,groups,
205// configuration}` from it, and PATCH them onto the IMS image the session
206// produced. Fails fast when the session has no result image.
207// ---------------------------------------------------------------------------
208
209#[utoipa::path(post, path = "/sat-file/images/stamp", tag = "sat-file",
210  params(SiteHeader),
211  request_body = StampImageFromSessionRequest,
212  security(("bearerAuth" = [])),
213  responses(
214    // Image (IMS) lives in manta-backend-dispatcher (third-party,
215    // no ToSchema) — kept as Value until upstream derives it.
216    (status = 200, description = "Image stamped",               body = serde_json::Value),
217    (status = 400, description = "Session not complete / no image", body = ErrorResponse),
218    (status = 401, description = "Unauthorized",                body = ErrorResponse),
219    (status = 500, description = "Internal error",              body = ErrorResponse),
220  )
221)]
222/// `POST /v2/sat-file/images/stamp` — fetch the named CFS session,
223/// derive the provenance stamp, and PATCH the produced IMS image.
224///
225/// Performs two boundary checks before delegating to the backend:
226/// the caller must have access to every HSM group the session
227/// targets, and the session must have produced a result image. See
228/// [`crate::service::session::validate_session_access`] +
229/// [`crate::service::session::require_result_image`].
230#[tracing::instrument(skip_all)]
231pub async fn post_sat_image_stamp(
232  ctx: RequestCtx,
233  note: AuditNote,
234  Json(body): Json<StampImageFromSessionRequest>,
235) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
236  note
237    .action("sat_file.image.stamp")
238    .target("session", &body.cfs_session_name);
239
240  tracing::info!("post_sat_image_stamp cfs_session={}", body.cfs_session_name);
241  let infra = ctx.infra();
242
243  let session = crate::service::session::validate_session_access(
244    &infra,
245    &ctx.token,
246    &body.cfs_session_name,
247  )
248  .await
249  .map_err(to_handler_error)?;
250
251  crate::service::session::require_result_image(&session)
252    .map_err(to_handler_error)?;
253
254  let image = crate::service::sat_file::stamp_image_from_session(
255    &infra,
256    &ctx.token,
257    &body.cfs_session_name,
258  )
259  .await
260  .map_err(to_handler_error)?;
261  if let Some(id) = &image.id {
262    note.produced("image", id);
263  }
264
265  Ok(Json::<Image>(image))
266}
267
268// ---------------------------------------------------------------------------
269// POST /v2/sat-file/session-templates — Apply one SAT session_template
270// ---------------------------------------------------------------------------
271
272#[utoipa::path(post, path = "/sat-file/session-templates", tag = "sat-file",
273  params(SiteHeader),
274  request_body = PostSatSessionTemplateRequest,
275  security(("bearerAuth" = [])),
276  responses(
277    (status = 200, description = "Session template applied", body = PostSatSessionTemplateResponse),
278    (status = 401, description = "Unauthorized",             body = ErrorResponse),
279    (status = 500, description = "Internal error",           body = ErrorResponse),
280  )
281)]
282/// `POST /v2/sat-file/session-templates` — apply a single SAT
283/// session_template entry. Returns the created BOS session template
284/// and (if `create_bos_session` was set and we're not in dry-run) the
285/// BOS session that was created from the new template to boot the
286/// targeted nodes through it.
287#[tracing::instrument(skip_all)]
288pub async fn post_sat_session_template(
289  ctx: RequestCtx,
290  note: AuditNote,
291  Json(body): Json<PostSatSessionTemplateRequest>,
292) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
293  note
294    .action("sat_file.session_template.apply")
295    .targets("template", sat_element_name(&body.session_template))
296    .detail("create_bos_session", body.create_bos_session)
297    .dry_run(body.dry_run);
298
299  tracing::info!(
300    "post_sat_session_template dry_run={} create_bos_session={}",
301    body.dry_run,
302    body.create_bos_session
303  );
304  let infra = ctx.infra();
305
306  let target_groups =
307    crate::service::sat_groups::extract_session_template_groups(
308      &body.session_template,
309    );
310
311  // Group access validation and the backend call are consolidated in
312  // the service function: it fetches the available-group list once,
313  // validates inline (non-admin callers), and forwards the list to the
314  // backend — eliminating the duplicate get_group_name_available fetch
315  // that previously appeared here.
316  let (template, session) = crate::service::sat_file::apply_session_template(
317    &infra,
318    &ctx.token,
319    body.session_template,
320    body.ref_lookup,
321    &target_groups,
322    body.create_bos_session,
323    body.dry_run,
324  )
325  .await
326  .map_err(to_handler_error)?;
327  if !body.dry_run
328    && let Some(name) = session.as_ref().and_then(|s| s.name.as_ref())
329  {
330    note.produced("session", name);
331  }
332
333  // Dry-run + create_bos_session: the backend has returned a mock
334  // template but no session (it never actually created one). Synthesise
335  // a mock session so the client can review the BOS session that *would*
336  // have been kicked off. The mock has no status — it never ran — and
337  // its name is prefixed with "dry-run-" to make accidental confusion
338  // with a real persisted session impossible.
339  let session = match session {
340    Some(s) => {
341      tracing::debug!(
342        "backend returned a session (dry_run={}, create_bos_session={})",
343        body.dry_run,
344        body.create_bos_session
345      );
346      Some(s)
347    }
348    None if body.dry_run && body.create_bos_session => {
349      let mock = mock_bos_session_for_template(&template);
350      tracing::info!(
351        "Synthesising mock BOS session for dry-run preview (name={:?}, template={})",
352        mock.name,
353        mock.template_name
354      );
355      Some(mock)
356    }
357    None => {
358      tracing::debug!(
359        "no session returned (backend=None, dry_run={}, create_bos_session={})",
360        body.dry_run,
361        body.create_bos_session
362      );
363      None
364    }
365  };
366
367  Ok(Json(PostSatSessionTemplateResponse { template, session }))
368}
369
370/// Build a `BosSession` that mirrors what a real session created from
371/// `template` would look like, for dry-run preview only. The session
372/// carries no `Status` (it never ran), and its `name` is prefixed with
373/// `"dry-run-"` so a consumer can't mistake it for a persisted CSM
374/// session.
375fn mock_bos_session_for_template(
376  template: &manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate,
377) -> BosSession {
378  let template_name = template
379    .name
380    .clone()
381    .unwrap_or_else(|| "<unnamed>".to_string());
382  BosSession {
383    name: Some(format!("dry-run-{template_name}")),
384    tenant: None,
385    operation: Some(BosOperation::Reboot),
386    template_name,
387    limit: None,
388    stage: None,
389    components: None,
390    include_disabled: None,
391    status: None,
392  }
393}
394
395/// `name` of a SAT-file element (configuration, image, session
396/// template), for the audit event.
397fn sat_element_name(element: &serde_json::Value) -> Option<&str> {
398  element.get("name").and_then(serde_json::Value::as_str)
399}
400
401#[cfg(test)]
402mod tests {
403  //! Locks the JSON wire format of the per-element request/response
404  //! types. The CLI builds the request JSON literally and pretty-prints
405  //! each response verbatim, so renames or reordering here would break
406  //! the wire boundary.
407
408  use super::{
409    CreateImageCfsSessionRequest, PostSatConfigurationRequest,
410    PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
411    StampImageFromSessionRequest,
412  };
413
414  /// Lock the shape of the CLI's POST /sat-file/configurations body.
415  /// Catches renames on either side of the wire.
416  #[test]
417  fn cli_configuration_body_deserialises() {
418    let cli_body = serde_json::json!({
419      "configuration": { "name": "cfg-v1", "layers": [] },
420      "overwrite": true,
421      "dry_run": false,
422    });
423    let req: PostSatConfigurationRequest =
424      serde_json::from_value(cli_body).unwrap();
425    assert_eq!(req.configuration["name"].as_str(), Some("cfg-v1"));
426    assert!(req.overwrite);
427    assert!(!req.dry_run);
428  }
429
430  /// Minimal configuration body — only `configuration` is required; the
431  /// two booleans default to `false`.
432  #[test]
433  fn cli_configuration_body_with_defaults_deserialises() {
434    let cli_body = serde_json::json!({
435      "configuration": { "name": "cfg-v1" },
436    });
437    let req: PostSatConfigurationRequest =
438      serde_json::from_value(cli_body).unwrap();
439    assert!(!req.overwrite);
440    assert!(!req.dry_run);
441  }
442
443  /// Lock the shape of the CLI's POST /sat-file/images/cfs-session body.
444  #[test]
445  fn cli_create_image_cfs_session_body_deserialises() {
446    let cli_body = serde_json::json!({
447      "image": { "name": "img-v1", "ref_name": "base", "configuration": "cfg-v1" },
448      "ref_lookup": { "earlier-ref": "abc-123" },
449      "ansible_verbosity": 3,
450      "ansible_passthrough": "--check",
451      "dry_run": false,
452    });
453    let req: CreateImageCfsSessionRequest =
454      serde_json::from_value(cli_body).unwrap();
455    assert_eq!(req.image["name"].as_str(), Some("img-v1"));
456    assert_eq!(
457      req.ref_lookup.get("earlier-ref").map(String::as_str),
458      Some("abc-123")
459    );
460    assert_eq!(req.ansible_verbosity, Some(3));
461    assert_eq!(req.ansible_passthrough.as_deref(), Some("--check"));
462    assert!(!req.dry_run);
463  }
464
465  /// Minimal create-session body — only `image` is required.
466  #[test]
467  fn cli_create_image_cfs_session_body_with_defaults_deserialises() {
468    let cli_body = serde_json::json!({ "image": { "name": "img-v1" } });
469    let req: CreateImageCfsSessionRequest =
470      serde_json::from_value(cli_body).unwrap();
471    assert!(req.ref_lookup.is_empty());
472    assert_eq!(req.ansible_verbosity, None);
473    assert_eq!(req.ansible_passthrough, None);
474    assert!(!req.dry_run);
475  }
476
477  /// Lock the shape of the CLI's POST /sat-file/images/stamp body —
478  /// just the CFS session name.
479  #[test]
480  fn cli_stamp_image_body_deserialises() {
481    let cli_body = serde_json::json!({ "cfs_session_name": "sat-img-v1" });
482    let req: StampImageFromSessionRequest =
483      serde_json::from_value(cli_body).unwrap();
484    assert_eq!(req.cfs_session_name, "sat-img-v1");
485  }
486
487  /// Lock the shape of the CLI's POST /sat-file/session-templates body.
488  #[test]
489  fn cli_session_template_body_deserialises() {
490    let cli_body = serde_json::json!({
491      "session_template": { "name": "st-1", "image": { "image_ref": "base" }, "configuration": "cfg-v1" },
492      "ref_lookup": { "base": "image-xyz" },
493      "create_bos_session": true,
494      "dry_run": false,
495    });
496    let req: PostSatSessionTemplateRequest =
497      serde_json::from_value(cli_body).unwrap();
498    assert_eq!(req.session_template["name"].as_str(), Some("st-1"));
499    assert_eq!(
500      req.ref_lookup.get("base").map(String::as_str),
501      Some("image-xyz")
502    );
503    assert!(req.create_bos_session);
504    assert!(!req.dry_run);
505  }
506
507  /// Lock the shape of the session_template response body —
508  /// `{ template, session? }`. The CLI's dispatcher reads these
509  /// two fields by name.
510  #[test]
511  fn session_template_response_serialises_with_template_and_optional_session() {
512    use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
513
514    let body = PostSatSessionTemplateResponse {
515      template: BosSessionTemplate {
516        name: Some("st-1".to_string()),
517        tenant: None,
518        description: None,
519        enable_cfs: Some(true),
520        cfs: None,
521        boot_sets: None,
522        links: None,
523      },
524      session: None,
525    };
526    let v: serde_json::Value = serde_json::to_value(&body).unwrap();
527    let obj = v.as_object().expect("object");
528    assert!(obj.contains_key("template"));
529    assert!(obj.contains_key("session"));
530    assert_eq!(obj["template"]["name"].as_str(), Some("st-1"));
531    assert!(obj["session"].is_null());
532  }
533
534  /// Mock BOS session for dry-run + create_bos_session: name carries
535  /// the `dry-run-` prefix, the operation is Reboot, the template_name
536  /// follows the template, and no Status is attached (the session
537  /// never ran).
538  #[test]
539  fn dry_run_mock_bos_session_for_template_shape() {
540    use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
541
542    let template = BosSessionTemplate {
543      name: Some("st-42".to_string()),
544      tenant: None,
545      description: None,
546      enable_cfs: None,
547      cfs: None,
548      boot_sets: None,
549      links: None,
550    };
551    let session = super::mock_bos_session_for_template(&template);
552    assert_eq!(session.name.as_deref(), Some("dry-run-st-42"));
553    assert_eq!(session.template_name, "st-42");
554    assert!(session.status.is_none());
555    assert!(matches!(
556      session.operation,
557      Some(super::BosOperation::Reboot)
558    ));
559  }
560
561  /// Template with no name → mock falls back to `<unnamed>` so the
562  /// session shape is still valid (template_name is required on
563  /// BosSession).
564  #[test]
565  fn dry_run_mock_handles_unnamed_template() {
566    use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
567
568    let template = BosSessionTemplate {
569      name: None,
570      tenant: None,
571      description: None,
572      enable_cfs: None,
573      cfs: None,
574      boot_sets: None,
575      links: None,
576    };
577    let session = super::mock_bos_session_for_template(&template);
578    assert_eq!(session.template_name, "<unnamed>");
579    assert_eq!(session.name.as_deref(), Some("dry-run-<unnamed>"));
580  }
581}