1use crate::{ObservedInventoryBatch, Platform, ResourceHeartbeat, StackState};
4use alien_error::AlienError;
5use bon::Builder;
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase")]
16pub enum InitialSetupAuthority {
17 #[default]
20 ImportedHandoff,
21 DirectSetup,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct SetupUpdateAuthorization {
30 pub nonce: String,
32 pub baseline_frozen_digest: String,
35 pub target_frozen_digest: String,
38 pub release_id: String,
40 pub setup_target: String,
42 pub setup_fingerprint: String,
44 pub setup_fingerprint_version: u32,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase")]
52pub struct SetupReportRefusal {
53 pub reason: String,
55 pub reported_release_id: String,
57 pub at: chrono::DateTime<chrono::Utc>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(rename_all = "camelCase")]
67pub struct CloudSetupRequired {
68 pub release_id: String,
70 pub reason: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78#[serde(rename_all = "camelCase")]
79pub struct InstalledHelmRelease {
80 pub name: String,
82 pub namespace: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase")]
90pub struct CredentialRetry {
91 pub started_at: chrono::DateTime<chrono::Utc>,
93 pub status: DeploymentStatus,
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101#[serde(rename_all = "camelCase")]
102pub enum DeletionPolicy {
103 #[default]
104 Retain,
105 Erase,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111#[serde(rename_all = "camelCase")]
112pub struct AirgappedReportMetadata {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub written_at: Option<chrono::DateTime<chrono::Utc>>,
116 pub received_at: chrono::DateTime<chrono::Utc>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125#[serde(rename_all = "camelCase")]
126pub struct RuntimeMetadata {
127 #[serde(default)]
129 pub deletion_policy: DeletionPolicy,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub airgapped_report: Option<AirgappedReportMetadata>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub credential_retry: Option<CredentialRetry>,
138
139 #[serde(default)]
142 pub initial_setup_authority: InitialSetupAuthority,
143
144 #[serde(default, skip_serializing_if = "super::is_false")]
147 pub automatic_setup: bool,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
152 pub last_synced_env_vars_hash: Option<String>,
153
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub last_synced_secret_names: Vec<String>,
159
160 #[serde(default, skip_serializing_if = "is_false")]
164 pub vault_credentials_revoked: bool,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub retained_storage_protection_hash: Option<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub retained_storage_protection_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub retained_data_protection_started_at: Option<chrono::DateTime<chrono::Utc>>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
187 pub prepared_stack: Option<crate::Stack>,
188
189 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
197 pub persisted_gate_answers: GateAnswers,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub pending_prepared_stack: Option<crate::Stack>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub pending_prepared_release_id: Option<String>,
210
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub setup_report_refusal: Option<SetupReportRefusal>,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub cloud_setup_required: Option<CloudSetupRequired>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub installed_helm_release: Option<InstalledHelmRelease>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub direct_setup_revision: Option<String>,
235
236 #[serde(default, skip_serializing_if = "is_false")]
241 pub registry_access_granted: bool,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub registry_access: Option<RegistryAccess>,
248
249 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
253
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
264#[serde(rename_all = "camelCase", tag = "type")]
265pub enum SetupScaffolding {
266 #[serde(rename_all = "camelCase")]
268 AwsSandbox {
269 build_role_name: String,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 egress: Option<AwsSandboxEgressScaffolding>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 image_arn: Option<String>,
278 },
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285#[serde(rename_all = "camelCase")]
286pub struct AwsSandboxEgressScaffolding {
287 pub operator_role_name: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub security_group_id: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub connector_arn: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub connector_request: Option<String>,
299}
300
301impl SetupScaffolding {
302 pub fn fill_missing(&mut self, found: SetupScaffolding) {
305 match (self, found) {
306 (
307 SetupScaffolding::AwsSandbox {
308 egress, image_arn, ..
309 },
310 SetupScaffolding::AwsSandbox {
311 egress: found_egress,
312 image_arn: found_image_arn,
313 ..
314 },
315 ) => {
316 if image_arn.is_none() {
317 *image_arn = found_image_arn;
318 }
319 match (egress.as_mut(), found_egress) {
320 (None, found_egress) => *egress = found_egress,
321 (Some(recorded), Some(found)) => {
322 if recorded.security_group_id.is_none() {
323 recorded.security_group_id = found.security_group_id;
324 }
325 if recorded.connector_arn.is_none() {
326 recorded.connector_arn = found.connector_arn;
327 }
328 }
329 (Some(_), None) => {}
330 }
331 }
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
338#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
339#[serde(rename_all = "camelCase")]
340pub struct RegistryAccess {
341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub repositories: Vec<String>,
344
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub service_types: Vec<String>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
359#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
360#[serde(rename_all = "camelCase")]
361pub struct DeploymentState {
362 pub status: DeploymentStatus,
364 pub platform: Platform,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub current_release: Option<ReleaseInfo>,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub target_release: Option<ReleaseInfo>,
372 #[serde(skip_serializing_if = "Option::is_none")]
374 pub stack_state: Option<StackState>,
375 #[serde(skip_serializing_if = "Option::is_none")]
379 pub error: Option<AlienError>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub environment_info: Option<EnvironmentInfo>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub runtime_metadata: Option<RuntimeMetadata>,
386 #[serde(default, skip_serializing_if = "is_false")]
389 pub retry_requested: bool,
390 pub protocol_version: u32,
395}
396
397impl DeploymentState {
398 pub fn has_desired(&self) -> bool {
401 self.current_release.is_some()
402 || self.target_release.is_some()
403 || self.stack_state.is_some()
404 }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
412#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
413#[serde(rename_all = "camelCase")]
414pub struct DeploymentStepResult {
415 pub state: DeploymentState,
417
418 #[serde(skip_serializing_if = "Option::is_none")]
422 pub suggested_delay_ms: Option<u64>,
423
424 #[serde(default, skip_serializing_if = "is_false")]
428 pub update_heartbeat: bool,
429
430 #[serde(
432 default,
433 rename = "resourceHeartbeats",
434 skip_serializing_if = "Vec::is_empty"
435 )]
436 pub heartbeats: Vec<ResourceHeartbeat>,
437
438 #[serde(
440 default,
441 rename = "observedInventoryBatches",
442 skip_serializing_if = "Vec::is_empty"
443 )]
444 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
445}
446
447pub(crate) fn is_false(b: &bool) -> bool {
448 !*b
449}
450
451pub type GateAnswers = IndexMap<String, bool>;
453
454pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
456
457pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
461
462pub const MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
464
465pub const GCP_FEDERATION_DEPLOYMENT_PROTOCOL_VERSION: u32 = 2;
467
468pub fn deployment_protocol_version_for_platform(
469 _platform: crate::Platform,
470 _base_platform: Option<crate::Platform>,
471) -> u32 {
472 CURRENT_DEPLOYMENT_PROTOCOL_VERSION
473}
474
475pub fn accepts_deployment_protocol_report(registered: u32, reported: u32) -> bool {
479 (MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION..=MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION)
480 .contains(®istered)
481 && (reported == registered
482 || (registered < CURRENT_DEPLOYMENT_PROTOCOL_VERSION
483 && reported == CURRENT_DEPLOYMENT_PROTOCOL_VERSION))
484}
485
486pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::{Platform, ReleaseInfo, Stack, StackState};
493 use indexmap::IndexMap;
494
495 fn empty_stack() -> Stack {
496 Stack {
497 id: "stack_test".to_string(),
498 resources: IndexMap::new(),
499 inputs: vec![],
500 dynamic_container_repositories: Vec::new(),
501 dynamic_container_image_resources: Vec::new(),
502 operations: None,
503 permissions: crate::PermissionsConfig::default(),
504 supported_platforms: None,
505 }
506 }
507
508 fn release_info(id: &str) -> ReleaseInfo {
509 ReleaseInfo {
510 release_id: Some(id.to_string()),
511 version: None,
512 description: None,
513 stack: empty_stack(),
514 }
515 }
516
517 fn state() -> DeploymentState {
518 DeploymentState {
519 status: DeploymentStatus::Pending,
520 platform: Platform::Kubernetes,
521 current_release: None,
522 target_release: None,
523 stack_state: None,
524 error: None,
525 environment_info: None,
526 runtime_metadata: None,
527 retry_requested: false,
528 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
529 }
530 }
531
532 #[test]
533 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
534 let observe_only = state();
535 assert!(!observe_only.has_desired());
536
537 let mut current = state();
538 current.current_release = Some(release_info("rel_current"));
539 assert!(current.has_desired());
540
541 let mut target = state();
542 target.target_release = Some(release_info("rel_target"));
543 assert!(target.has_desired());
544
545 let mut imported = state();
546 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
547 assert!(imported.has_desired());
548 }
549
550 #[test]
551 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
552 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
553 "lastSyncedEnvVarsHash": "old-hash"
554 }))
555 .expect("old runtime metadata remains readable");
556
557 assert_eq!(
558 metadata.last_synced_env_vars_hash.as_deref(),
559 Some("old-hash")
560 );
561 assert!(metadata.last_synced_secret_names.is_empty());
562 assert!(!metadata.vault_credentials_revoked);
563 assert!(metadata.retained_storage_protection_hash.is_none());
564 assert!(metadata.retained_storage_protection_confirmed_at.is_none());
565 assert!(metadata.pending_prepared_stack.is_none());
566 assert!(metadata.setup_update_authorization.is_none());
567 assert!(metadata.setup_scaffolding.is_empty());
568 }
569
570 #[test]
572 fn setup_scaffolding_round_trips_in_its_persisted_form() {
573 let persisted = serde_json::json!({
574 "initialSetupAuthority": "directSetup",
575 "setupScaffolding": {
576 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
577 }
578 });
579 let metadata: RuntimeMetadata =
580 serde_json::from_value(persisted.clone()).expect("persisted record reads");
581 assert_eq!(
582 metadata.setup_scaffolding["agents"],
583 SetupScaffolding::AwsSandbox {
584 build_role_name: "acme-agents-build".to_string(),
585 egress: None,
586 image_arn: None,
587 }
588 );
589 assert_eq!(metadata.deletion_policy, DeletionPolicy::Retain);
590 let mut expected = persisted;
591 expected["deletionPolicy"] = serde_json::json!("retain");
592 assert_eq!(serde_json::to_value(&metadata).unwrap(), expected);
593 assert!(
594 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
595 "state with no scaffolding serializes as it did before the field existed"
596 );
597 }
598
599 #[test]
600 fn filling_a_record_takes_only_what_it_lacks() {
601 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
602 operator_role_name: "acme-agents-egress".to_string(),
603 security_group_id: group.map(str::to_string),
604 connector_arn: connector.map(str::to_string),
605 connector_request: None,
606 };
607 let record = |egress| SetupScaffolding::AwsSandbox {
608 build_role_name: "acme-agents-build".to_string(),
609 egress,
610 image_arn: None,
611 };
612
613 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
614 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
615 assert_eq!(
616 partial,
617 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
618 "a recorded id is the one teardown deletes"
619 );
620
621 let mut role_only = record(None);
622 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
623 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
624
625 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
626 build_role_name: "acme-agents-build".to_string(),
627 egress: None,
628 image_arn: arn.map(str::to_string),
629 };
630 let mut without_image = image(None);
631 without_image.fill_missing(image(Some("arn:found")));
632 assert_eq!(without_image, image(Some("arn:found")));
633 let mut with_image = image(Some("arn:recorded"));
634 with_image.fill_missing(image(Some("arn:found")));
635 assert_eq!(with_image, image(Some("arn:recorded")));
636 }
637
638 #[test]
640 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
641 let persisted = serde_json::json!({
642 "type": "awsSandbox",
643 "buildRoleName": "acme-agents-build",
644 "egress": {
645 "operatorRoleName": "acme-agents-egress",
646 "securityGroupId": "sg-0123"
647 }
648 });
649 let record: SetupScaffolding =
650 serde_json::from_value(persisted.clone()).expect("persisted record reads");
651 assert_eq!(
652 record,
653 SetupScaffolding::AwsSandbox {
654 build_role_name: "acme-agents-build".to_string(),
655 egress: Some(AwsSandboxEgressScaffolding {
656 operator_role_name: "acme-agents-egress".to_string(),
657 security_group_id: Some("sg-0123".to_string()),
658 connector_arn: None,
659 connector_request: None,
660 }),
661 image_arn: None,
662 }
663 );
664 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
665 }
666}