1use 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
66pub 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 (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#[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 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#[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 (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#[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#[utoipa::path(post, path = "/sat-file/images/stamp", tag = "sat-file",
205 params(SiteHeader),
206 request_body = StampImageFromSessionRequest,
207 security(("bearerAuth" = [])),
208 responses(
209 (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#[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#[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#[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 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
352fn 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#[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#[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 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 use super::{
456 CreateImageCfsSessionRequest, PostSatConfigurationRequest,
457 PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
458 PostSatValidateRequest, StampImageFromSessionRequest,
459 };
460
461 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}