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, Default)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "camelCase")]
76pub struct RuntimeMetadata {
77 #[serde(default)]
79 pub deletion_policy: DeletionPolicy,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub credential_retry: Option<CredentialRetry>,
84
85 #[serde(default)]
88 pub initial_setup_authority: InitialSetupAuthority,
89
90 #[serde(default, skip_serializing_if = "super::is_false")]
93 pub automatic_setup: bool,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
98 pub last_synced_env_vars_hash: Option<String>,
99
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub last_synced_secret_names: Vec<String>,
105
106 #[serde(default, skip_serializing_if = "is_false")]
110 pub vault_credentials_revoked: bool,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub retained_storage_protection_hash: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub retained_storage_protection_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub retained_data_protection_started_at: Option<chrono::DateTime<chrono::Utc>>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
133 pub prepared_stack: Option<crate::Stack>,
134
135 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
143 pub persisted_gate_answers: GateAnswers,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub pending_prepared_stack: Option<crate::Stack>,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub pending_prepared_release_id: Option<String>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub direct_setup_revision: Option<String>,
167
168 #[serde(default, skip_serializing_if = "is_false")]
173 pub registry_access_granted: bool,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub registry_access: Option<RegistryAccess>,
180
181 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
185
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
196#[serde(rename_all = "camelCase", tag = "type")]
197pub enum SetupScaffolding {
198 #[serde(rename_all = "camelCase")]
200 AwsSandbox {
201 build_role_name: String,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 egress: Option<AwsSandboxEgressScaffolding>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 image_arn: Option<String>,
210 },
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
217#[serde(rename_all = "camelCase")]
218pub struct AwsSandboxEgressScaffolding {
219 pub operator_role_name: String,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub security_group_id: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub connector_arn: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub connector_request: Option<String>,
231}
232
233impl SetupScaffolding {
234 pub fn fill_missing(&mut self, found: SetupScaffolding) {
237 match (self, found) {
238 (
239 SetupScaffolding::AwsSandbox {
240 egress, image_arn, ..
241 },
242 SetupScaffolding::AwsSandbox {
243 egress: found_egress,
244 image_arn: found_image_arn,
245 ..
246 },
247 ) => {
248 if image_arn.is_none() {
249 *image_arn = found_image_arn;
250 }
251 match (egress.as_mut(), found_egress) {
252 (None, found_egress) => *egress = found_egress,
253 (Some(recorded), Some(found)) => {
254 if recorded.security_group_id.is_none() {
255 recorded.security_group_id = found.security_group_id;
256 }
257 if recorded.connector_arn.is_none() {
258 recorded.connector_arn = found.connector_arn;
259 }
260 }
261 (Some(_), None) => {}
262 }
263 }
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
270#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
271#[serde(rename_all = "camelCase")]
272pub struct RegistryAccess {
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub repositories: Vec<String>,
276
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub service_types: Vec<String>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292#[serde(rename_all = "camelCase")]
293pub struct DeploymentState {
294 pub status: DeploymentStatus,
296 pub platform: Platform,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub current_release: Option<ReleaseInfo>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub target_release: Option<ReleaseInfo>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub stack_state: Option<StackState>,
307 #[serde(skip_serializing_if = "Option::is_none")]
311 pub error: Option<AlienError>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub environment_info: Option<EnvironmentInfo>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub runtime_metadata: Option<RuntimeMetadata>,
318 #[serde(default, skip_serializing_if = "is_false")]
321 pub retry_requested: bool,
322 pub protocol_version: u32,
327}
328
329impl DeploymentState {
330 pub fn has_desired(&self) -> bool {
333 self.current_release.is_some()
334 || self.target_release.is_some()
335 || self.stack_state.is_some()
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
344#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
345#[serde(rename_all = "camelCase")]
346pub struct DeploymentStepResult {
347 pub state: DeploymentState,
349
350 #[serde(skip_serializing_if = "Option::is_none")]
354 pub suggested_delay_ms: Option<u64>,
355
356 #[serde(default, skip_serializing_if = "is_false")]
360 pub update_heartbeat: bool,
361
362 #[serde(
364 default,
365 rename = "resourceHeartbeats",
366 skip_serializing_if = "Vec::is_empty"
367 )]
368 pub heartbeats: Vec<ResourceHeartbeat>,
369
370 #[serde(
372 default,
373 rename = "observedInventoryBatches",
374 skip_serializing_if = "Vec::is_empty"
375 )]
376 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
377}
378
379pub(crate) fn is_false(b: &bool) -> bool {
380 !*b
381}
382
383pub type GateAnswers = IndexMap<String, bool>;
385
386pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
388
389pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
393
394pub const MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
396
397pub const GCP_FEDERATION_DEPLOYMENT_PROTOCOL_VERSION: u32 = 2;
399
400pub fn deployment_protocol_version_for_platform(
401 _platform: crate::Platform,
402 _base_platform: Option<crate::Platform>,
403) -> u32 {
404 CURRENT_DEPLOYMENT_PROTOCOL_VERSION
405}
406
407pub fn accepts_deployment_protocol_report(registered: u32, reported: u32) -> bool {
411 (MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION..=MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION)
412 .contains(®istered)
413 && (reported == registered
414 || (registered < CURRENT_DEPLOYMENT_PROTOCOL_VERSION
415 && reported == CURRENT_DEPLOYMENT_PROTOCOL_VERSION))
416}
417
418pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::{Platform, ReleaseInfo, Stack, StackState};
425 use indexmap::IndexMap;
426
427 fn empty_stack() -> Stack {
428 Stack {
429 id: "stack_test".to_string(),
430 resources: IndexMap::new(),
431 inputs: vec![],
432 dynamic_container_repositories: Vec::new(),
433 dynamic_container_image_resources: Vec::new(),
434 operations: None,
435 permissions: crate::PermissionsConfig::default(),
436 supported_platforms: None,
437 }
438 }
439
440 fn release_info(id: &str) -> ReleaseInfo {
441 ReleaseInfo {
442 release_id: Some(id.to_string()),
443 version: None,
444 description: None,
445 stack: empty_stack(),
446 }
447 }
448
449 fn state() -> DeploymentState {
450 DeploymentState {
451 status: DeploymentStatus::Pending,
452 platform: Platform::Kubernetes,
453 current_release: None,
454 target_release: None,
455 stack_state: None,
456 error: None,
457 environment_info: None,
458 runtime_metadata: None,
459 retry_requested: false,
460 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
461 }
462 }
463
464 #[test]
465 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
466 let observe_only = state();
467 assert!(!observe_only.has_desired());
468
469 let mut current = state();
470 current.current_release = Some(release_info("rel_current"));
471 assert!(current.has_desired());
472
473 let mut target = state();
474 target.target_release = Some(release_info("rel_target"));
475 assert!(target.has_desired());
476
477 let mut imported = state();
478 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
479 assert!(imported.has_desired());
480 }
481
482 #[test]
483 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
484 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
485 "lastSyncedEnvVarsHash": "old-hash"
486 }))
487 .expect("old runtime metadata remains readable");
488
489 assert_eq!(
490 metadata.last_synced_env_vars_hash.as_deref(),
491 Some("old-hash")
492 );
493 assert!(metadata.last_synced_secret_names.is_empty());
494 assert!(!metadata.vault_credentials_revoked);
495 assert!(metadata.retained_storage_protection_hash.is_none());
496 assert!(metadata.retained_storage_protection_confirmed_at.is_none());
497 assert!(metadata.pending_prepared_stack.is_none());
498 assert!(metadata.setup_update_authorization.is_none());
499 assert!(metadata.setup_scaffolding.is_empty());
500 }
501
502 #[test]
504 fn setup_scaffolding_round_trips_in_its_persisted_form() {
505 let persisted = serde_json::json!({
506 "initialSetupAuthority": "directSetup",
507 "setupScaffolding": {
508 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
509 }
510 });
511 let metadata: RuntimeMetadata =
512 serde_json::from_value(persisted.clone()).expect("persisted record reads");
513 assert_eq!(
514 metadata.setup_scaffolding["agents"],
515 SetupScaffolding::AwsSandbox {
516 build_role_name: "acme-agents-build".to_string(),
517 egress: None,
518 image_arn: None,
519 }
520 );
521 assert_eq!(metadata.deletion_policy, DeletionPolicy::Retain);
522 let mut expected = persisted;
523 expected["deletionPolicy"] = serde_json::json!("retain");
524 assert_eq!(serde_json::to_value(&metadata).unwrap(), expected);
525 assert!(
526 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
527 "state with no scaffolding serializes as it did before the field existed"
528 );
529 }
530
531 #[test]
532 fn filling_a_record_takes_only_what_it_lacks() {
533 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
534 operator_role_name: "acme-agents-egress".to_string(),
535 security_group_id: group.map(str::to_string),
536 connector_arn: connector.map(str::to_string),
537 connector_request: None,
538 };
539 let record = |egress| SetupScaffolding::AwsSandbox {
540 build_role_name: "acme-agents-build".to_string(),
541 egress,
542 image_arn: None,
543 };
544
545 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
546 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
547 assert_eq!(
548 partial,
549 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
550 "a recorded id is the one teardown deletes"
551 );
552
553 let mut role_only = record(None);
554 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
555 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
556
557 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
558 build_role_name: "acme-agents-build".to_string(),
559 egress: None,
560 image_arn: arn.map(str::to_string),
561 };
562 let mut without_image = image(None);
563 without_image.fill_missing(image(Some("arn:found")));
564 assert_eq!(without_image, image(Some("arn:found")));
565 let mut with_image = image(Some("arn:recorded"));
566 with_image.fill_missing(image(Some("arn:found")));
567 assert_eq!(with_image, image(Some("arn:recorded")));
568 }
569
570 #[test]
572 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
573 let persisted = serde_json::json!({
574 "type": "awsSandbox",
575 "buildRoleName": "acme-agents-build",
576 "egress": {
577 "operatorRoleName": "acme-agents-egress",
578 "securityGroupId": "sg-0123"
579 }
580 });
581 let record: SetupScaffolding =
582 serde_json::from_value(persisted.clone()).expect("persisted record reads");
583 assert_eq!(
584 record,
585 SetupScaffolding::AwsSandbox {
586 build_role_name: "acme-agents-build".to_string(),
587 egress: Some(AwsSandboxEgressScaffolding {
588 operator_role_name: "acme-agents-egress".to_string(),
589 security_group_id: Some("sg-0123".to_string()),
590 connector_arn: None,
591 connector_request: None,
592 }),
593 image_arn: None,
594 }
595 );
596 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
597 }
598}