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 setup report the manager declined, for display next to the waiting update.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase")]
52pub struct SetupReportRefusal {
53    /// Plain-language reason, safe to show to the customer.
54    pub reason: String,
55    /// Release the chart reported.
56    pub reported_release_id: String,
57    /// When the manager declined it.
58    pub at: chrono::DateTime<chrono::Utc>,
59}
60
61/// A waiting release whose setup change a Helm chart upgrade cannot carry. The
62/// manager works this out from the release and the installed setup, so every
63/// place that offers an upgrade agrees with the manager's report handling.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(rename_all = "camelCase")]
67pub struct CloudSetupRequired {
68    /// The waiting release this applies to.
69    pub release_id: String,
70    /// Plain-language reason, safe to show to the customer.
71    pub reason: String,
72}
73
74/// The Helm release an Operator reports it was installed as, so the exact
75/// upgrade command can be shown. Reported, never trusted for authority.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78#[serde(rename_all = "camelCase")]
79pub struct InstalledHelmRelease {
80    /// Release name, as passed to `helm install`.
81    pub name: String,
82    /// Kubernetes namespace the release is installed in.
83    pub namespace: String,
84}
85
86/// A credential outage's durable retry window, scoped to its deployment phase.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase")]
90pub struct CredentialRetry {
91    /// First credential failure in this phase; subsequent attempts keep this timestamp.
92    pub started_at: chrono::DateTime<chrono::Utc>,
93    /// Changing phase starts a new window instead of carrying a previous outage forward.
94    pub status: DeploymentStatus,
95}
96
97/// Explicit disposition of durable data when deleting a deployment.
98/// Missing values from older checkpoints always preserve data.
99#[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/// When an air-gapped site's latest status was written and received.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111#[serde(rename_all = "camelCase")]
112pub struct AirgappedReportMetadata {
113    /// Site clock time; absent for reports from older clients.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub written_at: Option<chrono::DateTime<chrono::Utc>>,
116    /// Manager clock time when the report was accepted.
117    pub received_at: chrono::DateTime<chrono::Utc>,
118}
119
120/// Runtime metadata for deployment
121///
122/// Stores deployment state that needs to persist across step calls.
123#[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    /// User-authorized data disposition, persisted before deletion starts.
128    #[serde(default)]
129    pub deletion_policy: DeletionPolicy,
130
131    /// Most recent air-gapped report accepted by the manager.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub airgapped_report: Option<AirgappedReportMetadata>,
134
135    /// Bounded credential retry state shared by all deployment drivers.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub credential_retry: Option<CredentialRetry>,
138
139    /// Actor authorized to perform structural Frozen-resource work during
140    /// InitialSetup. Missing legacy values fail closed as ImportedHandoff.
141    #[serde(default)]
142    pub initial_setup_authority: InitialSetupAuthority,
143
144    /// Setup-owned grant to keep using the direct setup executor for updates.
145    /// Runtime settings and release contents must never create or change this grant.
146    #[serde(default, skip_serializing_if = "super::is_false")]
147    pub automatic_setup: bool,
148
149    /// Hash of the environment variables snapshot that was last synced to the vault
150    /// Used to avoid redundant sync operations during incremental deployment
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub last_synced_env_vars_hash: Option<String>,
153
154    /// Exact vault keys owned by the deployment secret synchronizer. This
155    /// inventory lets a later snapshot delete removed keys without listing or
156    /// touching unrelated values in the same vault.
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub last_synced_secret_names: Vec<String>,
159
160    /// Credential revocation completed before resource teardown. A delete retry
161    /// can use this receipt after the vault or its binding is gone. Secret sync
162    /// clears it before a deployment can write runtime credentials again.
163    #[serde(default, skip_serializing_if = "is_false")]
164    pub vault_credentials_revoked: bool,
165
166    /// Digest of the installed data inventory whose runtime public-access and
167    /// expiry configuration was protected before cleanup. The serialized field
168    /// name is retained for compatibility. This is not a
169    /// receipt for effective expiry propagation or privileged setup teardown.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub retained_storage_protection_hash: Option<String>,
172
173    /// UTC API-confirmation time for the matching data protection digest.
174    /// Provider propagation must be qualified separately before final retention.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub retained_storage_protection_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
177
178    /// Start of the bounded wait for retained data's expiry controls to disable.
179    /// Cleared on confirmation, explicit retry, or returning to normal execution.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub retained_data_protection_started_at: Option<chrono::DateTime<chrono::Utc>>,
182
183    /// The prepared (mutated) stack from the last successful deployment phase
184    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
185    /// Used for compatibility checks during updates to compare mutated stacks
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub prepared_stack: Option<crate::Stack>,
188
189    /// Canonical resolved answers for inputs that gate Frozen resources,
190    /// keyed by input id, recorded when the deployment is created or its
191    /// setup import registers.
192    ///
193    /// Manual installations keep these answers fixed. An installation with an
194    /// ongoing-management grant refreshes them when preparing a release. Live
195    /// resources sharing a gate use the same recorded answer as Frozen resources.
196    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
197    pub persisted_gate_answers: GateAnswers,
198
199    /// Prepared target for an update that has not reached Running yet. Keeping
200    /// it separate preserves the last successful baseline across retries.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub pending_prepared_stack: Option<crate::Stack>,
203
204    /// Release the in-flight stack was prepared from: `pending_prepared_stack`
205    /// during updates, or `prepared_stack` during local first startup. The target can move
206    /// to a newer release while that stack is still being applied; this tells
207    /// the update which release actually converged.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub pending_prepared_release_id: Option<String>,
210
211    /// One-shot setup update authority. It contains only non-secret identity
212    /// and canonical resource digests, never the imported payload or tokens.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub setup_update_authorization: Option<SetupUpdateAuthorization>,
215
216    /// Why the manager last declined a setup report from the installed chart,
217    /// with when. Cleared when a report is accepted or no longer needed.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub setup_report_refusal: Option<SetupReportRefusal>,
220
221    /// Set while the waiting release changes something only the cloud setup
222    /// can apply, so a chart upgrade is not offered for it.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub cloud_setup_required: Option<CloudSetupRequired>,
225
226    /// The Helm release the Operator reported it runs as.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub installed_helm_release: Option<InstalledHelmRelease>,
229
230    /// Last generated CLI package revision whose direct setup was applied.
231    /// This lets a newer generated CLI refresh setup-owned infrastructure once
232    /// before handing runtime changes back to the hosted manager.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub direct_setup_revision: Option<String>,
235
236    /// Whether cross-account registry access has been successfully granted.
237    /// Set to true after the manager successfully sets the ECR/GAR repo policy
238    /// for this deployment's target account. Prevents redundant API calls on
239    /// every reconcile tick.
240    #[serde(default, skip_serializing_if = "is_false")]
241    pub registry_access_granted: bool,
242
243    /// What that grant opened, compared against what the deployment needs now so a grant made
244    /// before a resource existed is finished rather than skipped, and the revoke names what was
245    /// granted. Absent on a grant recorded before this field existed.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub registry_access: Option<RegistryAccess>,
248
249    /// What a direct setup created for resources it does not own, keyed by resource id. Setup
250    /// teardown removes exactly what is recorded here.
251    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252    pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
253
254    /// Whether each vault-native deployer secret is in the customer's secret
255    /// store, with where it goes. Checked from metadata only; no value is ever
256    /// read or recorded here.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub deployer_secrets: Vec<crate::DeployerSecretReport>,
259}
260
261/// Cloud objects a direct setup created so a runtime-owned resource can run.
262#[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    /// An AWS sandbox's image-build role, and for `egress: deny` its egress objects.
267    #[serde(rename_all = "camelCase")]
268    AwsSandbox {
269        /// IAM role the image build runs as.
270        build_role_name: String,
271        /// What `egress: deny` needs; absent for an open sandbox.
272        #[serde(default, skip_serializing_if = "Option::is_none")]
273        egress: Option<AwsSandboxEgressScaffolding>,
274        /// A Frozen sandbox's MicroVM image, built during setup. A Live one's image belongs to its
275        /// runtime controller.
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        image_arn: Option<String>,
278    },
279}
280
281/// The objects that keep an AWS deny sandbox's sessions inside the VPC. Each id is recorded as
282/// soon as the object exists and cleared once it is deleted.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285#[serde(rename_all = "camelCase")]
286pub struct AwsSandboxEgressScaffolding {
287    /// IAM role Lambda assumes to place the connector's network interfaces.
288    pub operator_role_name: String,
289    /// Security group permitting egress to loopback only.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub security_group_id: Option<String>,
292    /// `AWS::Lambda::NetworkConnector` the sessions start with.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub connector_arn: Option<String>,
295    /// Cloud Control request creating or deleting the connector that AWS has not finished. Kept
296    /// so the request's outcome, and AWS's reason when it fails, is read on a later call.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub connector_request: Option<String>,
299}
300
301impl SetupScaffolding {
302    /// Takes each object `found` names that this record does not, and keeps every one it does:
303    /// a recorded id is what setup last saw, and teardown must delete that one.
304    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/// The cross-account read a manager opened on Alien's registry for one deployment.
337#[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    /// Repository identifiers the grant names, sorted.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub repositories: Vec<String>,
344
345    /// Compute services the grant admits, sorted. Each pulls as its own principal, so a service
346    /// added later needs the policy rewritten.
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub service_types: Vec<String>,
349}
350
351/// Deployment state
352///
353/// Represents the current state of deployed infrastructure, including release tracking.
354/// This is platform-agnostic - no backend IDs or database relationships.
355///
356/// The deployment engine manages releases internally: when a deployment succeeds,
357/// it promotes `target_release` to `current_release` and clears `target_release`.
358#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
359#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
360#[serde(rename_all = "camelCase")]
361pub struct DeploymentState {
362    /// Current lifecycle phase
363    pub status: DeploymentStatus,
364    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
365    pub platform: Platform,
366    /// Currently deployed release (None for first deployment)
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub current_release: Option<ReleaseInfo>,
369    /// Target release to deploy (None when synced with current)
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub target_release: Option<ReleaseInfo>,
372    /// Infrastructure resource tracking (which resources exist, their status, outputs)
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub stack_state: Option<StackState>,
375    /// Deployment-level error for failures not owned by a specific resource.
376    ///
377    /// Resource controller failures belong in `stack_state.resources[*].error`.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub error: Option<AlienError>,
380    /// Cloud account details (account ID, project number, region)
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub environment_info: Option<EnvironmentInfo>,
383    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub runtime_metadata: Option<RuntimeMetadata>,
386    /// Whether a retry has been requested for a failed deployment
387    /// When true and status is a failed state, the deployment system will retry failed resources
388    #[serde(default, skip_serializing_if = "is_false")]
389    pub retry_requested: bool,
390    /// Protocol version for cross-actor compatibility.
391    /// All actors (manager, push client, agent) check this before stepping.
392    /// Mismatched versions produce a clear error instead of silent corruption.
393    /// See docs/02-manager/10-deployment-protocol.md.
394    pub protocol_version: u32,
395}
396
397impl DeploymentState {
398    /// Returns whether this state carries desired infrastructure for the
399    /// deployment runner to converge.
400    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/// Result of a deployment step
408///
409/// Contains the complete next deployment state along with hints for the platform.
410/// This replaces the old delta-based `DeploymentStateUpdate` approach.
411#[derive(Debug, Clone, Serialize, Deserialize)]
412#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
413#[serde(rename_all = "camelCase")]
414pub struct DeploymentStepResult {
415    /// The complete next deployment state
416    pub state: DeploymentState,
417
418    /// Suggested delay before next step (optimization hint)
419    /// - `None`: No suggested delay, can poll immediately
420    /// - `Some(ms)`: Wait this many milliseconds before next step
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub suggested_delay_ms: Option<u64>,
423
424    /// Whether to update heartbeat timestamp (monitoring signal)
425    /// - `false`: Don't update heartbeat (default for most steps)
426    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
427    #[serde(default, skip_serializing_if = "is_false")]
428    pub update_heartbeat: bool,
429
430    /// Managed Alien resource status samples emitted by controllers during this step.
431    #[serde(
432        default,
433        rename = "resourceHeartbeats",
434        skip_serializing_if = "Vec::is_empty"
435    )]
436    pub heartbeats: Vec<ResourceHeartbeat>,
437
438    /// Observed raw-resource inventory batches read during this step.
439    #[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
451/// Answers for inputs gating Frozen resources, keyed by input id.
452pub type GateAnswers = IndexMap<String, bool>;
453
454/// Oldest deployment protocol version this binary can read.
455pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
456
457/// Deployment protocol written by actors with resumable protection, CORS and
458/// database update states and retained deletion. Older readers must be fenced
459/// before these states exist.
460pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
461
462/// Latest state protocol this binary can execute.
463pub const MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 4;
464
465/// GCP federation changes setup ownership and target credential semantics.
466pub 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
475/// Accept unchanged reports or the explicit upgrade to this writer protocol.
476/// Never permit downgrade or unknown protocols. Provider setup prerequisites
477/// remain separate from this checkpoint compatibility check.
478pub fn accepts_deployment_protocol_report(registered: u32, reported: u32) -> bool {
479    (MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION..=MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION)
480        .contains(&registered)
481        && (reported == registered
482            || (registered < CURRENT_DEPLOYMENT_PROTOCOL_VERSION
483                && reported == CURRENT_DEPLOYMENT_PROTOCOL_VERSION))
484}
485
486/// Backwards-compatible alias for older call sites.
487pub 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    /// Teardown reads this record back from persisted state, so its wire form is a contract.
571    #[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    /// A deny sandbox's record is written piece by piece, so a partial one must read back as is.
639    #[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}