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