Skip to main content

alien_core/deployment/
state.rs

1//! Deployment state, step results, and runtime metadata.
2
3use 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/// Actor that owns structural work during the initial setup phase.
13#[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    /// Setup state was registered by an external setup engine. Alien may only
18    /// continue controller states that the importer explicitly initialized.
19    #[default]
20    ImportedHandoff,
21    /// Alien is the setup engine and is running with administrator credentials.
22    DirectSetup,
23}
24
25/// One-shot authority for a setup re-import to replace setup-owned resources.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct SetupUpdateAuthorization {
30    /// Unique revision used by persistence layers for compare-and-swap updates.
31    pub nonce: String,
32    /// Setup resources and permissions digest (`Stack::setup_update_digest`) of the last
33    /// successful deployment. The field name is retained for serialized compatibility.
34    pub baseline_frozen_digest: String,
35    /// Setup resources and permissions digest (`Stack::setup_update_digest`) of the stack
36    /// the setup re-import prepared.
37    pub target_frozen_digest: String,
38    /// Release whose stack was prepared by setup.
39    pub release_id: String,
40    /// Stable setup target recorded on the imported deployment.
41    pub setup_target: String,
42    /// Exact setup artifact revision that authored this authority.
43    pub setup_fingerprint: String,
44    /// Setup fingerprint contract version.
45    pub setup_fingerprint_version: u32,
46}
47
48/// A credential outage's durable retry window, scoped to its deployment phase.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase")]
52pub struct CredentialRetry {
53    /// First credential failure in this phase; subsequent attempts keep this timestamp.
54    pub started_at: chrono::DateTime<chrono::Utc>,
55    /// Changing phase starts a new window instead of carrying a previous outage forward.
56    pub status: DeploymentStatus,
57}
58
59/// Explicit disposition of durable data when deleting a deployment.
60/// Missing values from older checkpoints always preserve data.
61#[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/// When an air-gapped site's latest status was written and received.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct AirgappedReportMetadata {
75    /// Site clock time; absent for reports from older clients.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub written_at: Option<chrono::DateTime<chrono::Utc>>,
78    /// Manager clock time when the report was accepted.
79    pub received_at: chrono::DateTime<chrono::Utc>,
80}
81
82/// Runtime metadata for deployment
83///
84/// Stores deployment state that needs to persist across step calls.
85#[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    /// User-authorized data disposition, persisted before deletion starts.
90    #[serde(default)]
91    pub deletion_policy: DeletionPolicy,
92
93    /// Most recent air-gapped report accepted by the manager.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub airgapped_report: Option<AirgappedReportMetadata>,
96
97    /// Bounded credential retry state shared by all deployment drivers.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub credential_retry: Option<CredentialRetry>,
100
101    /// Actor authorized to perform structural Frozen-resource work during
102    /// InitialSetup. Missing legacy values fail closed as ImportedHandoff.
103    #[serde(default)]
104    pub initial_setup_authority: InitialSetupAuthority,
105
106    /// Setup-owned grant to keep using the direct setup executor for updates.
107    /// Runtime settings and release contents must never create or change this grant.
108    #[serde(default, skip_serializing_if = "super::is_false")]
109    pub automatic_setup: bool,
110
111    /// Hash of the environment variables snapshot that was last synced to the vault
112    /// Used to avoid redundant sync operations during incremental deployment
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub last_synced_env_vars_hash: Option<String>,
115
116    /// Exact vault keys owned by the deployment secret synchronizer. This
117    /// inventory lets a later snapshot delete removed keys without listing or
118    /// touching unrelated values in the same vault.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub last_synced_secret_names: Vec<String>,
121
122    /// Credential revocation completed before resource teardown. A delete retry
123    /// can use this receipt after the vault or its binding is gone. Secret sync
124    /// clears it before a deployment can write runtime credentials again.
125    #[serde(default, skip_serializing_if = "is_false")]
126    pub vault_credentials_revoked: bool,
127
128    /// Digest of the installed data inventory whose runtime public-access and
129    /// expiry configuration was protected before cleanup. The serialized field
130    /// name is retained for compatibility. This is not a
131    /// receipt for effective expiry propagation or privileged setup teardown.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub retained_storage_protection_hash: Option<String>,
134
135    /// UTC API-confirmation time for the matching data protection digest.
136    /// Provider propagation must be qualified separately before final retention.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub retained_storage_protection_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
139
140    /// Start of the bounded wait for retained data's expiry controls to disable.
141    /// Cleared on confirmation, explicit retry, or returning to normal execution.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub retained_data_protection_started_at: Option<chrono::DateTime<chrono::Utc>>,
144
145    /// The prepared (mutated) stack from the last successful deployment phase
146    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
147    /// Used for compatibility checks during updates to compare mutated stacks
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub prepared_stack: Option<crate::Stack>,
150
151    /// Canonical resolved answers for inputs that gate Frozen resources,
152    /// keyed by input id, recorded when the deployment is created or its
153    /// setup import registers.
154    ///
155    /// Manual installations keep these answers fixed. An installation with an
156    /// ongoing-management grant refreshes them when preparing a release. Live
157    /// resources sharing a gate use the same recorded answer as Frozen resources.
158    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
159    pub persisted_gate_answers: GateAnswers,
160
161    /// Prepared target for an update that has not reached Running yet. Keeping
162    /// it separate preserves the last successful baseline across retries.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub pending_prepared_stack: Option<crate::Stack>,
165
166    /// Release the in-flight stack was prepared from: `pending_prepared_stack`
167    /// during updates, or `prepared_stack` during local first startup. The target can move
168    /// to a newer release while that stack is still being applied; this tells
169    /// the update which release actually converged.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub pending_prepared_release_id: Option<String>,
172
173    /// One-shot setup update authority. It contains only non-secret identity
174    /// and canonical resource digests, never the imported payload or tokens.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub setup_update_authorization: Option<SetupUpdateAuthorization>,
177
178    /// Last generated CLI package revision whose direct setup was applied.
179    /// This lets a newer generated CLI refresh setup-owned infrastructure once
180    /// before handing runtime changes back to the hosted manager.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub direct_setup_revision: Option<String>,
183
184    /// Whether cross-account registry access has been successfully granted.
185    /// Set to true after the manager successfully sets the ECR/GAR repo policy
186    /// for this deployment's target account. Prevents redundant API calls on
187    /// every reconcile tick.
188    #[serde(default, skip_serializing_if = "is_false")]
189    pub registry_access_granted: bool,
190
191    /// What that grant opened, compared against what the deployment needs now so a grant made
192    /// before a resource existed is finished rather than skipped, and the revoke names what was
193    /// granted. Absent on a grant recorded before this field existed.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub registry_access: Option<RegistryAccess>,
196
197    /// What a direct setup created for resources it does not own, keyed by resource id. Setup
198    /// teardown removes exactly what is recorded here.
199    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
200    pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
201
202    /// Whether each vault-native deployer secret is in the customer's secret
203    /// store, with where it goes. Checked from metadata only; no value is ever
204    /// read or recorded here.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub deployer_secrets: Vec<crate::DeployerSecretReport>,
207}
208
209/// Cloud objects a direct setup created so a runtime-owned resource can run.
210#[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    /// An AWS sandbox's image-build role, and for `egress: deny` its egress objects.
215    #[serde(rename_all = "camelCase")]
216    AwsSandbox {
217        /// IAM role the image build runs as.
218        build_role_name: String,
219        /// What `egress: deny` needs; absent for an open sandbox.
220        #[serde(default, skip_serializing_if = "Option::is_none")]
221        egress: Option<AwsSandboxEgressScaffolding>,
222        /// A Frozen sandbox's MicroVM image, built during setup. A Live one's image belongs to its
223        /// runtime controller.
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        image_arn: Option<String>,
226    },
227}
228
229/// The objects that keep an AWS deny sandbox's sessions inside the VPC. Each id is recorded as
230/// soon as the object exists and cleared once it is deleted.
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233#[serde(rename_all = "camelCase")]
234pub struct AwsSandboxEgressScaffolding {
235    /// IAM role Lambda assumes to place the connector's network interfaces.
236    pub operator_role_name: String,
237    /// Security group permitting egress to loopback only.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub security_group_id: Option<String>,
240    /// `AWS::Lambda::NetworkConnector` the sessions start with.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub connector_arn: Option<String>,
243    /// Cloud Control request creating or deleting the connector that AWS has not finished. Kept
244    /// so the request's outcome, and AWS's reason when it fails, is read on a later call.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub connector_request: Option<String>,
247}
248
249impl SetupScaffolding {
250    /// Takes each object `found` names that this record does not, and keeps every one it does:
251    /// a recorded id is what setup last saw, and teardown must delete that one.
252    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/// The cross-account read a manager opened on Alien's registry for one deployment.
285#[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    /// Repository identifiers the grant names, sorted.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub repositories: Vec<String>,
292
293    /// Compute services the grant admits, sorted. Each pulls as its own principal, so a service
294    /// added later needs the policy rewritten.
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub service_types: Vec<String>,
297}
298
299/// Deployment state
300///
301/// Represents the current state of deployed infrastructure, including release tracking.
302/// This is platform-agnostic - no backend IDs or database relationships.
303///
304/// The deployment engine manages releases internally: when a deployment succeeds,
305/// it promotes `target_release` to `current_release` and clears `target_release`.
306#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
307#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
308#[serde(rename_all = "camelCase")]
309pub struct DeploymentState {
310    /// Current lifecycle phase
311    pub status: DeploymentStatus,
312    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
313    pub platform: Platform,
314    /// Currently deployed release (None for first deployment)
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub current_release: Option<ReleaseInfo>,
317    /// Target release to deploy (None when synced with current)
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub target_release: Option<ReleaseInfo>,
320    /// Infrastructure resource tracking (which resources exist, their status, outputs)
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub stack_state: Option<StackState>,
323    /// Deployment-level error for failures not owned by a specific resource.
324    ///
325    /// Resource controller failures belong in `stack_state.resources[*].error`.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub error: Option<AlienError>,
328    /// Cloud account details (account ID, project number, region)
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub environment_info: Option<EnvironmentInfo>,
331    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub runtime_metadata: Option<RuntimeMetadata>,
334    /// Whether a retry has been requested for a failed deployment
335    /// When true and status is a failed state, the deployment system will retry failed resources
336    #[serde(default, skip_serializing_if = "is_false")]
337    pub retry_requested: bool,
338    /// Protocol version for cross-actor compatibility.
339    /// All actors (manager, push client, agent) check this before stepping.
340    /// Mismatched versions produce a clear error instead of silent corruption.
341    /// See docs/02-manager/10-deployment-protocol.md.
342    pub protocol_version: u32,
343}
344
345impl DeploymentState {
346    /// Returns whether this state carries desired infrastructure for the
347    /// deployment runner to converge.
348    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/// Result of a deployment step
356///
357/// Contains the complete next deployment state along with hints for the platform.
358/// This replaces the old delta-based `DeploymentStateUpdate` approach.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
361#[serde(rename_all = "camelCase")]
362pub struct DeploymentStepResult {
363    /// The complete next deployment state
364    pub state: DeploymentState,
365
366    /// Suggested delay before next step (optimization hint)
367    /// - `None`: No suggested delay, can poll immediately
368    /// - `Some(ms)`: Wait this many milliseconds before next step
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub suggested_delay_ms: Option<u64>,
371
372    /// Whether to update heartbeat timestamp (monitoring signal)
373    /// - `false`: Don't update heartbeat (default for most steps)
374    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
375    #[serde(default, skip_serializing_if = "is_false")]
376    pub update_heartbeat: bool,
377
378    /// Managed Alien resource status samples emitted by controllers during this step.
379    #[serde(
380        default,
381        rename = "resourceHeartbeats",
382        skip_serializing_if = "Vec::is_empty"
383    )]
384    pub heartbeats: Vec<ResourceHeartbeat>,
385
386    /// Observed raw-resource inventory batches read during this step.
387    #[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
399/// Answers for inputs gating Frozen resources, keyed by input id.
400pub type GateAnswers = IndexMap<String, bool>;
401
402/// Oldest deployment protocol version this binary can read.
403pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
404
405/// Deployment protocol written by actors with resumable protection, CORS and
406/// database update states and retained deletion. Older readers must be fenced
407/// before these states exist.
408pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
409
410/// Latest state protocol this binary can execute.
411pub const MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
412
413/// GCP federation changes setup ownership and target credential semantics.
414pub 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
423/// Accept unchanged reports or the explicit upgrade to this writer protocol.
424/// Never permit downgrade or unknown protocols. Provider setup prerequisites
425/// remain separate from this checkpoint compatibility check.
426pub fn accepts_deployment_protocol_report(registered: u32, reported: u32) -> bool {
427    (MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION..=MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION)
428        .contains(&registered)
429        && (reported == registered
430            || (registered < CURRENT_DEPLOYMENT_PROTOCOL_VERSION
431                && reported == CURRENT_DEPLOYMENT_PROTOCOL_VERSION))
432}
433
434/// Backwards-compatible alias for older call sites.
435pub 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    /// Teardown reads this record back from persisted state, so its wire form is a contract.
519    #[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    /// A deny sandbox's record is written piece by piece, so a partial one must read back as is.
587    #[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}