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,
35 pub target_frozen_digest: String,
38 pub release_id: String,
40 pub setup_target: String,
42 pub setup_fingerprint: String,
44 pub setup_fingerprint_version: u32,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase")]
52pub struct CredentialRetry {
53 pub started_at: chrono::DateTime<chrono::Utc>,
55 pub status: DeploymentStatus,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[serde(rename_all = "camelCase")]
65pub struct RuntimeMetadata {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub credential_retry: Option<CredentialRetry>,
69
70 #[serde(default)]
73 pub initial_setup_authority: InitialSetupAuthority,
74
75 #[serde(default, skip_serializing_if = "super::is_false")]
78 pub automatic_setup: bool,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
83 pub last_synced_env_vars_hash: Option<String>,
84
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub last_synced_secret_names: Vec<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
95 pub prepared_stack: Option<crate::Stack>,
96
97 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
105 pub persisted_gate_answers: GateAnswers,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub pending_prepared_stack: Option<crate::Stack>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub pending_prepared_release_id: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub direct_setup_revision: Option<String>,
129
130 #[serde(default, skip_serializing_if = "is_false")]
135 pub registry_access_granted: bool,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub registry_access: Option<RegistryAccess>,
142
143 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
147
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
158#[serde(rename_all = "camelCase", tag = "type")]
159pub enum SetupScaffolding {
160 #[serde(rename_all = "camelCase")]
162 AwsSandbox {
163 build_role_name: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 egress: Option<AwsSandboxEgressScaffolding>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
171 image_arn: Option<String>,
172 },
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179#[serde(rename_all = "camelCase")]
180pub struct AwsSandboxEgressScaffolding {
181 pub operator_role_name: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub security_group_id: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub connector_arn: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub connector_request: Option<String>,
193}
194
195impl SetupScaffolding {
196 pub fn fill_missing(&mut self, found: SetupScaffolding) {
199 match (self, found) {
200 (
201 SetupScaffolding::AwsSandbox {
202 egress, image_arn, ..
203 },
204 SetupScaffolding::AwsSandbox {
205 egress: found_egress,
206 image_arn: found_image_arn,
207 ..
208 },
209 ) => {
210 if image_arn.is_none() {
211 *image_arn = found_image_arn;
212 }
213 match (egress.as_mut(), found_egress) {
214 (None, found_egress) => *egress = found_egress,
215 (Some(recorded), Some(found)) => {
216 if recorded.security_group_id.is_none() {
217 recorded.security_group_id = found.security_group_id;
218 }
219 if recorded.connector_arn.is_none() {
220 recorded.connector_arn = found.connector_arn;
221 }
222 }
223 (Some(_), None) => {}
224 }
225 }
226 }
227 }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233#[serde(rename_all = "camelCase")]
234pub struct RegistryAccess {
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub repositories: Vec<String>,
238
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub service_types: Vec<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
253#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
254#[serde(rename_all = "camelCase")]
255pub struct DeploymentState {
256 pub status: DeploymentStatus,
258 pub platform: Platform,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub current_release: Option<ReleaseInfo>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub target_release: Option<ReleaseInfo>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub stack_state: Option<StackState>,
269 #[serde(skip_serializing_if = "Option::is_none")]
273 pub error: Option<AlienError>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub environment_info: Option<EnvironmentInfo>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub runtime_metadata: Option<RuntimeMetadata>,
280 #[serde(default, skip_serializing_if = "is_false")]
283 pub retry_requested: bool,
284 pub protocol_version: u32,
289}
290
291impl DeploymentState {
292 pub fn has_desired(&self) -> bool {
295 self.current_release.is_some()
296 || self.target_release.is_some()
297 || self.stack_state.is_some()
298 }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
306#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
307#[serde(rename_all = "camelCase")]
308pub struct DeploymentStepResult {
309 pub state: DeploymentState,
311
312 #[serde(skip_serializing_if = "Option::is_none")]
316 pub suggested_delay_ms: Option<u64>,
317
318 #[serde(default, skip_serializing_if = "is_false")]
322 pub update_heartbeat: bool,
323
324 #[serde(
326 default,
327 rename = "resourceHeartbeats",
328 skip_serializing_if = "Vec::is_empty"
329 )]
330 pub heartbeats: Vec<ResourceHeartbeat>,
331
332 #[serde(
334 default,
335 rename = "observedInventoryBatches",
336 skip_serializing_if = "Vec::is_empty"
337 )]
338 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
339}
340
341pub(crate) fn is_false(b: &bool) -> bool {
342 !*b
343}
344
345pub type GateAnswers = IndexMap<String, bool>;
347
348pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
350
351pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
361
362pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::{Platform, ReleaseInfo, Stack, StackState};
369 use indexmap::IndexMap;
370
371 fn empty_stack() -> Stack {
372 Stack {
373 id: "stack_test".to_string(),
374 resources: IndexMap::new(),
375 inputs: vec![],
376 dynamic_container_repositories: Vec::new(),
377 dynamic_container_image_resources: Vec::new(),
378 operations: None,
379 permissions: crate::PermissionsConfig::default(),
380 supported_platforms: None,
381 }
382 }
383
384 fn release_info(id: &str) -> ReleaseInfo {
385 ReleaseInfo {
386 release_id: Some(id.to_string()),
387 version: None,
388 description: None,
389 stack: empty_stack(),
390 }
391 }
392
393 fn state() -> DeploymentState {
394 DeploymentState {
395 status: DeploymentStatus::Pending,
396 platform: Platform::Kubernetes,
397 current_release: None,
398 target_release: None,
399 stack_state: None,
400 error: None,
401 environment_info: None,
402 runtime_metadata: None,
403 retry_requested: false,
404 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
405 }
406 }
407
408 #[test]
409 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
410 let observe_only = state();
411 assert!(!observe_only.has_desired());
412
413 let mut current = state();
414 current.current_release = Some(release_info("rel_current"));
415 assert!(current.has_desired());
416
417 let mut target = state();
418 target.target_release = Some(release_info("rel_target"));
419 assert!(target.has_desired());
420
421 let mut imported = state();
422 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
423 assert!(imported.has_desired());
424 }
425
426 #[test]
427 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
428 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
429 "lastSyncedEnvVarsHash": "old-hash"
430 }))
431 .expect("old runtime metadata remains readable");
432
433 assert_eq!(
434 metadata.last_synced_env_vars_hash.as_deref(),
435 Some("old-hash")
436 );
437 assert!(metadata.last_synced_secret_names.is_empty());
438 assert!(metadata.pending_prepared_stack.is_none());
439 assert!(metadata.setup_update_authorization.is_none());
440 assert!(metadata.setup_scaffolding.is_empty());
441 }
442
443 #[test]
445 fn setup_scaffolding_round_trips_in_its_persisted_form() {
446 let persisted = serde_json::json!({
447 "initialSetupAuthority": "directSetup",
448 "setupScaffolding": {
449 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
450 }
451 });
452 let metadata: RuntimeMetadata =
453 serde_json::from_value(persisted.clone()).expect("persisted record reads");
454 assert_eq!(
455 metadata.setup_scaffolding["agents"],
456 SetupScaffolding::AwsSandbox {
457 build_role_name: "acme-agents-build".to_string(),
458 egress: None,
459 image_arn: None,
460 }
461 );
462 assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
463 assert!(
464 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
465 "state with no scaffolding serializes as it did before the field existed"
466 );
467 }
468
469 #[test]
470 fn filling_a_record_takes_only_what_it_lacks() {
471 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
472 operator_role_name: "acme-agents-egress".to_string(),
473 security_group_id: group.map(str::to_string),
474 connector_arn: connector.map(str::to_string),
475 connector_request: None,
476 };
477 let record = |egress| SetupScaffolding::AwsSandbox {
478 build_role_name: "acme-agents-build".to_string(),
479 egress,
480 image_arn: None,
481 };
482
483 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
484 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
485 assert_eq!(
486 partial,
487 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
488 "a recorded id is the one teardown deletes"
489 );
490
491 let mut role_only = record(None);
492 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
493 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
494
495 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
496 build_role_name: "acme-agents-build".to_string(),
497 egress: None,
498 image_arn: arn.map(str::to_string),
499 };
500 let mut without_image = image(None);
501 without_image.fill_missing(image(Some("arn:found")));
502 assert_eq!(without_image, image(Some("arn:found")));
503 let mut with_image = image(Some("arn:recorded"));
504 with_image.fill_missing(image(Some("arn:found")));
505 assert_eq!(with_image, image(Some("arn:recorded")));
506 }
507
508 #[test]
510 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
511 let persisted = serde_json::json!({
512 "type": "awsSandbox",
513 "buildRoleName": "acme-agents-build",
514 "egress": {
515 "operatorRoleName": "acme-agents-egress",
516 "securityGroupId": "sg-0123"
517 }
518 });
519 let record: SetupScaffolding =
520 serde_json::from_value(persisted.clone()).expect("persisted record reads");
521 assert_eq!(
522 record,
523 SetupScaffolding::AwsSandbox {
524 build_role_name: "acme-agents-build".to_string(),
525 egress: Some(AwsSandboxEgressScaffolding {
526 operator_role_name: "acme-agents-egress".to_string(),
527 security_group_id: Some("sg-0123".to_string()),
528 connector_arn: None,
529 connector_request: None,
530 }),
531 image_arn: None,
532 }
533 );
534 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
535 }
536}