use axum::{Json, http::StatusCode, response::IntoResponse};
use manta_backend_dispatcher::interfaces::apply_sat_file::{
ApplyConfigurationParams as BackendApplyConfigurationParams,
ApplyImageCreateSessionParams as BackendApplyImageCreateSessionParams,
ApplyImageStampParams as BackendApplyImageStampParams,
ApplySessionTemplateParams as BackendApplySessionTemplateParams, SatTrait,
ValidateSatFileParams as BackendValidateSatFileParams,
};
use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
use manta_backend_dispatcher::types::bos::session::{
BosSession, Operation as BosOperation,
};
use manta_backend_dispatcher::types::cfs::session::CfsSessionGetResponse;
use manta_backend_dispatcher::types::ims::Image;
use crate::service::authorization::validate_user_group_vec_access;
use super::{
ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
to_handler_error,
};
pub use manta_shared::types::api::sat_file::{
CreateImageCfsSessionRequest, PostSatConfigurationRequest,
PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
PostSatValidateRequest, StampImageFromSessionRequest,
};
#[utoipa::path(post, path = "/sat-file/configurations", tag = "sat-file",
params(SiteHeader),
request_body = PostSatConfigurationRequest,
security(("bearerAuth" = [])),
responses(
// CfsConfigurationResponse lives in manta-backend-dispatcher (third-party,
// no ToSchema) — kept as Value until upstream derives it.
(status = 200, description = "Configuration applied", body = serde_json::Value),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 500, description = "Internal error", body = ErrorResponse),
(status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
)
)]
#[tracing::instrument(skip_all)]
pub async fn post_sat_configuration(
ctx: RequestCtx,
Json(body): Json<PostSatConfigurationRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
tracing::info!("post_sat_configuration dry_run={}", body.dry_run);
let infra = ctx.infra();
let vault_base_url = require_vault(infra.vault_base_url)?;
let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
let gitea_token =
crate::server::common::vault::http_client::get_shasta_vcs_token(
&ctx.token,
vault_base_url,
infra.site_name,
)
.await
.map_err(to_handler_error)?;
let cfg = infra
.backend
.apply_configuration(BackendApplyConfigurationParams {
shasta_token: &ctx.token,
vault_base_url,
site_name: infra.site_name,
k8s_api_url,
gitea_base_url: infra.gitea_base_url,
gitea_token: &gitea_token,
configuration: body.configuration,
dry_run: body.dry_run,
overwrite: body.overwrite,
})
.await
.map_err(to_handler_error)?;
Ok(Json(cfg))
}
#[utoipa::path(post, path = "/sat-file/images/cfs-session", tag = "sat-file",
params(SiteHeader),
request_body = CreateImageCfsSessionRequest,
security(("bearerAuth" = [])),
responses(
// CfsSessionGetResponse lives in manta-backend-dispatcher (third-party,
// no ToSchema) — kept as Value until upstream derives it.
(status = 201, description = "CFS session created", body = serde_json::Value),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 500, description = "Internal error", body = ErrorResponse),
(status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
)
)]
#[tracing::instrument(skip_all)]
pub async fn post_sat_image_cfs_session(
ctx: RequestCtx,
Json(body): Json<CreateImageCfsSessionRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
tracing::info!("post_sat_image_cfs_session dry_run={}", body.dry_run);
let infra = ctx.infra();
let vault_base_url = require_vault(infra.vault_base_url)?;
let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
let target_groups =
crate::service::sat_groups::extract_image_groups(&body.image);
validate_user_group_vec_access(&infra, &ctx.token, &target_groups)
.await
.map_err(to_handler_error)?;
let session = infra
.backend
.apply_sat_image_create_session(BackendApplyImageCreateSessionParams {
shasta_token: &ctx.token,
vault_base_url,
site_name: infra.site_name,
k8s_api_url,
image: body.image,
ref_lookup: body.ref_lookup,
ansible_verbosity: body.ansible_verbosity,
ansible_passthrough: body.ansible_passthrough.as_deref(),
dry_run: body.dry_run,
})
.await
.map_err(to_handler_error)?;
Ok((StatusCode::CREATED, Json::<CfsSessionGetResponse>(session)))
}
#[utoipa::path(post, path = "/sat-file/images/stamp", tag = "sat-file",
params(SiteHeader),
request_body = StampImageFromSessionRequest,
security(("bearerAuth" = [])),
responses(
// Image (IMS) lives in manta-backend-dispatcher (third-party,
// no ToSchema) — kept as Value until upstream derives it.
(status = 200, description = "Image stamped", body = serde_json::Value),
(status = 400, description = "Session not complete / no image", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 500, description = "Internal error", body = ErrorResponse),
)
)]
#[tracing::instrument(skip_all)]
pub async fn post_sat_image_stamp(
ctx: RequestCtx,
Json(body): Json<StampImageFromSessionRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
tracing::info!("post_sat_image_stamp cfs_session={}", body.cfs_session_name);
let infra = ctx.infra();
let session = crate::service::session::validate_session_access(
&infra,
&ctx.token,
&body.cfs_session_name,
)
.await
.map_err(to_handler_error)?;
crate::service::session::require_result_image(&session)
.map_err(to_handler_error)?;
let image = infra
.backend
.apply_sat_image_stamp_from_session(BackendApplyImageStampParams {
shasta_token: &ctx.token,
cfs_session_name: &body.cfs_session_name,
})
.await
.map_err(to_handler_error)?;
Ok(Json::<Image>(image))
}
#[utoipa::path(post, path = "/sat-file/session-templates", tag = "sat-file",
params(SiteHeader),
request_body = PostSatSessionTemplateRequest,
security(("bearerAuth" = [])),
responses(
(status = 200, description = "Session template applied", body = PostSatSessionTemplateResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 500, description = "Internal error", body = ErrorResponse),
)
)]
#[tracing::instrument(skip_all)]
pub async fn post_sat_session_template(
ctx: RequestCtx,
Json(body): Json<PostSatSessionTemplateRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
tracing::info!(
"post_sat_session_template dry_run={} create_bos_session={}",
body.dry_run,
body.create_bos_session
);
let infra = ctx.infra();
let target_groups =
crate::service::sat_groups::extract_session_template_groups(
&body.session_template,
);
validate_user_group_vec_access(&infra, &ctx.token, &target_groups)
.await
.map_err(to_handler_error)?;
let hsm_group_available_vec = infra
.backend
.get_group_name_available(&ctx.token)
.await
.map_err(to_handler_error)?;
let (template, session) = infra
.backend
.apply_session_template(BackendApplySessionTemplateParams {
shasta_token: &ctx.token,
session_template: body.session_template,
ref_lookup: body.ref_lookup,
hsm_group_available_vec: &hsm_group_available_vec,
reboot: body.create_bos_session,
dry_run: body.dry_run,
})
.await
.map_err(to_handler_error)?;
let session = match session {
Some(s) => {
tracing::debug!(
"backend returned a session (dry_run={}, create_bos_session={})",
body.dry_run,
body.create_bos_session
);
Some(s)
}
None if body.dry_run && body.create_bos_session => {
let mock = mock_bos_session_for_template(&template);
tracing::info!(
"Synthesising mock BOS session for dry-run preview (name={:?}, template={})",
mock.name,
mock.template_name
);
Some(mock)
}
None => {
tracing::debug!(
"no session returned (backend=None, dry_run={}, create_bos_session={})",
body.dry_run,
body.create_bos_session
);
None
}
};
Ok(Json(PostSatSessionTemplateResponse { template, session }))
}
fn mock_bos_session_for_template(
template: &manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate,
) -> BosSession {
let template_name = template
.name
.clone()
.unwrap_or_else(|| "<unnamed>".to_string());
BosSession {
name: Some(format!("dry-run-{template_name}")),
tenant: None,
operation: Some(BosOperation::Reboot),
template_name,
limit: None,
stage: None,
components: None,
include_disabled: None,
status: None,
}
}
#[utoipa::path(post, path = "/sat-file/validate", tag = "sat-file",
params(SiteHeader),
request_body = PostSatValidateRequest,
security(("bearerAuth" = [])),
responses(
(status = 204, description = "SAT file is valid (configurations, images, session_templates sections — `hardware` is not validated)"),
(status = 400, description = "SAT validation failed", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Caller cannot target referenced HSM groups", body = ErrorResponse),
(status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
)
)]
#[tracing::instrument(skip_all)]
pub async fn post_sat_validate(
ctx: RequestCtx,
Json(body): Json<PostSatValidateRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
tracing::info!("post_sat_validate");
let infra = ctx.infra();
let vault_base_url = require_vault(infra.vault_base_url)?;
let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
let target_groups =
crate::service::sat_groups::extract_all_target_groups(&body.sat_file);
validate_user_group_vec_access(&infra, &ctx.token, &target_groups)
.await
.map_err(to_handler_error)?;
let hsm_group_available_vec = infra
.backend
.get_group_name_available(&ctx.token)
.await
.map_err(to_handler_error)?;
infra
.backend
.validate_sat_file(BackendValidateSatFileParams {
shasta_token: &ctx.token,
vault_base_url,
site_name: infra.site_name,
k8s_api_url,
sat_file: body.sat_file,
hsm_group_available_vec: &hsm_group_available_vec,
})
.await
.map_err(to_handler_error)?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::{
CreateImageCfsSessionRequest, PostSatConfigurationRequest,
PostSatSessionTemplateRequest, PostSatSessionTemplateResponse,
PostSatValidateRequest, StampImageFromSessionRequest,
};
#[test]
fn cli_configuration_body_deserialises() {
let cli_body = serde_json::json!({
"configuration": { "name": "cfg-v1", "layers": [] },
"overwrite": true,
"dry_run": false,
});
let req: PostSatConfigurationRequest =
serde_json::from_value(cli_body).unwrap();
assert_eq!(req.configuration["name"].as_str(), Some("cfg-v1"));
assert!(req.overwrite);
assert!(!req.dry_run);
}
#[test]
fn cli_configuration_body_with_defaults_deserialises() {
let cli_body = serde_json::json!({
"configuration": { "name": "cfg-v1" },
});
let req: PostSatConfigurationRequest =
serde_json::from_value(cli_body).unwrap();
assert!(!req.overwrite);
assert!(!req.dry_run);
}
#[test]
fn cli_create_image_cfs_session_body_deserialises() {
let cli_body = serde_json::json!({
"image": { "name": "img-v1", "ref_name": "base", "configuration": "cfg-v1" },
"ref_lookup": { "earlier-ref": "abc-123" },
"ansible_verbosity": 3,
"ansible_passthrough": "--check",
"dry_run": false,
});
let req: CreateImageCfsSessionRequest =
serde_json::from_value(cli_body).unwrap();
assert_eq!(req.image["name"].as_str(), Some("img-v1"));
assert_eq!(
req.ref_lookup.get("earlier-ref").map(String::as_str),
Some("abc-123")
);
assert_eq!(req.ansible_verbosity, Some(3));
assert_eq!(req.ansible_passthrough.as_deref(), Some("--check"));
assert!(!req.dry_run);
}
#[test]
fn cli_create_image_cfs_session_body_with_defaults_deserialises() {
let cli_body = serde_json::json!({ "image": { "name": "img-v1" } });
let req: CreateImageCfsSessionRequest =
serde_json::from_value(cli_body).unwrap();
assert!(req.ref_lookup.is_empty());
assert_eq!(req.ansible_verbosity, None);
assert_eq!(req.ansible_passthrough, None);
assert!(!req.dry_run);
}
#[test]
fn cli_stamp_image_body_deserialises() {
let cli_body = serde_json::json!({ "cfs_session_name": "sat-img-v1" });
let req: StampImageFromSessionRequest =
serde_json::from_value(cli_body).unwrap();
assert_eq!(req.cfs_session_name, "sat-img-v1");
}
#[test]
fn cli_session_template_body_deserialises() {
let cli_body = serde_json::json!({
"session_template": { "name": "st-1", "image": { "image_ref": "base" }, "configuration": "cfg-v1" },
"ref_lookup": { "base": "image-xyz" },
"create_bos_session": true,
"dry_run": false,
});
let req: PostSatSessionTemplateRequest =
serde_json::from_value(cli_body).unwrap();
assert_eq!(req.session_template["name"].as_str(), Some("st-1"));
assert_eq!(
req.ref_lookup.get("base").map(String::as_str),
Some("image-xyz")
);
assert!(req.create_bos_session);
assert!(!req.dry_run);
}
#[test]
fn session_template_response_serialises_with_template_and_optional_session() {
use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
let body = PostSatSessionTemplateResponse {
template: BosSessionTemplate {
name: Some("st-1".to_string()),
tenant: None,
description: None,
enable_cfs: Some(true),
cfs: None,
boot_sets: None,
links: None,
},
session: None,
};
let v: serde_json::Value = serde_json::to_value(&body).unwrap();
let obj = v.as_object().expect("object");
assert!(obj.contains_key("template"));
assert!(obj.contains_key("session"));
assert_eq!(obj["template"]["name"].as_str(), Some("st-1"));
assert!(obj["session"].is_null());
}
#[test]
fn dry_run_mock_bos_session_for_template_shape() {
use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
let template = BosSessionTemplate {
name: Some("st-42".to_string()),
tenant: None,
description: None,
enable_cfs: None,
cfs: None,
boot_sets: None,
links: None,
};
let session = super::mock_bos_session_for_template(&template);
assert_eq!(session.name.as_deref(), Some("dry-run-st-42"));
assert_eq!(session.template_name, "st-42");
assert!(session.status.is_none());
assert!(matches!(
session.operation,
Some(super::BosOperation::Reboot)
));
}
#[test]
fn dry_run_mock_handles_unnamed_template() {
use manta_backend_dispatcher::types::bos::session_template::BosSessionTemplate;
let template = BosSessionTemplate {
name: None,
tenant: None,
description: None,
enable_cfs: None,
cfs: None,
boot_sets: None,
links: None,
};
let session = super::mock_bos_session_for_template(&template);
assert_eq!(session.template_name, "<unnamed>");
assert_eq!(session.name.as_deref(), Some("dry-run-<unnamed>"));
}
#[test]
fn cli_validate_body_deserialises() {
let cli_body = serde_json::json!({
"sat_file": {
"configurations": [{ "name": "cfg-v1" }],
"images": [],
"session_templates": [],
}
});
let req: PostSatValidateRequest = serde_json::from_value(cli_body).unwrap();
assert!(req.sat_file.get("configurations").is_some());
}
}