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 derived
58 /// from the settled stack state on the first update of an older state).
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 a write-back that drops the field is
197/// rebuilt from the settled state. 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}