1use crate::{ObservedInventoryBatch, Platform, ResourceHeartbeat, StackState};
4use alien_error::AlienError;
5use bon::Builder;
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase")]
16pub enum InitialSetupAuthority {
17 #[default]
20 ImportedHandoff,
21 DirectSetup,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct SetupUpdateAuthorization {
30 pub nonce: String,
32 pub baseline_frozen_digest: String,
34 pub target_frozen_digest: String,
36 pub release_id: String,
38 pub setup_target: String,
40 pub setup_fingerprint: String,
42 pub setup_fingerprint_version: u32,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase")]
52pub struct RuntimeMetadata {
53 #[serde(default)]
56 pub initial_setup_authority: InitialSetupAuthority,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
61 pub last_synced_env_vars_hash: Option<String>,
62
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub last_synced_secret_names: Vec<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
73 pub prepared_stack: Option<crate::Stack>,
74
75 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
83 pub persisted_gate_answers: GateAnswers,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub pending_prepared_stack: Option<crate::Stack>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub direct_setup_revision: Option<String>,
100
101 #[serde(default, skip_serializing_if = "is_false")]
106 pub registry_access_granted: bool,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub registry_access: Option<RegistryAccess>,
113
114 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[serde(rename_all = "camelCase", tag = "type")]
124pub enum SetupScaffolding {
125 #[serde(rename_all = "camelCase")]
127 AwsSandbox {
128 build_role_name: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 egress: Option<AwsSandboxEgressScaffolding>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
136 image_arn: Option<String>,
137 },
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144#[serde(rename_all = "camelCase")]
145pub struct AwsSandboxEgressScaffolding {
146 pub operator_role_name: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub security_group_id: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub connector_arn: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub connector_request: Option<String>,
158}
159
160impl SetupScaffolding {
161 pub fn fill_missing(&mut self, found: SetupScaffolding) {
164 match (self, found) {
165 (
166 SetupScaffolding::AwsSandbox {
167 egress, image_arn, ..
168 },
169 SetupScaffolding::AwsSandbox {
170 egress: found_egress,
171 image_arn: found_image_arn,
172 ..
173 },
174 ) => {
175 if image_arn.is_none() {
176 *image_arn = found_image_arn;
177 }
178 match (egress.as_mut(), found_egress) {
179 (None, found_egress) => *egress = found_egress,
180 (Some(recorded), Some(found)) => {
181 if recorded.security_group_id.is_none() {
182 recorded.security_group_id = found.security_group_id;
183 }
184 if recorded.connector_arn.is_none() {
185 recorded.connector_arn = found.connector_arn;
186 }
187 }
188 (Some(_), None) => {}
189 }
190 }
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198#[serde(rename_all = "camelCase")]
199pub struct RegistryAccess {
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub repositories: Vec<String>,
203
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub service_types: Vec<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "camelCase")]
220pub struct DeploymentState {
221 pub status: DeploymentStatus,
223 pub platform: Platform,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub current_release: Option<ReleaseInfo>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub target_release: Option<ReleaseInfo>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub stack_state: Option<StackState>,
234 #[serde(skip_serializing_if = "Option::is_none")]
238 pub error: Option<AlienError>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub environment_info: Option<EnvironmentInfo>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub runtime_metadata: Option<RuntimeMetadata>,
245 #[serde(default, skip_serializing_if = "is_false")]
248 pub retry_requested: bool,
249 pub protocol_version: u32,
254}
255
256impl DeploymentState {
257 pub fn has_desired(&self) -> bool {
260 self.current_release.is_some()
261 || self.target_release.is_some()
262 || self.stack_state.is_some()
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
271#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
272#[serde(rename_all = "camelCase")]
273pub struct DeploymentStepResult {
274 pub state: DeploymentState,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
281 pub suggested_delay_ms: Option<u64>,
282
283 #[serde(default, skip_serializing_if = "is_false")]
287 pub update_heartbeat: bool,
288
289 #[serde(
291 default,
292 rename = "resourceHeartbeats",
293 skip_serializing_if = "Vec::is_empty"
294 )]
295 pub heartbeats: Vec<ResourceHeartbeat>,
296
297 #[serde(
299 default,
300 rename = "observedInventoryBatches",
301 skip_serializing_if = "Vec::is_empty"
302 )]
303 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
304}
305
306pub(crate) fn is_false(b: &bool) -> bool {
307 !*b
308}
309
310pub type GateAnswers = IndexMap<String, bool>;
312
313pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
315
316pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
326
327pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::{Platform, ReleaseInfo, Stack, StackState};
334 use indexmap::IndexMap;
335
336 fn empty_stack() -> Stack {
337 Stack {
338 id: "stack_test".to_string(),
339 resources: IndexMap::new(),
340 inputs: vec![],
341 dynamic_container_repositories: Vec::new(),
342 dynamic_container_image_resources: Vec::new(),
343 permissions: crate::PermissionsConfig::default(),
344 supported_platforms: None,
345 }
346 }
347
348 fn release_info(id: &str) -> ReleaseInfo {
349 ReleaseInfo {
350 release_id: Some(id.to_string()),
351 version: None,
352 description: None,
353 stack: empty_stack(),
354 }
355 }
356
357 fn state() -> DeploymentState {
358 DeploymentState {
359 status: DeploymentStatus::Pending,
360 platform: Platform::Kubernetes,
361 current_release: None,
362 target_release: None,
363 stack_state: None,
364 error: None,
365 environment_info: None,
366 runtime_metadata: None,
367 retry_requested: false,
368 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
369 }
370 }
371
372 #[test]
373 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
374 let observe_only = state();
375 assert!(!observe_only.has_desired());
376
377 let mut current = state();
378 current.current_release = Some(release_info("rel_current"));
379 assert!(current.has_desired());
380
381 let mut target = state();
382 target.target_release = Some(release_info("rel_target"));
383 assert!(target.has_desired());
384
385 let mut imported = state();
386 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
387 assert!(imported.has_desired());
388 }
389
390 #[test]
391 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
392 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
393 "lastSyncedEnvVarsHash": "old-hash"
394 }))
395 .expect("old runtime metadata remains readable");
396
397 assert_eq!(
398 metadata.last_synced_env_vars_hash.as_deref(),
399 Some("old-hash")
400 );
401 assert!(metadata.last_synced_secret_names.is_empty());
402 assert!(metadata.pending_prepared_stack.is_none());
403 assert!(metadata.setup_update_authorization.is_none());
404 assert!(metadata.setup_scaffolding.is_empty());
405 }
406
407 #[test]
409 fn setup_scaffolding_round_trips_in_its_persisted_form() {
410 let persisted = serde_json::json!({
411 "initialSetupAuthority": "directSetup",
412 "setupScaffolding": {
413 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
414 }
415 });
416 let metadata: RuntimeMetadata =
417 serde_json::from_value(persisted.clone()).expect("persisted record reads");
418 assert_eq!(
419 metadata.setup_scaffolding["agents"],
420 SetupScaffolding::AwsSandbox {
421 build_role_name: "acme-agents-build".to_string(),
422 egress: None,
423 image_arn: None,
424 }
425 );
426 assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
427 assert!(
428 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
429 "state with no scaffolding serializes as it did before the field existed"
430 );
431 }
432
433 #[test]
434 fn filling_a_record_takes_only_what_it_lacks() {
435 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
436 operator_role_name: "acme-agents-egress".to_string(),
437 security_group_id: group.map(str::to_string),
438 connector_arn: connector.map(str::to_string),
439 connector_request: None,
440 };
441 let record = |egress| SetupScaffolding::AwsSandbox {
442 build_role_name: "acme-agents-build".to_string(),
443 egress,
444 image_arn: None,
445 };
446
447 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
448 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
449 assert_eq!(
450 partial,
451 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
452 "a recorded id is the one teardown deletes"
453 );
454
455 let mut role_only = record(None);
456 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
457 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
458
459 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
460 build_role_name: "acme-agents-build".to_string(),
461 egress: None,
462 image_arn: arn.map(str::to_string),
463 };
464 let mut without_image = image(None);
465 without_image.fill_missing(image(Some("arn:found")));
466 assert_eq!(without_image, image(Some("arn:found")));
467 let mut with_image = image(Some("arn:recorded"));
468 with_image.fill_missing(image(Some("arn:found")));
469 assert_eq!(with_image, image(Some("arn:recorded")));
470 }
471
472 #[test]
474 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
475 let persisted = serde_json::json!({
476 "type": "awsSandbox",
477 "buildRoleName": "acme-agents-build",
478 "egress": {
479 "operatorRoleName": "acme-agents-egress",
480 "securityGroupId": "sg-0123"
481 }
482 });
483 let record: SetupScaffolding =
484 serde_json::from_value(persisted.clone()).expect("persisted record reads");
485 assert_eq!(
486 record,
487 SetupScaffolding::AwsSandbox {
488 build_role_name: "acme-agents-build".to_string(),
489 egress: Some(AwsSandboxEgressScaffolding {
490 operator_role_name: "acme-agents-egress".to_string(),
491 security_group_id: Some("sg-0123".to_string()),
492 connector_arn: None,
493 connector_request: None,
494 }),
495 image_arn: None,
496 }
497 );
498 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
499 }
500}