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 serde::{Deserialize, Serialize};
7
8use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};
9
10/// One-shot authority for a setup re-import to replace setup-owned resources.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct SetupUpdateAuthorization {
15    /// Unique revision used by persistence layers for compare-and-swap updates.
16    pub nonce: String,
17    /// Frozen resource projection from the last successful deployment.
18    pub baseline_frozen_digest: String,
19    /// Frozen resource projection prepared by the setup re-import.
20    pub target_frozen_digest: String,
21    /// Release whose stack was prepared by setup.
22    pub release_id: String,
23    /// Stable setup target recorded on the imported deployment.
24    pub setup_target: String,
25    /// Exact setup artifact revision that authored this authority.
26    pub setup_fingerprint: String,
27    /// Setup fingerprint contract version.
28    pub setup_fingerprint_version: u32,
29}
30
31/// Runtime metadata for deployment
32///
33/// Stores deployment state that needs to persist across step calls.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
35#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
36#[serde(rename_all = "camelCase")]
37pub struct RuntimeMetadata {
38    /// Hash of the environment variables snapshot that was last synced to the vault
39    /// Used to avoid redundant sync operations during incremental deployment
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub last_synced_env_vars_hash: Option<String>,
42
43    /// Exact vault keys owned by the deployment secret synchronizer. This
44    /// inventory lets a later snapshot delete removed keys without listing or
45    /// touching unrelated values in the same vault.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub last_synced_secret_names: Vec<String>,
48
49    /// The prepared (mutated) stack from the last successful deployment phase
50    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
51    /// Used for compatibility checks during updates to compare mutated stacks
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub prepared_stack: Option<crate::Stack>,
54
55    /// Prepared target for an update that has not reached Running yet. Keeping
56    /// it separate preserves the last successful baseline across retries.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub pending_prepared_stack: Option<crate::Stack>,
59
60    /// One-shot setup update authority. It contains only non-secret identity
61    /// and canonical resource digests, never the imported payload or tokens.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub setup_update_authorization: Option<SetupUpdateAuthorization>,
64
65    /// Whether cross-account registry access has been successfully granted.
66    /// Set to true after the manager successfully sets the ECR/GAR repo policy
67    /// for this deployment's target account. Prevents redundant API calls on
68    /// every reconcile tick.
69    #[serde(default, skip_serializing_if = "is_false")]
70    pub registry_access_granted: bool,
71}
72
73/// Deployment state
74///
75/// Represents the current state of deployed infrastructure, including release tracking.
76/// This is platform-agnostic - no backend IDs or database relationships.
77///
78/// The deployment engine manages releases internally: when a deployment succeeds,
79/// it promotes `target_release` to `current_release` and clears `target_release`.
80#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "camelCase")]
83pub struct DeploymentState {
84    /// Current lifecycle phase
85    pub status: DeploymentStatus,
86    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
87    pub platform: Platform,
88    /// Currently deployed release (None for first deployment)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub current_release: Option<ReleaseInfo>,
91    /// Target release to deploy (None when synced with current)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub target_release: Option<ReleaseInfo>,
94    /// Infrastructure resource tracking (which resources exist, their status, outputs)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub stack_state: Option<StackState>,
97    /// Deployment-level error for failures not owned by a specific resource.
98    ///
99    /// Resource controller failures belong in `stack_state.resources[*].error`.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub error: Option<AlienError>,
102    /// Cloud account details (account ID, project number, region)
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub environment_info: Option<EnvironmentInfo>,
105    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub runtime_metadata: Option<RuntimeMetadata>,
108    /// Whether a retry has been requested for a failed deployment
109    /// When true and status is a failed state, the deployment system will retry failed resources
110    #[serde(default, skip_serializing_if = "is_false")]
111    pub retry_requested: bool,
112    /// Protocol version for cross-actor compatibility.
113    /// All actors (manager, push client, agent) check this before stepping.
114    /// Mismatched versions produce a clear error instead of silent corruption.
115    /// See docs/02-manager/10-deployment-protocol.md.
116    pub protocol_version: u32,
117}
118
119impl DeploymentState {
120    /// Returns whether this state carries desired infrastructure for the
121    /// deployment runner to converge.
122    pub fn has_desired(&self) -> bool {
123        self.current_release.is_some()
124            || self.target_release.is_some()
125            || self.stack_state.is_some()
126    }
127}
128
129/// Result of a deployment step
130///
131/// Contains the complete next deployment state along with hints for the platform.
132/// This replaces the old delta-based `DeploymentStateUpdate` approach.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
135#[serde(rename_all = "camelCase")]
136pub struct DeploymentStepResult {
137    /// The complete next deployment state
138    pub state: DeploymentState,
139
140    /// Suggested delay before next step (optimization hint)
141    /// - `None`: No suggested delay, can poll immediately
142    /// - `Some(ms)`: Wait this many milliseconds before next step
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub suggested_delay_ms: Option<u64>,
145
146    /// Whether to update heartbeat timestamp (monitoring signal)
147    /// - `false`: Don't update heartbeat (default for most steps)
148    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
149    #[serde(default, skip_serializing_if = "is_false")]
150    pub update_heartbeat: bool,
151
152    /// Managed Alien resource status samples emitted by controllers during this step.
153    #[serde(
154        default,
155        rename = "resourceHeartbeats",
156        skip_serializing_if = "Vec::is_empty"
157    )]
158    pub heartbeats: Vec<ResourceHeartbeat>,
159
160    /// Observed raw-resource inventory batches read during this step.
161    #[serde(
162        default,
163        rename = "observedInventoryBatches",
164        skip_serializing_if = "Vec::is_empty"
165    )]
166    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
167}
168
169pub(crate) fn is_false(b: &bool) -> bool {
170    !*b
171}
172
173/// Oldest deployment protocol version this binary can read.
174pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
175
176/// Deployment protocol version this binary writes.
177/// Bump when making incompatible changes to DeploymentState semantics.
178pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
179
180/// Backwards-compatible alias for older call sites.
181pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{Platform, ReleaseInfo, Stack, StackState};
187    use indexmap::IndexMap;
188
189    fn empty_stack() -> Stack {
190        Stack {
191            id: "stack_test".to_string(),
192            resources: IndexMap::new(),
193            inputs: vec![],
194            permissions: crate::PermissionsConfig::default(),
195            supported_platforms: None,
196        }
197    }
198
199    fn release_info(id: &str) -> ReleaseInfo {
200        ReleaseInfo {
201            release_id: Some(id.to_string()),
202            version: None,
203            description: None,
204            stack: empty_stack(),
205        }
206    }
207
208    fn state() -> DeploymentState {
209        DeploymentState {
210            status: DeploymentStatus::Pending,
211            platform: Platform::Kubernetes,
212            current_release: None,
213            target_release: None,
214            stack_state: None,
215            error: None,
216            environment_info: None,
217            runtime_metadata: None,
218            retry_requested: false,
219            protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
220        }
221    }
222
223    #[test]
224    fn deployment_state_has_desired_when_release_or_stack_state_exists() {
225        let observe_only = state();
226        assert!(!observe_only.has_desired());
227
228        let mut current = state();
229        current.current_release = Some(release_info("rel_current"));
230        assert!(current.has_desired());
231
232        let mut target = state();
233        target.target_release = Some(release_info("rel_target"));
234        assert!(target.has_desired());
235
236        let mut imported = state();
237        imported.stack_state = Some(StackState::new(Platform::Kubernetes));
238        assert!(imported.has_desired());
239    }
240
241    #[test]
242    fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
243        let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
244            "lastSyncedEnvVarsHash": "old-hash"
245        }))
246        .expect("old runtime metadata remains readable");
247
248        assert_eq!(
249            metadata.last_synced_env_vars_hash.as_deref(),
250            Some("old-hash")
251        );
252        assert!(metadata.last_synced_secret_names.is_empty());
253        assert!(metadata.pending_prepared_stack.is_none());
254        assert!(metadata.setup_update_authorization.is_none());
255    }
256}