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