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, Default)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase")]
54pub struct RuntimeMetadata {
55 #[serde(default)]
58 pub initial_setup_authority: InitialSetupAuthority,
59
60 #[serde(default, skip_serializing_if = "super::is_false")]
63 pub automatic_setup: bool,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
68 pub last_synced_env_vars_hash: Option<String>,
69
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub last_synced_secret_names: Vec<String>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
80 pub prepared_stack: Option<crate::Stack>,
81
82 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
90 pub persisted_gate_answers: GateAnswers,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub pending_prepared_stack: Option<crate::Stack>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub pending_prepared_release_id: Option<String>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub setup_update_authorization: Option<SetupUpdateAuthorization>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub direct_setup_revision: Option<String>,
114
115 #[serde(default, skip_serializing_if = "is_false")]
120 pub registry_access_granted: bool,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub registry_access: Option<RegistryAccess>,
127
128 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131 pub setup_scaffolding: BTreeMap<String, SetupScaffolding>,
132
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub deployer_secrets: Vec<crate::DeployerSecretReport>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
143#[serde(rename_all = "camelCase", tag = "type")]
144pub enum SetupScaffolding {
145 #[serde(rename_all = "camelCase")]
147 AwsSandbox {
148 build_role_name: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 egress: Option<AwsSandboxEgressScaffolding>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 image_arn: Option<String>,
157 },
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
164#[serde(rename_all = "camelCase")]
165pub struct AwsSandboxEgressScaffolding {
166 pub operator_role_name: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub security_group_id: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub connector_arn: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub connector_request: Option<String>,
178}
179
180impl SetupScaffolding {
181 pub fn fill_missing(&mut self, found: SetupScaffolding) {
184 match (self, found) {
185 (
186 SetupScaffolding::AwsSandbox {
187 egress, image_arn, ..
188 },
189 SetupScaffolding::AwsSandbox {
190 egress: found_egress,
191 image_arn: found_image_arn,
192 ..
193 },
194 ) => {
195 if image_arn.is_none() {
196 *image_arn = found_image_arn;
197 }
198 match (egress.as_mut(), found_egress) {
199 (None, found_egress) => *egress = found_egress,
200 (Some(recorded), Some(found)) => {
201 if recorded.security_group_id.is_none() {
202 recorded.security_group_id = found.security_group_id;
203 }
204 if recorded.connector_arn.is_none() {
205 recorded.connector_arn = found.connector_arn;
206 }
207 }
208 (Some(_), None) => {}
209 }
210 }
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218#[serde(rename_all = "camelCase")]
219pub struct RegistryAccess {
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub repositories: Vec<String>,
223
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub service_types: Vec<String>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239#[serde(rename_all = "camelCase")]
240pub struct DeploymentState {
241 pub status: DeploymentStatus,
243 pub platform: Platform,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub current_release: Option<ReleaseInfo>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub target_release: Option<ReleaseInfo>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub stack_state: Option<StackState>,
254 #[serde(skip_serializing_if = "Option::is_none")]
258 pub error: Option<AlienError>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub environment_info: Option<EnvironmentInfo>,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub runtime_metadata: Option<RuntimeMetadata>,
265 #[serde(default, skip_serializing_if = "is_false")]
268 pub retry_requested: bool,
269 pub protocol_version: u32,
274}
275
276impl DeploymentState {
277 pub fn has_desired(&self) -> bool {
280 self.current_release.is_some()
281 || self.target_release.is_some()
282 || self.stack_state.is_some()
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292#[serde(rename_all = "camelCase")]
293pub struct DeploymentStepResult {
294 pub state: DeploymentState,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
301 pub suggested_delay_ms: Option<u64>,
302
303 #[serde(default, skip_serializing_if = "is_false")]
307 pub update_heartbeat: bool,
308
309 #[serde(
311 default,
312 rename = "resourceHeartbeats",
313 skip_serializing_if = "Vec::is_empty"
314 )]
315 pub heartbeats: Vec<ResourceHeartbeat>,
316
317 #[serde(
319 default,
320 rename = "observedInventoryBatches",
321 skip_serializing_if = "Vec::is_empty"
322 )]
323 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
324}
325
326pub(crate) fn is_false(b: &bool) -> bool {
327 !*b
328}
329
330pub type GateAnswers = IndexMap<String, bool>;
332
333pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
335
336pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;
346
347pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::{Platform, ReleaseInfo, Stack, StackState};
354 use indexmap::IndexMap;
355
356 fn empty_stack() -> Stack {
357 Stack {
358 id: "stack_test".to_string(),
359 resources: IndexMap::new(),
360 inputs: vec![],
361 dynamic_container_repositories: Vec::new(),
362 dynamic_container_image_resources: Vec::new(),
363 operations: None,
364 permissions: crate::PermissionsConfig::default(),
365 supported_platforms: None,
366 }
367 }
368
369 fn release_info(id: &str) -> ReleaseInfo {
370 ReleaseInfo {
371 release_id: Some(id.to_string()),
372 version: None,
373 description: None,
374 stack: empty_stack(),
375 }
376 }
377
378 fn state() -> DeploymentState {
379 DeploymentState {
380 status: DeploymentStatus::Pending,
381 platform: Platform::Kubernetes,
382 current_release: None,
383 target_release: None,
384 stack_state: None,
385 error: None,
386 environment_info: None,
387 runtime_metadata: None,
388 retry_requested: false,
389 protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
390 }
391 }
392
393 #[test]
394 fn deployment_state_has_desired_when_release_or_stack_state_exists() {
395 let observe_only = state();
396 assert!(!observe_only.has_desired());
397
398 let mut current = state();
399 current.current_release = Some(release_info("rel_current"));
400 assert!(current.has_desired());
401
402 let mut target = state();
403 target.target_release = Some(release_info("rel_target"));
404 assert!(target.has_desired());
405
406 let mut imported = state();
407 imported.stack_state = Some(StackState::new(Platform::Kubernetes));
408 assert!(imported.has_desired());
409 }
410
411 #[test]
412 fn runtime_metadata_from_before_secret_inventory_defaults_to_empty() {
413 let metadata: RuntimeMetadata = serde_json::from_value(serde_json::json!({
414 "lastSyncedEnvVarsHash": "old-hash"
415 }))
416 .expect("old runtime metadata remains readable");
417
418 assert_eq!(
419 metadata.last_synced_env_vars_hash.as_deref(),
420 Some("old-hash")
421 );
422 assert!(metadata.last_synced_secret_names.is_empty());
423 assert!(metadata.pending_prepared_stack.is_none());
424 assert!(metadata.setup_update_authorization.is_none());
425 assert!(metadata.setup_scaffolding.is_empty());
426 }
427
428 #[test]
430 fn setup_scaffolding_round_trips_in_its_persisted_form() {
431 let persisted = serde_json::json!({
432 "initialSetupAuthority": "directSetup",
433 "setupScaffolding": {
434 "agents": { "type": "awsSandbox", "buildRoleName": "acme-agents-build" }
435 }
436 });
437 let metadata: RuntimeMetadata =
438 serde_json::from_value(persisted.clone()).expect("persisted record reads");
439 assert_eq!(
440 metadata.setup_scaffolding["agents"],
441 SetupScaffolding::AwsSandbox {
442 build_role_name: "acme-agents-build".to_string(),
443 egress: None,
444 image_arn: None,
445 }
446 );
447 assert_eq!(serde_json::to_value(&metadata).unwrap(), persisted);
448 assert!(
449 serde_json::to_value(RuntimeMetadata::default()).unwrap()["setupScaffolding"].is_null(),
450 "state with no scaffolding serializes as it did before the field existed"
451 );
452 }
453
454 #[test]
455 fn filling_a_record_takes_only_what_it_lacks() {
456 let egress = |group: Option<&str>, connector: Option<&str>| AwsSandboxEgressScaffolding {
457 operator_role_name: "acme-agents-egress".to_string(),
458 security_group_id: group.map(str::to_string),
459 connector_arn: connector.map(str::to_string),
460 connector_request: None,
461 };
462 let record = |egress| SetupScaffolding::AwsSandbox {
463 build_role_name: "acme-agents-build".to_string(),
464 egress,
465 image_arn: None,
466 };
467
468 let mut partial = record(Some(egress(Some("sg-recorded"), None)));
469 partial.fill_missing(record(Some(egress(Some("sg-found"), Some("arn:found")))));
470 assert_eq!(
471 partial,
472 record(Some(egress(Some("sg-recorded"), Some("arn:found")))),
473 "a recorded id is the one teardown deletes"
474 );
475
476 let mut role_only = record(None);
477 role_only.fill_missing(record(Some(egress(Some("sg-found"), None))));
478 assert_eq!(role_only, record(Some(egress(Some("sg-found"), None))));
479
480 let image = |arn: Option<&str>| SetupScaffolding::AwsSandbox {
481 build_role_name: "acme-agents-build".to_string(),
482 egress: None,
483 image_arn: arn.map(str::to_string),
484 };
485 let mut without_image = image(None);
486 without_image.fill_missing(image(Some("arn:found")));
487 assert_eq!(without_image, image(Some("arn:found")));
488 let mut with_image = image(Some("arn:recorded"));
489 with_image.fill_missing(image(Some("arn:found")));
490 assert_eq!(with_image, image(Some("arn:recorded")));
491 }
492
493 #[test]
495 fn a_partial_egress_record_round_trips_in_its_persisted_form() {
496 let persisted = serde_json::json!({
497 "type": "awsSandbox",
498 "buildRoleName": "acme-agents-build",
499 "egress": {
500 "operatorRoleName": "acme-agents-egress",
501 "securityGroupId": "sg-0123"
502 }
503 });
504 let record: SetupScaffolding =
505 serde_json::from_value(persisted.clone()).expect("persisted record reads");
506 assert_eq!(
507 record,
508 SetupScaffolding::AwsSandbox {
509 build_role_name: "acme-agents-build".to_string(),
510 egress: Some(AwsSandboxEgressScaffolding {
511 operator_role_name: "acme-agents-egress".to_string(),
512 security_group_id: Some("sg-0123".to_string()),
513 connector_arn: None,
514 connector_request: None,
515 }),
516 image_arn: None,
517 }
518 );
519 assert_eq!(serde_json::to_value(&record).unwrap(), persisted);
520 }
521}