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