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 CredentialRetry {
53 pub started_at: chrono::DateTime<chrono::Utc>,
55 pub status: DeploymentStatus,
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(rename_all = "camelCase")]
64pub enum DeletionPolicy {
65 #[default]
66 Retain,
67 Erase,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct AirgappedReportMetadata {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub written_at: Option<chrono::DateTime<chrono::Utc>>,
78 pub received_at: chrono::DateTime<chrono::Utc>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "camelCase")]
88pub struct RuntimeMetadata {
89 #[serde(default)]
91 pub deletion_policy: DeletionPolicy,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub airgapped_report: Option<AirgappedReportMetadata>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub credential_retry: Option<CredentialRetry>,
100
101 #[serde(default)]
104 pub initial_setup_authority: InitialSetupAuthority,
105
106 #[serde(default, skip_serializing_if = "super::is_false")]
109 pub automatic_setup: bool,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
114 pub last_synced_env_vars_hash: Option<String>,
115
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub last_synced_secret_names: Vec<String>,
121
122 #[serde(default, skip_serializing_if = "is_false")]
126 pub vault_credentials_revoked: bool,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub retained_storage_protection_hash: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub retained_storage_protection_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
139
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub retained_data_protection_started_at: Option<chrono::DateTime<chrono::Utc>>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
149 pub prepared_stack: Option<crate::Stack>,
150
151 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
159 pub persisted_gate_answers: GateAnswers,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub pending_prepared_stack: Option<crate::Stack>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub pending_prepared_release_id: Option<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub direct_setup_revision: Option<String>,
183
184 #[serde(default, skip_serializing_if = "is_false")]
189 pub registry_access_granted: bool,
190
191 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub registry_access: Option<RegistryAccess>,
196
197 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
200 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
201
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
212#[serde(rename_all = "camelCase", tag = "type")]
213pub enum SetupScaffolding {
214 #[serde(rename_all = "camelCase")]
216 AwsSandbox {
217 build_role_name: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 egress: Option<AwsSandboxEgressScaffolding>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
225 image_arn: Option<String>,
226 },
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233#[serde(rename_all = "camelCase")]
234pub struct AwsSandboxEgressScaffolding {
235 pub operator_role_name: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub security_group_id: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub connector_arn: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub connector_request: Option<String>,
247}
248
249impl SetupScaffolding {
250 pub fn fill_missing(&mut self, found: SetupScaffolding) {
253 match (self, found) {
254 (
255 SetupScaffolding::AwsSandbox {
256 egress, image_arn, ..
257 },
258 SetupScaffolding::AwsSandbox {
259 egress: found_egress,
260 image_arn: found_image_arn,
261 ..
262 },
263 ) => {
264 if image_arn.is_none() {
265 *image_arn = found_image_arn;
266 }
267 match (egress.as_mut(), found_egress) {
268 (None, found_egress) => *egress = found_egress,
269 (Some(recorded), Some(found)) => {
270 if recorded.security_group_id.is_none() {
271 recorded.security_group_id = found.security_group_id;
272 }
273 if recorded.connector_arn.is_none() {
274 recorded.connector_arn = found.connector_arn;
275 }
276 }
277 (Some(_), None) => {}
278 }
279 }
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
286#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
287#[serde(rename_all = "camelCase")]
288pub struct RegistryAccess {
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub repositories: Vec<String>,
292
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub service_types: Vec<String>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
307#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
308#[serde(rename_all = "camelCase")]
309pub struct DeploymentState {
310 pub status: DeploymentStatus,
312 pub platform: Platform,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub current_release: Option<ReleaseInfo>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub target_release: Option<ReleaseInfo>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub stack_state: Option<StackState>,
323 #[serde(skip_serializing_if = "Option::is_none")]
327 pub error: Option<AlienError>,
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub environment_info: Option<EnvironmentInfo>,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub runtime_metadata: Option<RuntimeMetadata>,
334 #[serde(default, skip_serializing_if = "is_false")]
337 pub retry_requested: bool,
338 pub protocol_version: u32,
343}
344
345impl DeploymentState {
346 pub fn has_desired(&self) -> bool {
349 self.current_release.is_some()
350 || self.target_release.is_some()
351 || self.stack_state.is_some()
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
361#[serde(rename_all = "camelCase")]
362pub struct DeploymentStepResult {
363 pub state: DeploymentState,
365
366 #[serde(skip_serializing_if = "Option::is_none")]
370 pub suggested_delay_ms: Option<u64>,
371
372 #[serde(default, skip_serializing_if = "is_false")]
376 pub update_heartbeat: bool,
377
378 #[serde(
380 default,
381 rename = "resourceHeartbeats",
382 skip_serializing_if = "Vec::is_empty"
383 )]
384 pub heartbeats: Vec<ResourceHeartbeat>,
385
386 #[serde(
388 default,
389 rename = "observedInventoryBatches",
390 skip_serializing_if = "Vec::is_empty"
391 )]
392 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
393}
394
395pub(crate) fn is_false(b: &bool) -> bool {
396 !*b
397}
398
399pub type GateAnswers = IndexMap<String, bool>;
401
402pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
404
405pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
409
410pub const MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
412
413pub const GCP_FEDERATION_DEPLOYMENT_PROTOCOL_VERSION: u32 = 2;
415
416pub fn deployment_protocol_version_for_platform(
417 _platform: crate::Platform,
418 _base_platform: Option<crate::Platform>,
419) -> u32 {
420 CURRENT_DEPLOYMENT_PROTOCOL_VERSION
421}
422
423pub fn accepts_deployment_protocol_report(registered: u32, reported: u32) -> bool {
427 (MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION..=MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION)
428 .contains(®istered)
429 && (reported == registered
430 || (registered < CURRENT_DEPLOYMENT_PROTOCOL_VERSION
431 && reported == CURRENT_DEPLOYMENT_PROTOCOL_VERSION))
432}
433
434pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::{Platform, ReleaseInfo, Stack, StackState};
441 use indexmap::IndexMap;
442
443 fn empty_stack() -> Stack {
444 Stack {
445 id: "stack_test".to_string(),
446 resources: IndexMap::new(),
447 inputs: vec![],
448 dynamic_container_repositories: Vec::new(),
449 dynamic_container_image_resources: Vec::new(),
450 operations: None,
451 permissions: crate::PermissionsConfig::default(),
452 supported_platforms: None,
453 }
454 }
455
456 fn release_info(id: &str) -> ReleaseInfo {
457 ReleaseInfo {
458 release_id: Some(id.to_string()),
459 version: None,
460 description: None,
461 stack: empty_stack(),
462 }
463 }
464
465 fn state() -> DeploymentState {
466 DeploymentState {
467 status: DeploymentStatus::Pending,
468 platform: Platform::Kubernetes,
469 current_release: None,
470 target_release: None,
471 stack_state: None,
472 error: None,
473 environment_info: None,
474 runtime_metadata: None,
475 retry_requested: false,
476 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
477 }
478 }
479
480 #[test]
481 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
482 let observe_only = state();
483 assert!(!observe_only.has_desired());
484
485 let mut current = state();
486 current.current_release = Some(release_info("rel_current"));
487 assert!(current.has_desired());
488
489 let mut target = state();
490 target.target_release = Some(release_info("rel_target"));
491 assert!(target.has_desired());
492
493 let mut imported = state();
494 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
495 assert!(imported.has_desired());
496 }
497
498 #[test]
499 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
500 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
501 "lastSyncedEnvVarsHash": "old-hash"
502 }))
503 .expect("old runtime metadata remains readable");
504
505 assert_eq!(
506 metadata.last_synced_env_vars_hash.as_deref(),
507 Some("old-hash")
508 );
509 assert!(metadata.last_synced_secret_names.is_empty());
510 assert!(!metadata.vault_credentials_revoked);
511 assert!(metadata.retained_storage_protection_hash.is_none());
512 assert!(metadata.retained_storage_protection_confirmed_at.is_none());
513 assert!(metadata.pending_prepared_stack.is_none());
514 assert!(metadata.setup_update_authorization.is_none());
515 assert!(metadata.setup_scaffolding.is_empty());
516 }
517
518 #[test]
520 fn setup_scaffolding_round_trips_in_its_persisted_form() {
521 let persisted = serde_json::json!({
522 "initialSetupAuthority": "directSetup",
523 "setupScaffolding": {
524 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
525 }
526 });
527 let metadata: RuntimeMetadata =
528 serde_json::from_value(persisted.clone()).expect("persisted record reads");
529 assert_eq!(
530 metadata.setup_scaffolding["agents"],
531 SetupScaffolding::AwsSandbox {
532 build_role_name: "acme-agents-build".to_string(),
533 egress: None,
534 image_arn: None,
535 }
536 );
537 assert_eq!(metadata.deletion_policy, DeletionPolicy::Retain);
538 let mut expected = persisted;
539 expected["deletionPolicy"] = serde_json::json!("retain");
540 assert_eq!(serde_json::to_value(&metadata).unwrap(), expected);
541 assert!(
542 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
543 "state with no scaffolding serializes as it did before the field existed"
544 );
545 }
546
547 #[test]
548 fn filling_a_record_takes_only_what_it_lacks() {
549 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
550 operator_role_name: "acme-agents-egress".to_string(),
551 security_group_id: group.map(str::to_string),
552 connector_arn: connector.map(str::to_string),
553 connector_request: None,
554 };
555 let record = |egress| SetupScaffolding::AwsSandbox {
556 build_role_name: "acme-agents-build".to_string(),
557 egress,
558 image_arn: None,
559 };
560
561 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
562 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
563 assert_eq!(
564 partial,
565 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
566 "a recorded id is the one teardown deletes"
567 );
568
569 let mut role_only = record(None);
570 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
571 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
572
573 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
574 build_role_name: "acme-agents-build".to_string(),
575 egress: None,
576 image_arn: arn.map(str::to_string),
577 };
578 let mut without_image = image(None);
579 without_image.fill_missing(image(Some("arn:found")));
580 assert_eq!(without_image, image(Some("arn:found")));
581 let mut with_image = image(Some("arn:recorded"));
582 with_image.fill_missing(image(Some("arn:found")));
583 assert_eq!(with_image, image(Some("arn:recorded")));
584 }
585
586 #[test]
588 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
589 let persisted = serde_json::json!({
590 "type": "awsSandbox",
591 "buildRoleName": "acme-agents-build",
592 "egress": {
593 "operatorRoleName": "acme-agents-egress",
594 "securityGroupId": "sg-0123"
595 }
596 });
597 let record: SetupScaffolding =
598 serde_json::from_value(persisted.clone()).expect("persisted record reads");
599 assert_eq!(
600 record,
601 SetupScaffolding::AwsSandbox {
602 build_role_name: "acme-agents-build".to_string(),
603 egress: Some(AwsSandboxEgressScaffolding {
604 operator_role_name: "acme-agents-egress".to_string(),
605 security_group_id: Some("sg-0123".to_string()),
606 connector_arn: None,
607 connector_request: None,
608 }),
609 image_arn: None,
610 }
611 );
612 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
613 }
614}