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/// Runtime metadata for deployment
60///
61/// Stores deployment state that needs to persist across step calls.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[serde(rename_all = "camelCase")]
65pub struct RuntimeMetadata {
66    /// Bounded credential retry state shared by all deployment drivers.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub credential_retry: Option<CredentialRetry>,
69
70    /// Actor authorized to perform structural Frozen-resource work during
71    /// InitialSetup. Missing legacy values fail closed as ImportedHandoff.
72    #[serde(default)]
73    pub initial_setup_authority: InitialSetupAuthority,
74
75    /// Setup-owned grant to keep using the direct setup executor for updates.
76    /// Runtime settings and release contents must never create or change this grant.
77    #[serde(default, skip_serializing_if = "super::is_false")]
78    pub automatic_setup: bool,
79
80    /// Hash of the environment variables snapshot that was last synced to the vault
81    /// Used to avoid redundant sync operations during incremental deployment
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub last_synced_env_vars_hash: Option<String>,
84
85    /// Exact vault keys owned by the deployment secret synchronizer. This
86    /// inventory lets a later snapshot delete removed keys without listing or
87    /// touching unrelated values in the same vault.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub last_synced_secret_names: Vec<String>,
90
91    /// The prepared (mutated) stack from the last successful deployment phase
92    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
93    /// Used for compatibility checks during updates to compare mutated stacks
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub prepared_stack: Option<crate::Stack>,
96
97    /// Canonical resolved answers for inputs that gate Frozen resources,
98    /// keyed by input id, recorded when the deployment is created or its
99    /// setup import registers.
100    ///
101    /// Manual installations keep these answers fixed. An installation with an
102    /// ongoing-management grant refreshes them when preparing a release. Live
103    /// resources sharing a gate use the same recorded answer as Frozen resources.
104    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
105    pub persisted_gate_answers: GateAnswers,
106
107    /// Prepared target for an update that has not reached Running yet. Keeping
108    /// it separate preserves the last successful baseline across retries.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub pending_prepared_stack: Option<crate::Stack>,
111
112    /// Release the in-flight stack was prepared from: `pending_prepared_stack`
113    /// during updates, or `prepared_stack` during local first startup. The target can move
114    /// to a newer release while that stack is still being applied; this tells
115    /// the update which release actually converged.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub pending_prepared_release_id: Option<String>,
118
119    /// One-shot setup update authority. It contains only non-secret identity
120    /// and canonical resource digests, never the imported payload or tokens.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub setup_update_authorization: Option<SetupUpdateAuthorization>,
123
124    /// Last generated CLI package revision whose direct setup was applied.
125    /// This lets a newer generated CLI refresh setup-owned infrastructure once
126    /// before handing runtime changes back to the hosted manager.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub direct_setup_revision: Option<String>,
129
130    /// Whether cross-account registry access has been successfully granted.
131    /// Set to true after the manager successfully sets the ECR/GAR repo policy
132    /// for this deployment's target account. Prevents redundant API calls on
133    /// every reconcile tick.
134    #[serde(default, skip_serializing_if = "is_false")]
135    pub registry_access_granted: bool,
136
137    /// What that grant opened, compared against what the deployment needs now so a grant made
138    /// before a resource existed is finished rather than skipped, and the revoke names what was
139    /// granted. Absent on a grant recorded before this field existed.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub registry_access: Option<RegistryAccess>,
142
143    /// What a direct setup created for resources it does not own, keyed by resource id. Setup
144    /// teardown removes exactly what is recorded here.
145    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146    pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
147
148    /// Whether each vault-native deployer secret is in the customer's secret
149    /// store, with where it goes. Checked from metadata only; no value is ever
150    /// read or recorded here.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub deployer_secrets: Vec<crate::DeployerSecretReport>,
153}
154
155/// Cloud objects a direct setup created so a runtime-owned resource can run.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
158#[serde(rename_all = "camelCase", tag = "type")]
159pub enum SetupScaffolding {
160    /// An AWS sandbox's image-build role, and for `egress: deny` its egress objects.
161    #[serde(rename_all = "camelCase")]
162    AwsSandbox {
163        /// IAM role the image build runs as.
164        build_role_name: String,
165        /// What `egress: deny` needs; absent for an open sandbox.
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        egress: Option<AwsSandboxEgressScaffolding>,
168        /// A Frozen sandbox's MicroVM image, built during setup. A Live one's image belongs to its
169        /// runtime controller.
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        image_arn: Option<String>,
172    },
173}
174
175/// The objects that keep an AWS deny sandbox's sessions inside the VPC. Each id is recorded as
176/// soon as the object exists and cleared once it is deleted.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179#[serde(rename_all = "camelCase")]
180pub struct AwsSandboxEgressScaffolding {
181    /// IAM role Lambda assumes to place the connector's network interfaces.
182    pub operator_role_name: String,
183    /// Security group permitting egress to loopback only.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub security_group_id: Option<String>,
186    /// `AWS::Lambda::NetworkConnector` the sessions start with.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub connector_arn: Option<String>,
189    /// Cloud Control request creating or deleting the connector that AWS has not finished. Kept
190    /// so the request's outcome, and AWS's reason when it fails, is read on a later call.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub connector_request: Option<String>,
193}
194
195impl SetupScaffolding {
196    /// Takes each object `found` names that this record does not, and keeps every one it does:
197    /// a recorded id is what setup last saw, and teardown must delete that one.
198    pub fn fill_missing(&mut self, found: SetupScaffolding) {
199        match (self, found) {
200            (
201                SetupScaffolding::AwsSandbox {
202                    egress, image_arn, ..
203                },
204                SetupScaffolding::AwsSandbox {
205                    egress: found_egress,
206                    image_arn: found_image_arn,
207                    ..
208                },
209            ) => {
210                if image_arn.is_none() {
211                    *image_arn = found_image_arn;
212                }
213                match (egress.as_mut(), found_egress) {
214                    (None, found_egress) => *egress = found_egress,
215                    (Some(recorded), Some(found)) => {
216                        if recorded.security_group_id.is_none() {
217                            recorded.security_group_id = found.security_group_id;
218                        }
219                        if recorded.connector_arn.is_none() {
220                            recorded.connector_arn = found.connector_arn;
221                        }
222                    }
223                    (Some(_), None) => {}
224                }
225            }
226        }
227    }
228}
229
230/// The cross-account read a manager opened on Alien's registry for one deployment.
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233#[serde(rename_all = "camelCase")]
234pub struct RegistryAccess {
235    /// Repository identifiers the grant names, sorted.
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub repositories: Vec<String>,
238
239    /// Compute services the grant admits, sorted. Each pulls as its own principal, so a service
240    /// added later needs the policy rewritten.
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub service_types: Vec<String>,
243}
244
245/// Deployment state
246///
247/// Represents the current state of deployed infrastructure, including release tracking.
248/// This is platform-agnostic - no backend IDs or database relationships.
249///
250/// The deployment engine manages releases internally: when a deployment succeeds,
251/// it promotes `target_release` to `current_release` and clears `target_release`.
252#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
253#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
254#[serde(rename_all = "camelCase")]
255pub struct DeploymentState {
256    /// Current lifecycle phase
257    pub status: DeploymentStatus,
258    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
259    pub platform: Platform,
260    /// Currently deployed release (None for first deployment)
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub current_release: Option<ReleaseInfo>,
263    /// Target release to deploy (None when synced with current)
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub target_release: Option<ReleaseInfo>,
266    /// Infrastructure resource tracking (which resources exist, their status, outputs)
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub stack_state: Option<StackState>,
269    /// Deployment-level error for failures not owned by a specific resource.
270    ///
271    /// Resource controller failures belong in `stack_state.resources[*].error`.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub error: Option<AlienError>,
274    /// Cloud account details (account ID, project number, region)
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub environment_info: Option<EnvironmentInfo>,
277    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub runtime_metadata: Option<RuntimeMetadata>,
280    /// Whether a retry has been requested for a failed deployment
281    /// When true and status is a failed state, the deployment system will retry failed resources
282    #[serde(default, skip_serializing_if = "is_false")]
283    pub retry_requested: bool,
284    /// Protocol version for cross-actor compatibility.
285    /// All actors (manager, push client, agent) check this before stepping.
286    /// Mismatched versions produce a clear error instead of silent corruption.
287    /// See docs/02-manager/10-deployment-protocol.md.
288    pub protocol_version: u32,
289}
290
291impl DeploymentState {
292    /// Returns whether this state carries desired infrastructure for the
293    /// deployment runner to converge.
294    pub fn has_desired(&self) -> bool {
295        self.current_release.is_some()
296            || self.target_release.is_some()
297            || self.stack_state.is_some()
298    }
299}
300
301/// Result of a deployment step
302///
303/// Contains the complete next deployment state along with hints for the platform.
304/// This replaces the old delta-based `DeploymentStateUpdate` approach.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
307#[serde(rename_all = "camelCase")]
308pub struct DeploymentStepResult {
309    /// The complete next deployment state
310    pub state: DeploymentState,
311
312    /// Suggested delay before next step (optimization hint)
313    /// - `None`: No suggested delay, can poll immediately
314    /// - `Some(ms)`: Wait this many milliseconds before next step
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub suggested_delay_ms: Option<u64>,
317
318    /// Whether to update heartbeat timestamp (monitoring signal)
319    /// - `false`: Don't update heartbeat (default for most steps)
320    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
321    #[serde(default, skip_serializing_if = "is_false")]
322    pub update_heartbeat: bool,
323
324    /// Managed Alien resource status samples emitted by controllers during this step.
325    #[serde(
326        default,
327        rename = "resourceHeartbeats",
328        skip_serializing_if = "Vec::is_empty"
329    )]
330    pub heartbeats: Vec<ResourceHeartbeat>,
331
332    /// Observed raw-resource inventory batches read during this step.
333    #[serde(
334        default,
335        rename = "observedInventoryBatches",
336        skip_serializing_if = "Vec::is_empty"
337    )]
338    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
339}
340
341pub(crate) fn is_false(b: &bool) -> bool {
342    !*b
343}
344
345/// Answers for inputs gating Frozen resources, keyed by input id.
346pub type GateAnswers = IndexMap<String, bool>;
347
348/// Oldest deployment protocol version this binary can read.
349pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
350
351/// Deployment protocol version this binary writes.
352/// Bump when making incompatible changes to DeploymentState semantics.
353///
354/// `persisted_gate_answers` on the runtime metadata stays at this version:
355/// the field is additive, an actor unaware of it still cannot flip a frozen
356/// resource (its strip resolves from state presence, so a changed input is
357/// ignored rather than applied), and the sync routes carry a recorded map
358/// through a write-back that drops the field. A bump would hard-refuse every
359/// customer-scheduled pull agent the moment a newer manager writes state.
360pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
361
362/// Backwards-compatible alias for older call sites.
363pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::{Platform, ReleaseInfo, Stack, StackState};
369    use indexmap::IndexMap;
370
371    fn empty_stack() -> Stack {
372        Stack {
373            id: "stack_test".to_string(),
374            resources: IndexMap::new(),
375            inputs: vec![],
376            dynamic_container_repositories: Vec::new(),
377            dynamic_container_image_resources: Vec::new(),
378            operations: None,
379            permissions: crate::PermissionsConfig::default(),
380            supported_platforms: None,
381        }
382    }
383
384    fn release_info(id: &str) -> ReleaseInfo {
385        ReleaseInfo {
386            release_id: Some(id.to_string()),
387            version: None,
388            description: None,
389            stack: empty_stack(),
390        }
391    }
392
393    fn state() -> DeploymentState {
394        DeploymentState {
395            status: DeploymentStatus::Pending,
396            platform: Platform::Kubernetes,
397            current_release: None,
398            target_release: None,
399            stack_state: None,
400            error: None,
401            environment_info: None,
402            runtime_metadata: None,
403            retry_requested: false,
404            protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
405        }
406    }
407
408    #[test]
409    fn deployment_state_has_desired_when_release_or_stack_state_exists() {
410        let observe_only = state();
411        assert!(!observe_only.has_desired());
412
413        let mut current = state();
414        current.current_release = Some(release_info("rel_current"));
415        assert!(current.has_desired());
416
417        let mut target = state();
418        target.target_release = Some(release_info("rel_target"));
419        assert!(target.has_desired());
420
421        let mut imported = state();
422        imported.stack_state = Some(StackState::new(Platform::Kubernetes));
423        assert!(imported.has_desired());
424    }
425
426    #[test]
427    fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
428        let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
429            "lastSyncedEnvVarsHash": "old-hash"
430        }))
431        .expect("old runtime metadata remains readable");
432
433        assert_eq!(
434            metadata.last_synced_env_vars_hash.as_deref(),
435            Some("old-hash")
436        );
437        assert!(metadata.last_synced_secret_names.is_empty());
438        assert!(metadata.pending_prepared_stack.is_none());
439        assert!(metadata.setup_update_authorization.is_none());
440        assert!(metadata.setup_scaffolding.is_empty());
441    }
442
443    /// Teardown reads this record back from persisted state, so its wire form is a contract.
444    #[test]
445    fn setup_scaffolding_round_trips_in_its_persisted_form() {
446        let persisted = serde_json::json!({
447            "initialSetupAuthority": "directSetup",
448            "setupScaffolding": {
449                "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
450            }
451        });
452        let metadata: RuntimeMetadata =
453            serde_json::from_value(persisted.clone()).expect("persisted record reads");
454        assert_eq!(
455            metadata.setup_scaffolding["agents"],
456            SetupScaffolding::AwsSandbox {
457                build_role_name: "acme-agents-build".to_string(),
458                egress: None,
459                image_arn: None,
460            }
461        );
462        assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
463        assert!(
464            serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
465            "state with no scaffolding serializes as it did before the field existed"
466        );
467    }
468
469    #[test]
470    fn filling_a_record_takes_only_what_it_lacks() {
471        let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
472            operator_role_name: "acme-agents-egress".to_string(),
473            security_group_id: group.map(str::to_string),
474            connector_arn: connector.map(str::to_string),
475            connector_request: None,
476        };
477        let record = |egress| SetupScaffolding::AwsSandbox {
478            build_role_name: "acme-agents-build".to_string(),
479            egress,
480            image_arn: None,
481        };
482
483        let mut partial = record(Some(egress(Some("sg-recorded"), None)));
484        partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
485        assert_eq!(
486            partial,
487            record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
488            "a recorded id is the one teardown deletes"
489        );
490
491        let mut role_only = record(None);
492        role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
493        assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
494
495        let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
496            build_role_name: "acme-agents-build".to_string(),
497            egress: None,
498            image_arn: arn.map(str::to_string),
499        };
500        let mut without_image = image(None);
501        without_image.fill_missing(image(Some("arn:found")));
502        assert_eq!(without_image, image(Some("arn:found")));
503        let mut with_image = image(Some("arn:recorded"));
504        with_image.fill_missing(image(Some("arn:found")));
505        assert_eq!(with_image, image(Some("arn:recorded")));
506    }
507
508    /// A deny sandbox's record is written piece by piece, so a partial one must read back as is.
509    #[test]
510    fn a_partial_egress_record_round_trips_in_its_persisted_form() {
511        let persisted = serde_json::json!({
512            "type": "awsSandbox",
513            "buildRoleName": "acme-agents-build",
514            "egress": {
515                "operatorRoleName": "acme-agents-egress",
516                "securityGroupId": "sg-0123"
517            }
518        });
519        let record: SetupScaffolding =
520            serde_json::from_value(persisted.clone()).expect("persisted record reads");
521        assert_eq!(
522            record,
523            SetupScaffolding::AwsSandbox {
524                build_role_name: "acme-agents-build".to_string(),
525                egress: Some(AwsSandboxEgressScaffolding {
526                    operator_role_name: "acme-agents-egress".to_string(),
527                    security_group_id: Some("sg-0123".to_string()),
528                    connector_arn: None,
529                    connector_request: None,
530                }),
531                image_arn: None,
532            }
533        );
534        assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
535    }
536}