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