use crate::{ObservedInventoryBatch, Platform, ResourceHeartbeat, StackState};
use alien_error::AlienError;
use bon::Builder;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum InitialSetupAuthority {
#[default]
ImportedHandoff,
DirectSetup,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SetupUpdateAuthorization {
pub nonce: String,
pub baseline_frozen_digest: String,
pub target_frozen_digest: String,
pub release_id: String,
pub setup_target: String,
pub setup_fingerprint: String,
pub setup_fingerprint_version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct RuntimeMetadata {
#[serde(default)]
pub initial_setup_authority: InitialSetupAuthority,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_synced_env_vars_hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub last_synced_secret_names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prepared_stack: Option<crate::Stack>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub persisted_gate_answers: GateAnswers,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_prepared_stack: Option<crate::Stack>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup_update_authorization: Option<SetupUpdateAuthorization>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direct_setup_revision: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub registry_access_granted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_access: Option<RegistryAccess>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SetupScaffolding {
#[serde(rename_all = "camelCase")]
AwsSandbox {
build_role_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
egress: Option<AwsSandboxEgressScaffolding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
image_arn: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AwsSandboxEgressScaffolding {
pub operator_role_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security_group_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connector_arn: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connector_request: Option<String>,
}
impl SetupScaffolding {
pub fn fill_missing(&mut self, found: SetupScaffolding) {
match (self, found) {
(
SetupScaffolding::AwsSandbox {
egress, image_arn, ..
},
SetupScaffolding::AwsSandbox {
egress: found_egress,
image_arn: found_image_arn,
..
},
) => {
if image_arn.is_none() {
*image_arn = found_image_arn;
}
match (egress.as_mut(), found_egress) {
(None, found_egress) => *egress = found_egress,
(Some(recorded), Some(found)) => {
if recorded.security_group_id.is_none() {
recorded.security_group_id = found.security_group_id;
}
if recorded.connector_arn.is_none() {
recorded.connector_arn = found.connector_arn;
}
}
(Some(_), None) => {}
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct RegistryAccess {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeploymentState {
pub status: DeploymentStatus,
pub platform: Platform,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_release: Option<ReleaseInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_release: Option<ReleaseInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_state: Option<StackState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<AlienError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_info: Option<EnvironmentInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_metadata: Option<RuntimeMetadata>,
#[serde(default, skip_serializing_if = "is_false")]
pub retry_requested: bool,
pub protocol_version: u32,
}
impl DeploymentState {
pub fn has_desired(&self) -> bool {
self.current_release.is_some()
|| self.target_release.is_some()
|| self.stack_state.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeploymentStepResult {
pub state: DeploymentState,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "is_false")]
pub update_heartbeat: bool,
#[serde(
default,
rename = "resourceHeartbeats",
skip_serializing_if = "Vec::is_empty"
)]
pub heartbeats: Vec<ResourceHeartbeat>,
#[serde(
default,
rename = "observedInventoryBatches",
skip_serializing_if = "Vec::is_empty"
)]
pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
}
pub(crate) fn is_false(b: &bool) -> bool {
!*b
}
pub type GateAnswers = IndexMap<String, bool>;
pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
#[cfg(test)]
mod tests {
use super::*;
use crate::{Platform, ReleaseInfo, Stack, StackState};
use indexmap::IndexMap;
fn empty_stack() -> Stack {
Stack {
id: "stack_test".to_string(),
resources: IndexMap::new(),
inputs: vec![],
dynamic_container_repositories: Vec::new(),
dynamic_container_image_resources: Vec::new(),
permissions: crate::PermissionsConfig::default(),
supported_platforms: None,
}
}
fn release_info(id: &str) -> ReleaseInfo {
ReleaseInfo {
release_id: Some(id.to_string()),
version: None,
description: None,
stack: empty_stack(),
}
}
fn state() -> DeploymentState {
DeploymentState {
status: DeploymentStatus::Pending,
platform: Platform::Kubernetes,
current_release: None,
target_release: None,
stack_state: None,
error: None,
environment_info: None,
runtime_metadata: None,
retry_requested: false,
protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
}
}
#[test]
fn deployment_state_has_desired_when_release_or_stack_state_exists() {
let observe_only = state();
assert!(!observe_only.has_desired());
let mut current = state();
current.current_release = Some(release_info("rel_current"));
assert!(current.has_desired());
let mut target = state();
target.target_release = Some(release_info("rel_target"));
assert!(target.has_desired());
let mut imported = state();
imported.stack_state = Some(StackState::new(Platform::Kubernetes));
assert!(imported.has_desired());
}
#[test]
fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
"lastSyncedEnvVarsHash": "old-hash"
}))
.expect("old runtime metadata remains readable");
assert_eq!(
metadata.last_synced_env_vars_hash.as_deref(),
Some("old-hash")
);
assert!(metadata.last_synced_secret_names.is_empty());
assert!(metadata.pending_prepared_stack.is_none());
assert!(metadata.setup_update_authorization.is_none());
assert!(metadata.setup_scaffolding.is_empty());
}
#[test]
fn setup_scaffolding_round_trips_in_its_persisted_form() {
let persisted = serde_json::json!({
"initialSetupAuthority": "directSetup",
"setupScaffolding": {
"agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
}
});
let metadata: RuntimeMetadata =
serde_json::from_value(persisted.clone()).expect("persisted record reads");
assert_eq!(
metadata.setup_scaffolding["agents"],
SetupScaffolding::AwsSandbox {
build_role_name: "acme-agents-build".to_string(),
egress: None,
image_arn: None,
}
);
assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
assert!(
serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
"state with no scaffolding serializes as it did before the field existed"
);
}
#[test]
fn filling_a_record_takes_only_what_it_lacks() {
let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
operator_role_name: "acme-agents-egress".to_string(),
security_group_id: group.map(str::to_string),
connector_arn: connector.map(str::to_string),
connector_request: None,
};
let record = |egress| SetupScaffolding::AwsSandbox {
build_role_name: "acme-agents-build".to_string(),
egress,
image_arn: None,
};
let mut partial = record(Some(egress(Some("sg-recorded"), None)));
partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
assert_eq!(
partial,
record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
"a recorded id is the one teardown deletes"
);
let mut role_only = record(None);
role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
build_role_name: "acme-agents-build".to_string(),
egress: None,
image_arn: arn.map(str::to_string),
};
let mut without_image = image(None);
without_image.fill_missing(image(Some("arn:found")));
assert_eq!(without_image, image(Some("arn:found")));
let mut with_image = image(Some("arn:recorded"));
with_image.fill_missing(image(Some("arn:found")));
assert_eq!(with_image, image(Some("arn:recorded")));
}
#[test]
fn a_partial_egress_record_round_trips_in_its_persisted_form() {
let persisted = serde_json::json!({
"type": "awsSandbox",
"buildRoleName": "acme-agents-build",
"egress": {
"operatorRoleName": "acme-agents-egress",
"securityGroupId": "sg-0123"
}
});
let record: SetupScaffolding =
serde_json::from_value(persisted.clone()).expect("persisted record reads");
assert_eq!(
record,
SetupScaffolding::AwsSandbox {
build_role_name: "acme-agents-build".to_string(),
egress: Some(AwsSandboxEgressScaffolding {
operator_role_name: "acme-agents-egress".to_string(),
security_group_id: Some("sg-0123".to_string()),
connector_arn: None,
connector_request: None,
}),
image_arn: None,
}
);
assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
}
}