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};
8
9use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};
10
11/// Actor that owns structural work during the initial setup phase.
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14#[serde(rename_all = "camelCase")]
15pub enum InitialSetupAuthority {
16    /// Setup state was registered by an external setup engine. Alien may only
17    /// continue controller states that the importer explicitly initialized.
18    #[default]
19    ImportedHandoff,
20    /// Alien is the setup engine and is running with administrator credentials.
21    DirectSetup,
22}
23
24/// One-shot authority for a setup re-import to replace setup-owned resources.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
27#[serde(rename_all = "camelCase")]
28pub struct SetupUpdateAuthorization {
29    /// Unique revision used by persistence layers for compare-and-swap updates.
30    pub nonce: String,
31    /// Frozen resource projection from the last successful deployment.
32    pub baseline_frozen_digest: String,
33    /// Frozen resource projection prepared by the setup re-import.
34    pub target_frozen_digest: String,
35    /// Release whose stack was prepared by setup.
36    pub release_id: String,
37    /// Stable setup target recorded on the imported deployment.
38    pub setup_target: String,
39    /// Exact setup artifact revision that authored this authority.
40    pub setup_fingerprint: String,
41    /// Setup fingerprint contract version.
42    pub setup_fingerprint_version: u32,
43}
44
45/// Runtime metadata for deployment
46///
47/// Stores deployment state that needs to persist across step calls.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50#[serde(rename_all = "camelCase")]
51pub struct RuntimeMetadata {
52    /// Actor authorized to perform structural Frozen-resource work during
53    /// InitialSetup. Missing legacy values fail closed as ImportedHandoff.
54    #[serde(default)]
55    pub initial_setup_authority: InitialSetupAuthority,
56
57    /// Hash of the environment variables snapshot that was last synced to the vault
58    /// Used to avoid redundant sync operations during incremental deployment
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub last_synced_env_vars_hash: Option<String>,
61
62    /// Exact vault keys owned by the deployment secret synchronizer. This
63    /// inventory lets a later snapshot delete removed keys without listing or
64    /// touching unrelated values in the same vault.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub last_synced_secret_names: Vec<String>,
67
68    /// The prepared (mutated) stack from the last successful deployment phase
69    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
70    /// Used for compatibility checks during updates to compare mutated stacks
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub prepared_stack: Option<crate::Stack>,
73
74    /// Canonical resolved answers for inputs that gate Frozen resources,
75    /// keyed by input id, recorded when the deployment is created or its
76    /// setup import registers.
77    ///
78    /// A frozen gate's answer is fixed for the deployment's lifetime: the
79    /// update path refuses input values that conflict with these, and a Live
80    /// resource sharing such an input resolves the persisted answer forever.
81    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
82    pub persisted_gate_answers: GateAnswers,
83
84    /// Prepared target for an update that has not reached Running yet. Keeping
85    /// it separate preserves the last successful baseline across retries.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub pending_prepared_stack: Option<crate::Stack>,
88
89    /// One-shot setup update authority. It contains only non-secret identity
90    /// and canonical resource digests, never the imported payload or tokens.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub setup_update_authorization: Option<SetupUpdateAuthorization>,
93
94    /// Last generated CLI package revision whose direct setup was applied.
95    /// This lets a newer generated CLI refresh setup-owned infrastructure once
96    /// before handing runtime changes back to the hosted manager.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub direct_setup_revision: Option<String>,
99
100    /// Whether cross-account registry access has been successfully granted.
101    /// Set to true after the manager successfully sets the ECR/GAR repo policy
102    /// for this deployment's target account. Prevents redundant API calls on
103    /// every reconcile tick.
104    #[serde(default, skip_serializing_if = "is_false")]
105    pub registry_access_granted: bool,
106
107    /// What that grant opened, compared against what the deployment needs now so a grant made
108    /// before a resource existed is finished rather than skipped, and the revoke names what was
109    /// granted. Absent on a grant recorded before this field existed.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub registry_access: Option<RegistryAccess>,
112}
113
114/// The cross-account read a manager opened on Alien's registry for one deployment.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[serde(rename_all = "camelCase")]
118pub struct RegistryAccess {
119    /// Repository identifiers the grant names, sorted.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub repositories: Vec<String>,
122
123    /// Compute services the grant admits, sorted. Each pulls as its own principal, so a service
124    /// added later needs the policy rewritten.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub service_types: Vec<String>,
127}
128
129/// Deployment state
130///
131/// Represents the current state of deployed infrastructure, including release tracking.
132/// This is platform-agnostic - no backend IDs or database relationships.
133///
134/// The deployment engine manages releases internally: when a deployment succeeds,
135/// it promotes `target_release` to `current_release` and clears `target_release`.
136#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[serde(rename_all = "camelCase")]
139pub struct DeploymentState {
140    /// Current lifecycle phase
141    pub status: DeploymentStatus,
142    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
143    pub platform: Platform,
144    /// Currently deployed release (None for first deployment)
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub current_release: Option<ReleaseInfo>,
147    /// Target release to deploy (None when synced with current)
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub target_release: Option<ReleaseInfo>,
150    /// Infrastructure resource tracking (which resources exist, their status, outputs)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub stack_state: Option<StackState>,
153    /// Deployment-level error for failures not owned by a specific resource.
154    ///
155    /// Resource controller failures belong in `stack_state.resources[*].error`.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub error: Option<AlienError>,
158    /// Cloud account details (account ID, project number, region)
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub environment_info: Option<EnvironmentInfo>,
161    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub runtime_metadata: Option<RuntimeMetadata>,
164    /// Whether a retry has been requested for a failed deployment
165    /// When true and status is a failed state, the deployment system will retry failed resources
166    #[serde(default, skip_serializing_if = "is_false")]
167    pub retry_requested: bool,
168    /// Protocol version for cross-actor compatibility.
169    /// All actors (manager, push client, agent) check this before stepping.
170    /// Mismatched versions produce a clear error instead of silent corruption.
171    /// See docs/02-manager/10-deployment-protocol.md.
172    pub protocol_version: u32,
173}
174
175impl DeploymentState {
176    /// Returns whether this state carries desired infrastructure for the
177    /// deployment runner to converge.
178    pub fn has_desired(&self) -> bool {
179        self.current_release.is_some()
180            || self.target_release.is_some()
181            || self.stack_state.is_some()
182    }
183}
184
185/// Result of a deployment step
186///
187/// Contains the complete next deployment state along with hints for the platform.
188/// This replaces the old delta-based `DeploymentStateUpdate` approach.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
191#[serde(rename_all = "camelCase")]
192pub struct DeploymentStepResult {
193    /// The complete next deployment state
194    pub state: DeploymentState,
195
196    /// Suggested delay before next step (optimization hint)
197    /// - `None`: No suggested delay, can poll immediately
198    /// - `Some(ms)`: Wait this many milliseconds before next step
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub suggested_delay_ms: Option<u64>,
201
202    /// Whether to update heartbeat timestamp (monitoring signal)
203    /// - `false`: Don't update heartbeat (default for most steps)
204    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
205    #[serde(default, skip_serializing_if = "is_false")]
206    pub update_heartbeat: bool,
207
208    /// Managed Alien resource status samples emitted by controllers during this step.
209    #[serde(
210        default,
211        rename = "resourceHeartbeats",
212        skip_serializing_if = "Vec::is_empty"
213    )]
214    pub heartbeats: Vec<ResourceHeartbeat>,
215
216    /// Observed raw-resource inventory batches read during this step.
217    #[serde(
218        default,
219        rename = "observedInventoryBatches",
220        skip_serializing_if = "Vec::is_empty"
221    )]
222    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
223}
224
225pub(crate) fn is_false(b: &bool) -> bool {
226    !*b
227}
228
229/// Answers for inputs gating Frozen resources, keyed by input id.
230pub type GateAnswers = IndexMap<String, bool>;
231
232/// Oldest deployment protocol version this binary can read.
233pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
234
235/// Deployment protocol version this binary writes.
236/// Bump when making incompatible changes to DeploymentState semantics.
237///
238/// `persisted_gate_answers` on the runtime metadata stays at this version:
239/// the field is additive, an actor unaware of it still cannot flip a frozen
240/// resource (its strip resolves from state presence, so a changed input is
241/// ignored rather than applied), and the sync routes carry a recorded map
242/// through a write-back that drops the field. A bump would hard-refuse every
243/// customer-scheduled pull agent the moment a newer manager writes state.
244pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
245
246/// Backwards-compatible alias for older call sites.
247pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::{Platform, ReleaseInfo, Stack, StackState};
253    use indexmap::IndexMap;
254
255    fn empty_stack() -> Stack {
256        Stack {
257            id: "stack_test".to_string(),
258            resources: IndexMap::new(),
259            inputs: vec![],
260            permissions: crate::PermissionsConfig::default(),
261            supported_platforms: None,
262        }
263    }
264
265    fn release_info(id: &str) -> ReleaseInfo {
266        ReleaseInfo {
267            release_id: Some(id.to_string()),
268            version: None,
269            description: None,
270            stack: empty_stack(),
271        }
272    }
273
274    fn state() -> DeploymentState {
275        DeploymentState {
276            status: DeploymentStatus::Pending,
277            platform: Platform::Kubernetes,
278            current_release: None,
279            target_release: None,
280            stack_state: None,
281            error: None,
282            environment_info: None,
283            runtime_metadata: None,
284            retry_requested: false,
285            protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
286        }
287    }
288
289    #[test]
290    fn deployment_state_has_desired_when_release_or_stack_state_exists() {
291        let observe_only = state();
292        assert!(!observe_only.has_desired());
293
294        let mut current = state();
295        current.current_release = Some(release_info("rel_current"));
296        assert!(current.has_desired());
297
298        let mut target = state();
299        target.target_release = Some(release_info("rel_target"));
300        assert!(target.has_desired());
301
302        let mut imported = state();
303        imported.stack_state = Some(StackState::new(Platform::Kubernetes));
304        assert!(imported.has_desired());
305    }
306
307    #[test]
308    fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
309        let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
310            "lastSyncedEnvVarsHash": "old-hash"
311        }))
312        .expect("old runtime metadata remains readable");
313
314        assert_eq!(
315            metadata.last_synced_env_vars_hash.as_deref(),
316            Some("old-hash")
317        );
318        assert!(metadata.last_synced_secret_names.is_empty());
319        assert!(metadata.pending_prepared_stack.is_none());
320        assert!(metadata.setup_update_authorization.is_none());
321    }
322}