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::service::authorization::validate_user_group_vec_access;
53
54use super::{
55 ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
56 to_handler_error,
57};
58
59pub use manta_shared::types::api::sat_file::{
64 CreateImageCfsSessionRequest, PostSatConfigurationRequest,
65 PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
66 PostSatValidateRequest, StampImageFromSessionRequest,
67};
68
69#[utoipa::path(post, path = "/sat-file/configurations", tag = "sat-file",
70 params(SiteHeader),
71 request_body = PostSatConfigurationRequest,
72 security(("bearerAuth" = [])),
73 responses(
74 (status = 200, description = "Configuration applied", body = serde_json::Value),
77 (status = 401, description = "Unauthorized", body = ErrorResponse),
78 (status = 500, description = "Internal error", body = ErrorResponse),
79 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
80 )
81)]
82#[tracing::instrument(skip_all)]
85pub async fn post_sat_configuration(
86 ctx: RequestCtx,
87 Json(body): Json<PostSatConfigurationRequest>,
88) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
89 tracing::info!("post_sat_configuration dry_run={}", body.dry_run);
90 let infra = ctx.infra();
91
92 let vault_base_url = require_vault(infra.vault_base_url)?;
93 let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
94
95 let gitea_token =
96 crate::server::common::vault::http_client::get_shasta_vcs_token(
97 &ctx.token,
98 vault_base_url,
99 infra.site_name,
100 )
101 .await
102 .map_err(to_handler_error)?;
103
104 let cfg = crate::service::sat_file::apply_configuration(
111 &infra,
112 &ctx.token,
113 vault_base_url,
114 k8s_api_url,
115 &gitea_token,
116 body.configuration,
117 body.dry_run,
118 body.overwrite,
119 )
120 .await
121 .map_err(to_handler_error)?;
122
123 Ok(Json(cfg))
124}
125
126#[utoipa::path(post, path = "/sat-file/images/cfs-session", tag = "sat-file",
134 params(SiteHeader),
135 request_body = CreateImageCfsSessionRequest,
136 security(("bearerAuth" = [])),
137 responses(
138 (status = 201, description = "CFS session created", body = serde_json::Value),
141 (status = 401, description = "Unauthorized", body = ErrorResponse),
142 (status = 500, description = "Internal error", body = ErrorResponse),
143 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
144 )
145)]
146#[tracing::instrument(skip_all)]
151pub async fn post_sat_image_cfs_session(
152 ctx: RequestCtx,
153 Json(body): Json<CreateImageCfsSessionRequest>,
154) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
155 tracing::info!("post_sat_image_cfs_session dry_run={}", body.dry_run);
156 let infra = ctx.infra();
157
158 let vault_base_url = require_vault(infra.vault_base_url)?;
159 let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
160
161 let target_groups =
162 crate::service::sat_groups::extract_image_groups(&body.image);
163
164 validate_user_group_vec_access(&infra, &ctx.token, &target_groups)
165 .await
166 .map_err(to_handler_error)?;
167
168 let session = crate::service::sat_file::create_image_cfs_session(
169 &infra,
170 &ctx.token,
171 vault_base_url,
172 k8s_api_url,
173 body.image,
174 body.ref_lookup,
175 body.ansible_verbosity,
176 body.ansible_passthrough.as_deref(),
177 body.dry_run,
178 )
179 .await
180 .map_err(to_handler_error)?;
181
182 Ok((StatusCode::CREATED, Json::<CfsSessionGetResponse>(session)))
183}
184
185#[utoipa::path(post, path = "/sat-file/images/stamp", tag = "sat-file",
193 params(SiteHeader),
194 request_body = StampImageFromSessionRequest,
195 security(("bearerAuth" = [])),
196 responses(
197 (status = 200, description = "Image stamped", body = serde_json::Value),
200 (status = 400, description = "Session not complete / no image", body = ErrorResponse),
201 (status = 401, description = "Unauthorized", body = ErrorResponse),
202 (status = 500, description = "Internal error", body = ErrorResponse),
203 )
204)]
205#[tracing::instrument(skip_all)]
214pub async fn post_sat_image_stamp(
215 ctx: RequestCtx,
216 Json(body): Json<StampImageFromSessionRequest>,
217) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
218 tracing::info!("post_sat_image_stamp cfs_session={}", body.cfs_session_name);
219 let infra = ctx.infra();
220
221 let session = crate::service::session::validate_session_access(
222 &infra,
223 &ctx.token,
224 &body.cfs_session_name,
225 )
226 .await
227 .map_err(to_handler_error)?;
228
229 crate::service::session::require_result_image(&session)
230 .map_err(to_handler_error)?;
231
232 let image = crate::service::sat_file::stamp_image_from_session(
233 &infra,
234 &ctx.token,
235 &body.cfs_session_name,
236 )
237 .await
238 .map_err(to_handler_error)?;
239
240 Ok(Json::<Image>(image))
241}
242
243#[utoipa::path(post, path = "/sat-file/session-templates", tag = "sat-file",
248 params(SiteHeader),
249 request_body = PostSatSessionTemplateRequest,
250 security(("bearerAuth" = [])),
251 responses(
252 (status = 200, description = "Session template applied", body = PostSatSessionTemplateResponse),
253 (status = 401, description = "Unauthorized", body = ErrorResponse),
254 (status = 500, description = "Internal error", body = ErrorResponse),
255 )
256)]
257#[tracing::instrument(skip_all)]
263pub async fn post_sat_session_template(
264 ctx: RequestCtx,
265 Json(body): Json<PostSatSessionTemplateRequest>,
266) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
267 tracing::info!(
268 "post_sat_session_template dry_run={} create_bos_session={}",
269 body.dry_run,
270 body.create_bos_session
271 );
272 let infra = ctx.infra();
273
274 let target_groups =
275 crate::service::sat_groups::extract_session_template_groups(
276 &body.session_template,
277 );
278
279 let (template, session) = crate::service::sat_file::apply_session_template(
285 &infra,
286 &ctx.token,
287 body.session_template,
288 body.ref_lookup,
289 &target_groups,
290 body.create_bos_session,
291 body.dry_run,
292 )
293 .await
294 .map_err(to_handler_error)?;
295
296 let session = match session {
303 Some(s) => {
304 tracing::debug!(
305 "backend returned a session (dry_run={}, create_bos_session={})",
306 body.dry_run,
307 body.create_bos_session
308 );
309 Some(s)
310 }
311 None if body.dry_run && body.create_bos_session => {
312 let mock = mock_bos_session_for_template(&template);
313 tracing::info!(
314 "Synthesising mock BOS session for dry-run preview (name={:?}, template={})",
315 mock.name,
316 mock.template_name
317 );
318 Some(mock)
319 }
320 None => {
321 tracing::debug!(
322 "no session returned (backend=None, dry_run={}, create_bos_session={})",
323 body.dry_run,
324 body.create_bos_session
325 );
326 None
327 }
328 };
329
330 Ok(Json(PostSatSessionTemplateResponse { template, session }))
331}
332
333fn mock_bos_session_for_template(
339 template: &manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate,
340) -> BosSession {
341 let template_name = template
342 .name
343 .clone()
344 .unwrap_or_else(|| "<unnamed>".to_string());
345 BosSession {
346 name: Some(format!("dry-run-{template_name}")),
347 tenant: None,
348 operation: Some(BosOperation::Reboot),
349 template_name,
350 limit: None,
351 stage: None,
352 components: None,
353 include_disabled: None,
354 status: None,
355 }
356}
357
358#[utoipa::path(post, path = "/sat-file/validate", tag = "sat-file",
365 params(SiteHeader),
366 request_body = PostSatValidateRequest,
367 security(("bearerAuth" = [])),
368 responses(
369 (status = 204, description = "SAT file is valid (configurations, images, session_templates sections — `hardware` is not validated)"),
370 (status = 400, description = "SAT validation failed", body = ErrorResponse),
371 (status = 401, description = "Unauthorized", body = ErrorResponse),
372 (status = 403, description = "Caller cannot target referenced HSM groups", body = ErrorResponse),
373 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
374 )
375)]
376#[tracing::instrument(skip_all)]
388pub async fn post_sat_validate(
389 ctx: RequestCtx,
390 Json(body): Json<PostSatValidateRequest>,
391) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
392 tracing::info!("post_sat_validate");
393 let infra = ctx.infra();
394
395 let vault_base_url = require_vault(infra.vault_base_url)?;
396 let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
397
398 let target_groups =
399 crate::service::sat_groups::extract_all_target_groups(&body.sat_file);
400
401 crate::service::sat_file::validate_sat_file(
407 &infra,
408 &ctx.token,
409 body.sat_file,
410 &target_groups,
411 vault_base_url,
412 k8s_api_url,
413 )
414 .await
415 .map_err(to_handler_error)?;
416
417 Ok(StatusCode::NO_CONTENT)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::{
428 CreateImageCfsSessionRequest, PostSatConfigurationRequest,
429 PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
430 PostSatValidateRequest, StampImageFromSessionRequest,
431 };
432
433 #[test]
436 fn cli_configuration_body_deserialises() {
437 let cli_body = serde_json::json!({
438 "configuration": { "name": "cfg-v1", "layers": [] },
439 "overwrite": true,
440 "dry_run": false,
441 });
442 let req: PostSatConfigurationRequest =
443 serde_json::from_value(cli_body).unwrap();
444 assert_eq!(req.configuration["name"].as_str(), Some("cfg-v1"));
445 assert!(req.overwrite);
446 assert!(!req.dry_run);
447 }
448
449 #[test]
452 fn cli_configuration_body_with_defaults_deserialises() {
453 let cli_body = serde_json::json!({
454 "configuration": { "name": "cfg-v1" },
455 });
456 let req: PostSatConfigurationRequest =
457 serde_json::from_value(cli_body).unwrap();
458 assert!(!req.overwrite);
459 assert!(!req.dry_run);
460 }
461
462 #[test]
464 fn cli_create_image_cfs_session_body_deserialises() {
465 let cli_body = serde_json::json!({
466 "image": { "name": "img-v1", "ref_name": "base", "configuration": "cfg-v1" },
467 "ref_lookup": { "earlier-ref": "abc-123" },
468 "ansible_verbosity": 3,
469 "ansible_passthrough": "--check",
470 "dry_run": false,
471 });
472 let req: CreateImageCfsSessionRequest =
473 serde_json::from_value(cli_body).unwrap();
474 assert_eq!(req.image["name"].as_str(), Some("img-v1"));
475 assert_eq!(
476 req.ref_lookup.get("earlier-ref").map(String::as_str),
477 Some("abc-123")
478 );
479 assert_eq!(req.ansible_verbosity, Some(3));
480 assert_eq!(req.ansible_passthrough.as_deref(), Some("--check"));
481 assert!(!req.dry_run);
482 }
483
484 #[test]
486 fn cli_create_image_cfs_session_body_with_defaults_deserialises() {
487 let cli_body = serde_json::json!({ "image": { "name": "img-v1" } });
488 let req: CreateImageCfsSessionRequest =
489 serde_json::from_value(cli_body).unwrap();
490 assert!(req.ref_lookup.is_empty());
491 assert_eq!(req.ansible_verbosity, None);
492 assert_eq!(req.ansible_passthrough, None);
493 assert!(!req.dry_run);
494 }
495
496 #[test]
499 fn cli_stamp_image_body_deserialises() {
500 let cli_body = serde_json::json!({ "cfs_session_name": "sat-img-v1" });
501 let req: StampImageFromSessionRequest =
502 serde_json::from_value(cli_body).unwrap();
503 assert_eq!(req.cfs_session_name, "sat-img-v1");
504 }
505
506 #[test]
508 fn cli_session_template_body_deserialises() {
509 let cli_body = serde_json::json!({
510 "session_template": { "name": "st-1", "image": { "image_ref": "base" }, "configuration": "cfg-v1" },
511 "ref_lookup": { "base": "image-xyz" },
512 "create_bos_session": true,
513 "dry_run": false,
514 });
515 let req: PostSatSessionTemplateRequest =
516 serde_json::from_value(cli_body).unwrap();
517 assert_eq!(req.session_template["name"].as_str(), Some("st-1"));
518 assert_eq!(
519 req.ref_lookup.get("base").map(String::as_str),
520 Some("image-xyz")
521 );
522 assert!(req.create_bos_session);
523 assert!(!req.dry_run);
524 }
525
526 #[test]
530 fn session_template_response_serialises_with_template_and_optional_session() {
531 use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
532
533 let body = PostSatSessionTemplateResponse {
534 template: BosSessionTemplate {
535 name: Some("st-1".to_string()),
536 tenant: None,
537 description: None,
538 enable_cfs: Some(true),
539 cfs: None,
540 boot_sets: None,
541 links: None,
542 },
543 session: None,
544 };
545 let v: serde_json::Value = serde_json::to_value(&body).unwrap();
546 let obj = v.as_object().expect("object");
547 assert!(obj.contains_key("template"));
548 assert!(obj.contains_key("session"));
549 assert_eq!(obj["template"]["name"].as_str(), Some("st-1"));
550 assert!(obj["session"].is_null());
551 }
552
553 #[test]
558 fn dry_run_mock_bos_session_for_template_shape() {
559 use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
560
561 let template = BosSessionTemplate {
562 name: Some("st-42".to_string()),
563 tenant: None,
564 description: None,
565 enable_cfs: None,
566 cfs: None,
567 boot_sets: None,
568 links: None,
569 };
570 let session = super::mock_bos_session_for_template(&template);
571 assert_eq!(session.name.as_deref(), Some("dry-run-st-42"));
572 assert_eq!(session.template_name, "st-42");
573 assert!(session.status.is_none());
574 assert!(matches!(
575 session.operation,
576 Some(super::BosOperation::Reboot)
577 ));
578 }
579
580 #[test]
584 fn dry_run_mock_handles_unnamed_template() {
585 use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
586
587 let template = BosSessionTemplate {
588 name: None,
589 tenant: None,
590 description: None,
591 enable_cfs: None,
592 cfs: None,
593 boot_sets: None,
594 links: None,
595 };
596 let session = super::mock_bos_session_for_template(&template);
597 assert_eq!(session.template_name, "<unnamed>");
598 assert_eq!(session.name.as_deref(), Some("dry-run-<unnamed>"));
599 }
600
601 #[test]
604 fn cli_validate_body_deserialises() {
605 let cli_body = serde_json::json!({
606 "sat_file": {
607 "configurations": [{ "name": "cfg-v1" }],
608 "images": [],
609 "session_templates": [],
610 }
611 });
612 let req: PostSatValidateRequest = serde_json::from_value(cli_body).unwrap();
613 assert!(req.sat_file.get("configurations").is_some());
614 }
615}