1use 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
60pub 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 (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#[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 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#[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 (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#[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#[utoipa::path(post, path = "/sat-file/images/stamp", tag = "sat-file",
210 params(SiteHeader),
211 request_body = StampImageFromSessionRequest,
212 security(("bearerAuth" = [])),
213 responses(
214 (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#[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#[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#[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 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 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
370fn 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
395fn 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 use super::{
409 CreateImageCfsSessionRequest, PostSatConfigurationRequest,
410 PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
411 StampImageFromSessionRequest,
412 };
413
414 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}