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