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