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")]
94 pub pending_prepared_release_id: Option<String>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub direct_setup_revision: Option<String>,
106
107 #[serde(default, skip_serializing_if = "is_false")]
112 pub registry_access_granted: bool,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub registry_access: Option<RegistryAccess>,
119
120 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
123 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
124
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
135#[serde(rename_all = "camelCase", tag = "type")]
136pub enum SetupScaffolding {
137 #[serde(rename_all = "camelCase")]
139 AwsSandbox {
140 build_role_name: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 egress: Option<AwsSandboxEgressScaffolding>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 image_arn: Option<String>,
149 },
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(rename_all = "camelCase")]
157pub struct AwsSandboxEgressScaffolding {
158 pub operator_role_name: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub security_group_id: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub connector_arn: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub connector_request: Option<String>,
170}
171
172impl SetupScaffolding {
173 pub fn fill_missing(&mut self, found: SetupScaffolding) {
176 match (self, found) {
177 (
178 SetupScaffolding::AwsSandbox {
179 egress, image_arn, ..
180 },
181 SetupScaffolding::AwsSandbox {
182 egress: found_egress,
183 image_arn: found_image_arn,
184 ..
185 },
186 ) => {
187 if image_arn.is_none() {
188 *image_arn = found_image_arn;
189 }
190 match (egress.as_mut(), found_egress) {
191 (None, found_egress) => *egress = found_egress,
192 (Some(recorded), Some(found)) => {
193 if recorded.security_group_id.is_none() {
194 recorded.security_group_id = found.security_group_id;
195 }
196 if recorded.connector_arn.is_none() {
197 recorded.connector_arn = found.connector_arn;
198 }
199 }
200 (Some(_), None) => {}
201 }
202 }
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
209#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
210#[serde(rename_all = "camelCase")]
211pub struct RegistryAccess {
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub repositories: Vec<String>,
215
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub service_types: Vec<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
230#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
231#[serde(rename_all = "camelCase")]
232pub struct DeploymentState {
233 pub status: DeploymentStatus,
235 pub platform: Platform,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub current_release: Option<ReleaseInfo>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub target_release: Option<ReleaseInfo>,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub stack_state: Option<StackState>,
246 #[serde(skip_serializing_if = "Option::is_none")]
250 pub error: Option<AlienError>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub environment_info: Option<EnvironmentInfo>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub runtime_metadata: Option<RuntimeMetadata>,
257 #[serde(default, skip_serializing_if = "is_false")]
260 pub retry_requested: bool,
261 pub protocol_version: u32,
266}
267
268impl DeploymentState {
269 pub fn has_desired(&self) -> bool {
272 self.current_release.is_some()
273 || self.target_release.is_some()
274 || self.stack_state.is_some()
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
283#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
284#[serde(rename_all = "camelCase")]
285pub struct DeploymentStepResult {
286 pub state: DeploymentState,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
293 pub suggested_delay_ms: Option<u64>,
294
295 #[serde(default, skip_serializing_if = "is_false")]
299 pub update_heartbeat: bool,
300
301 #[serde(
303 default,
304 rename = "resourceHeartbeats",
305 skip_serializing_if = "Vec::is_empty"
306 )]
307 pub heartbeats: Vec<ResourceHeartbeat>,
308
309 #[serde(
311 default,
312 rename = "observedInventoryBatches",
313 skip_serializing_if = "Vec::is_empty"
314 )]
315 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
316}
317
318pub(crate) fn is_false(b: &bool) -> bool {
319 !*b
320}
321
322pub type GateAnswers = IndexMap<String, bool>;
324
325pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
327
328pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
338
339pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::{Platform, ReleaseInfo, Stack, StackState};
346 use indexmap::IndexMap;
347
348 fn empty_stack() -> Stack {
349 Stack {
350 id: "stack_test".to_string(),
351 resources: IndexMap::new(),
352 inputs: vec![],
353 dynamic_container_repositories: Vec::new(),
354 dynamic_container_image_resources: Vec::new(),
355 operations: None,
356 permissions: crate::PermissionsConfig::default(),
357 supported_platforms: None,
358 }
359 }
360
361 fn release_info(id: &str) -> ReleaseInfo {
362 ReleaseInfo {
363 release_id: Some(id.to_string()),
364 version: None,
365 description: None,
366 stack: empty_stack(),
367 }
368 }
369
370 fn state() -> DeploymentState {
371 DeploymentState {
372 status: DeploymentStatus::Pending,
373 platform: Platform::Kubernetes,
374 current_release: None,
375 target_release: None,
376 stack_state: None,
377 error: None,
378 environment_info: None,
379 runtime_metadata: None,
380 retry_requested: false,
381 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
382 }
383 }
384
385 #[test]
386 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
387 let observe_only = state();
388 assert!(!observe_only.has_desired());
389
390 let mut current = state();
391 current.current_release = Some(release_info("rel_current"));
392 assert!(current.has_desired());
393
394 let mut target = state();
395 target.target_release = Some(release_info("rel_target"));
396 assert!(target.has_desired());
397
398 let mut imported = state();
399 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
400 assert!(imported.has_desired());
401 }
402
403 #[test]
404 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
405 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
406 "lastSyncedEnvVarsHash": "old-hash"
407 }))
408 .expect("old runtime metadata remains readable");
409
410 assert_eq!(
411 metadata.last_synced_env_vars_hash.as_deref(),
412 Some("old-hash")
413 );
414 assert!(metadata.last_synced_secret_names.is_empty());
415 assert!(metadata.pending_prepared_stack.is_none());
416 assert!(metadata.setup_update_authorization.is_none());
417 assert!(metadata.setup_scaffolding.is_empty());
418 }
419
420 #[test]
422 fn setup_scaffolding_round_trips_in_its_persisted_form() {
423 let persisted = serde_json::json!({
424 "initialSetupAuthority": "directSetup",
425 "setupScaffolding": {
426 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
427 }
428 });
429 let metadata: RuntimeMetadata =
430 serde_json::from_value(persisted.clone()).expect("persisted record reads");
431 assert_eq!(
432 metadata.setup_scaffolding["agents"],
433 SetupScaffolding::AwsSandbox {
434 build_role_name: "acme-agents-build".to_string(),
435 egress: None,
436 image_arn: None,
437 }
438 );
439 assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
440 assert!(
441 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
442 "state with no scaffolding serializes as it did before the field existed"
443 );
444 }
445
446 #[test]
447 fn filling_a_record_takes_only_what_it_lacks() {
448 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
449 operator_role_name: "acme-agents-egress".to_string(),
450 security_group_id: group.map(str::to_string),
451 connector_arn: connector.map(str::to_string),
452 connector_request: None,
453 };
454 let record = |egress| SetupScaffolding::AwsSandbox {
455 build_role_name: "acme-agents-build".to_string(),
456 egress,
457 image_arn: None,
458 };
459
460 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
461 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
462 assert_eq!(
463 partial,
464 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
465 "a recorded id is the one teardown deletes"
466 );
467
468 let mut role_only = record(None);
469 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
470 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
471
472 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
473 build_role_name: "acme-agents-build".to_string(),
474 egress: None,
475 image_arn: arn.map(str::to_string),
476 };
477 let mut without_image = image(None);
478 without_image.fill_missing(image(Some("arn:found")));
479 assert_eq!(without_image, image(Some("arn:found")));
480 let mut with_image = image(Some("arn:recorded"));
481 with_image.fill_missing(image(Some("arn:found")));
482 assert_eq!(with_image, image(Some("arn:recorded")));
483 }
484
485 #[test]
487 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
488 let persisted = serde_json::json!({
489 "type": "awsSandbox",
490 "buildRoleName": "acme-agents-build",
491 "egress": {
492 "operatorRoleName": "acme-agents-egress",
493 "securityGroupId": "sg-0123"
494 }
495 });
496 let record: SetupScaffolding =
497 serde_json::from_value(persisted.clone()).expect("persisted record reads");
498 assert_eq!(
499 record,
500 SetupScaffolding::AwsSandbox {
501 build_role_name: "acme-agents-build".to_string(),
502 egress: Some(AwsSandboxEgressScaffolding {
503 operator_role_name: "acme-agents-egress".to_string(),
504 security_group_id: Some("sg-0123".to_string()),
505 connector_arn: None,
506 connector_request: None,
507 }),
508 image_arn: None,
509 }
510 );
511 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
512 }
513}