manta_server/server/handlers/
sat_file.rs1use 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#[derive(Deserialize, ToSchema)]
35pub struct PostSatFileRequest {
36 #[schema(value_type = serde_json::Value)]
40 pub sat_file: serde_json::Value,
41 pub ansible_verbosity: Option<u8>,
43 pub ansible_passthrough: Option<String>,
45 #[serde(default)]
47 pub reboot: bool,
48 #[serde(default)]
50 pub watch_logs: bool,
51 #[serde(default)]
53 pub timestamps: bool,
54 #[serde(default)]
56 pub overwrite: bool,
57 #[serde(default)]
59 pub dry_run: bool,
60}
61
62#[derive(Serialize, ToSchema)]
66pub struct PostSatFileResponse {
67 #[schema(value_type = Vec<serde_json::Value>)]
69 pub configurations: Vec<CfsConfigurationResponse>,
70 #[schema(value_type = Vec<serde_json::Value>)]
72 pub images: Vec<Image>,
73 #[schema(value_type = Vec<serde_json::Value>)]
75 pub session_templates: Vec<BosSessionTemplate>,
76 #[schema(value_type = Vec<serde_json::Value>)]
78 pub bos_sessions: Vec<BosSession>,
79}
80
81#[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 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 #[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 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 #[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 #[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}