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