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