manta_server/server/handlers/
sat_file.rs

1//! POST /api/v1/sat-file.
2//!
3//! Accepts a pre-rendered SAT YAML plus apply-time flags, forwards them
4//! to [`service::sat_file::apply_sat_file`], and returns the four lists
5//! of artifacts the backend produced as a [`PostSatFileResponse`]. The
6//! CLI deserialises that JSON into `serde_json::Value` and pretty-prints
7//! it, so any change to the field names here is user-visible.
8
9use axum::{Json, http::StatusCode, response::IntoResponse};
10use manta_backend_dispatcher::types::{
11  bos::{session::BosSession, session_template::BosSessionTemplate},
12  cfs::cfs_configuration_response::CfsConfigurationResponse,
13  ims::Image,
14};
15use serde::{Deserialize, Serialize};
16use utoipa::ToSchema;
17
18use super::{
19  ErrorResponse, RequestCtx, SiteHeader, display_error, require_k8s_url,
20  require_vault,
21};
22use crate::service;
23
24// ---------------------------------------------------------------------------
25// POST /api/v1/sat-file — Apply a SAT file
26// ---------------------------------------------------------------------------
27
28/// Request body for `POST /sat-file`.
29///
30/// The CLI renders Jinja2, parses the rendered YAML into a structured
31/// value, applies the `image_only` / `session_template_only` filters
32/// locally, and sends the resulting value in `sat_file`. The server only
33/// orchestrates the apply (Vault secrets, HSM groups, backend call).
34#[derive(Deserialize, ToSchema)]
35pub struct PostSatFileRequest {
36  /// Final SAT file as a structured value — Jinja2 already evaluated
37  /// and `image_only` / `session_template_only` filters already applied
38  /// client-side.
39  #[schema(value_type = serde_json::Value)]
40  pub sat_file: serde_json::Value,
41  /// Ansible verbosity level passed to any CFS sessions created.
42  pub ansible_verbosity: Option<u8>,
43  /// Extra arguments forwarded verbatim to `ansible-playbook`.
44  pub ansible_passthrough: Option<String>,
45  /// Reboot nodes after applying the SAT file.
46  #[serde(default)]
47  pub reboot: bool,
48  /// Stream CFS session logs after creation.
49  #[serde(default)]
50  pub watch_logs: bool,
51  /// Prefix log lines with timestamps when streaming logs.
52  #[serde(default)]
53  pub timestamps: bool,
54  /// Overwrite existing IMS images or BOS session templates.
55  #[serde(default)]
56  pub overwrite: bool,
57  /// When true, validates the SAT file without creating any resources.
58  #[serde(default)]
59  pub dry_run: bool,
60}
61
62/// Response body for `POST /sat-file`. Each field is the list of objects
63/// the backend produced (or would produce, in `dry_run` mode) while
64/// realising the SAT file.
65#[derive(Serialize, ToSchema)]
66pub struct PostSatFileResponse {
67  /// CFS configurations created from the SAT file's `configurations`.
68  #[schema(value_type = Vec<serde_json::Value>)]
69  pub configurations: Vec<CfsConfigurationResponse>,
70  /// IMS images built from the SAT file's `images`.
71  #[schema(value_type = Vec<serde_json::Value>)]
72  pub images: Vec<Image>,
73  /// BOS session templates created from `session_templates`.
74  #[schema(value_type = Vec<serde_json::Value>)]
75  pub session_templates: Vec<BosSessionTemplate>,
76  /// BOS sessions triggered when `reboot` was set.
77  #[schema(value_type = Vec<serde_json::Value>)]
78  pub bos_sessions: Vec<BosSession>,
79}
80
81/// `POST /api/v1/sat-file` — apply a pre-rendered SAT file (images, session
82/// templates, and CFS sessions).
83#[utoipa::path(post, path = "/sat-file", tag = "sat-file",
84  params(SiteHeader),
85  request_body = PostSatFileRequest,
86  security(("bearerAuth" = [])),
87  responses(
88    (status = 200, description = "SAT file applied",               body = PostSatFileResponse),
89    (status = 401, description = "Unauthorized",                   body = ErrorResponse),
90    (status = 500, description = "Internal error",                 body = ErrorResponse),
91    (status = 501, description = "Vault or k8s not configured",    body = ErrorResponse),
92  )
93)]
94#[tracing::instrument(skip_all)]
95pub async fn post_sat_file(
96  ctx: RequestCtx,
97  Json(body): Json<PostSatFileRequest>,
98) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
99  tracing::info!("post_sat_file dry_run={}", body.dry_run);
100  let infra = ctx.infra();
101
102  let vault_base_url = require_vault(infra.vault_base_url)?;
103  let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
104
105  let gitea_token =
106    crate::server::common::vault::http_client::fetch_shasta_vcs_token(
107      &ctx.token,
108      vault_base_url,
109      infra.site_name,
110    )
111    .await
112    .map_err(display_error)?;
113
114  let (configurations, images, session_templates, bos_sessions) =
115    service::sat_file::apply_sat_file(
116      &infra,
117      &ctx.token,
118      &gitea_token,
119      vault_base_url,
120      k8s_api_url,
121      service::sat_file::ApplySatFileParams {
122        sat_file: body.sat_file,
123        ansible_verbosity: body.ansible_verbosity,
124        ansible_passthrough: body.ansible_passthrough.as_deref(),
125        reboot: body.reboot,
126        watch_logs: body.watch_logs,
127        timestamps: body.timestamps,
128        overwrite: body.overwrite,
129        dry_run: body.dry_run,
130      },
131    )
132    .await
133    .map_err(display_error)?;
134
135  Ok(Json(PostSatFileResponse {
136    configurations,
137    images,
138    session_templates,
139    bos_sessions,
140  }))
141}
142
143#[cfg(test)]
144mod tests {
145  //! Locks the JSON wire format of `PostSatFileRequest` and
146  //! `PostSatFileResponse`. The CLI builds the request JSON literally
147  //! and pretty-prints the response value verbatim, so renames or
148  //! reordering here would break the wire boundary.
149
150  use super::{PostSatFileRequest, PostSatFileResponse};
151
152  #[test]
153  fn empty_response_has_four_named_arrays() {
154    let body = PostSatFileResponse {
155      configurations: vec![],
156      images: vec![],
157      session_templates: vec![],
158      bos_sessions: vec![],
159    };
160    let v: serde_json::Value = serde_json::to_value(&body).unwrap();
161    let obj = v.as_object().expect("object");
162    assert_eq!(obj.len(), 4);
163    for key in
164      ["configurations", "images", "session_templates", "bos_sessions"]
165    {
166      assert!(obj.contains_key(key), "missing key: {key}");
167      assert!(obj[key].is_array(), "{key} should be an array");
168      assert_eq!(obj[key].as_array().unwrap().len(), 0);
169    }
170  }
171
172  /// Wire-boundary test: a request body shaped exactly the way the CLI
173  /// builds it (in `MantaClient::apply_sat_file`) must deserialise into
174  /// `PostSatFileRequest`. Catches accidental renames of `sat_file` or
175  /// any of the flag fields on either side of the wire.
176  #[test]
177  fn cli_request_body_deserialises_into_post_sat_file_request() {
178    let cli_body = serde_json::json!({
179      "sat_file": {
180        "configurations": [{ "name": "cfg-v1", "layers": [] }],
181        "images": [{ "name": "img-v1", "configuration": "cfg-v1" }],
182        "session_templates": [
183          { "name": "st-v1", "image": { "image_ref": "img-v1" }, "configuration": "cfg-v1" }
184        ]
185      },
186      "ansible_verbosity": 2,
187      "ansible_passthrough": "--check",
188      "reboot": true,
189      "watch_logs": true,
190      "timestamps": false,
191      "overwrite": true,
192      "dry_run": false,
193    });
194
195    let req: PostSatFileRequest = serde_json::from_value(cli_body).unwrap();
196
197    assert_eq!(req.ansible_verbosity, Some(2));
198    assert_eq!(req.ansible_passthrough.as_deref(), Some("--check"));
199    assert!(req.reboot);
200    assert!(req.watch_logs);
201    assert!(!req.timestamps);
202    assert!(req.overwrite);
203    assert!(!req.dry_run);
204
205    // The structured SAT file round-trips intact.
206    let sat = req.sat_file.as_object().expect("sat_file is object");
207    assert!(sat.contains_key("configurations"));
208    assert!(sat.contains_key("images"));
209    assert!(sat.contains_key("session_templates"));
210    assert_eq!(
211      sat["images"][0]["name"].as_str(),
212      Some("img-v1"),
213      "image name should round-trip"
214    );
215  }
216
217  /// CLI default-flag form: only required fields and `#[serde(default)]`
218  /// fields omitted. Verifies the server accepts the minimal body.
219  #[test]
220  fn cli_request_body_with_defaults_deserialises() {
221    let cli_body = serde_json::json!({
222      "sat_file": { "configurations": [], "images": [], "session_templates": [] },
223    });
224    let req: PostSatFileRequest = serde_json::from_value(cli_body).unwrap();
225    assert_eq!(req.ansible_verbosity, None);
226    assert_eq!(req.ansible_passthrough, None);
227    assert!(!req.reboot);
228    assert!(!req.watch_logs);
229    assert!(!req.timestamps);
230    assert!(!req.overwrite);
231    assert!(!req.dry_run);
232  }
233
234  /// Missing `sat_file` must fail — there's no default for it.
235  #[test]
236  fn request_body_without_sat_file_is_rejected() {
237    let body = serde_json::json!({ "reboot": true });
238    let result = serde_json::from_value::<PostSatFileRequest>(body);
239    let err = match result {
240      Ok(_) => panic!("expected deserialisation failure"),
241      Err(e) => e,
242    };
243    assert!(
244      err.to_string().contains("sat_file"),
245      "error should mention the missing field, got: {err}"
246    );
247  }
248}