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