1use crate::permissions::{ManagementPermissions, PermissionProfile, PermissionsConfig};
2use crate::{Platform, Resource, ResourceLifecycle, ResourceRef, StackInputDefinition};
3use bon::Builder;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10#[serde(rename_all = "camelCase")]
11pub struct ResourceEntry {
12 pub config: Resource,
14 pub lifecycle: ResourceLifecycle,
16 pub dependencies: Vec<ResourceRef>,
19 #[serde(default)]
23 pub remote_access: bool,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub enabled_when: Option<String>,
32}
33
34impl ResourceEntry {
35 pub fn combined_dependencies(&self) -> Vec<ResourceRef> {
37 let mut dependencies = self.config.get_dependencies();
38 dependencies.extend(self.dependencies.clone());
39 dependencies
40 }
41
42 pub fn has_remote_bindings(&self) -> bool {
48 crate::remote_bindings::remote_binding_for_entry(self).is_some()
49 }
50
51 pub fn publishes_binding_params(&self) -> bool {
55 self.remote_access
56 || self
57 .config
58 .downcast_ref::<crate::Vault>()
59 .is_some_and(|vault| vault.id == "secrets")
60 }
61}
62
63#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(rename_all = "camelCase")]
67#[builder(start_fn = new)]
68pub struct Stack {
69 #[builder(start_fn)]
71 pub id: String,
72 #[builder(field)]
74 pub resources: IndexMap<String, ResourceEntry>,
75 #[builder(field)]
77 #[serde(default)]
78 pub permissions: PermissionsConfig,
79 #[builder(field)]
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub supported_platforms: Option<Vec<Platform>>,
83 #[builder(field)]
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub inputs: Vec<StackInputDefinition>,
87 #[builder(field)]
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub dynamic_container_repositories: Vec<String>,
92 #[builder(field)]
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub dynamic_container_image_resources: Vec<String>,
97 #[builder(field)]
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub operations: Option<crate::OperationsConfig>,
102}
103
104impl Stack {
105 pub fn setup_owned_digest(&self) -> String {
109 let mut resources = self
110 .resources
111 .iter()
112 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
113 .map(|(id, entry)| {
114 let mut value =
115 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
116 canonicalize_json(&mut value);
117 (id, value)
118 })
119 .collect::<Vec<_>>();
120 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
121
122 let mut live_sandbox_inputs = self
123 .resources
124 .iter()
125 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Live)
126 .filter_map(|(id, entry)| {
127 let sandbox = entry.config.downcast_ref::<crate::Sandbox>()?;
128 let mut inputs = crate::sandbox_setup_inputs::comparable_aws_sandbox_setup_inputs(
129 self,
130 sandbox,
131 entry.lifecycle,
132 );
133 for (_, value) in &mut inputs {
134 canonicalize_json(value);
135 }
136 Some((id, inputs))
137 })
138 .collect::<Vec<_>>();
139 live_sandbox_inputs.sort_unstable_by_key(|(id, _)| *id);
140
141 let encoded = if live_sandbox_inputs.is_empty() {
142 serde_json::to_vec(&resources)
143 } else {
144 serde_json::to_vec(&(&resources, &live_sandbox_inputs))
145 }
146 .expect("canonical setup-owned projection always serializes");
147 format!("{:x}", Sha256::digest(encoded))
148 }
149
150 pub fn setup_update_digest(&self) -> String {
153 let mut permissions = serde_json::to_value(&self.permissions)
154 .expect("permission profiles always serialize to JSON");
155 canonicalize_json(&mut permissions);
156 let encoded = serde_json::to_vec(&(self.setup_owned_digest(), permissions))
157 .expect("setup update projection always serializes");
158 format!("{:x}", Sha256::digest(encoded))
159 }
160
161 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
163 self.resources.iter()
164 }
165
166 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
168 self.resources.iter_mut()
169 }
170
171 pub fn id(&self) -> &str {
172 &self.id
173 }
174
175 pub fn current() -> StackRef {
177 StackRef::Current
178 }
179
180 pub fn permissions(&self) -> &PermissionsConfig {
182 &self.permissions
183 }
184
185 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
187 &self.permissions.profiles
188 }
189
190 pub fn management(&self) -> &ManagementPermissions {
192 &self.permissions.management
193 }
194
195 pub fn supported_platforms(&self) -> Option<&[Platform]> {
197 self.supported_platforms.as_deref()
198 }
199
200 pub fn inputs(&self) -> &[StackInputDefinition] {
202 &self.inputs
203 }
204
205 pub fn supports_platform(&self, platform: &Platform) -> bool {
208 match &self.supported_platforms {
209 Some(platforms) => platforms.contains(platform),
210 None => true,
211 }
212 }
213}
214
215fn canonicalize_json(value: &mut serde_json::Value) {
216 match value {
217 serde_json::Value::Array(values) => {
218 for value in values {
219 canonicalize_json(value);
220 }
221 }
222 serde_json::Value::Object(object) => {
223 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
224 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
225 for (key, mut value) in entries {
226 canonicalize_json(&mut value);
227 object.insert(key, value);
228 }
229 }
230 _ => {}
231 }
232}
233
234impl StackBuilder {
235 pub fn add<T: crate::ResourceDefinition>(
239 self,
240 resource: T,
241 lifecycle: ResourceLifecycle,
242 ) -> Self {
243 self.add_with_dependencies(resource, lifecycle, vec![])
244 }
245
246 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
249 self,
250 resource: T,
251 lifecycle: ResourceLifecycle,
252 additional_dependencies: Vec<ResourceRef>,
253 ) -> Self {
254 let mut entry = Self::entry(resource, lifecycle);
255 entry.dependencies = additional_dependencies;
256 self.insert(entry)
257 }
258
259 #[doc(hidden)]
266 pub fn add_enabled_when<T: crate::ResourceDefinition>(
267 self,
268 resource: T,
269 lifecycle: ResourceLifecycle,
270 input_id: impl Into<String>,
271 ) -> Self {
272 let mut entry = Self::entry(resource, lifecycle);
273 entry.enabled_when = Some(input_id.into());
274 self.insert(entry)
275 }
276
277 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
280 self,
281 resource: T,
282 lifecycle: ResourceLifecycle,
283 ) -> Self {
284 let mut entry = Self::entry(resource, lifecycle);
285 entry.remote_access = true;
286 self.insert(entry)
287 }
288
289 fn entry<T: crate::ResourceDefinition>(
293 resource: T,
294 lifecycle: ResourceLifecycle,
295 ) -> ResourceEntry {
296 ResourceEntry {
297 config: Resource::new(resource),
298 lifecycle,
299 dependencies: Vec::new(),
300 remote_access: false,
301 enabled_when: None,
302 }
303 }
304
305 fn insert(mut self, entry: ResourceEntry) -> Self {
306 self.resources.insert(entry.config.id().to_string(), entry);
307 self
308 }
309
310 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
313 self.permissions = permissions;
314 self
315 }
316
317 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
329 self.permissions.profiles.insert(name.into(), profile);
330 self
331 }
332
333 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
335 self.supported_platforms = Some(platforms);
336 self
337 }
338
339 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
341 self.inputs = inputs;
342 self
343 }
344
345 pub fn operations(mut self, operations: crate::OperationsConfig) -> Self {
347 self.operations = Some(operations);
348 self
349 }
350
351 pub fn management(mut self, management: ManagementPermissions) -> Self {
377 self.permissions.management = management;
378 self
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
385#[serde(rename_all = "camelCase")]
386pub enum StackRef {
387 Current,
389 External(String),
391}
392
393impl StackRef {
394 pub fn from_stack(stack: &Stack) -> Self {
396 StackRef::External(stack.id().to_string())
397 }
398}
399
400impl From<&Stack> for StackRef {
401 fn from(stack: &Stack) -> Self {
402 StackRef::External(stack.id().to_string())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::resource::ResourceLifecycle;
410 use crate::{
411 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
412 Storage, Worker, WorkerCode,
413 };
414 use insta::assert_json_snapshot;
415
416 fn resource_entry<T: crate::ResourceDefinition>(
417 resource: T,
418 lifecycle: ResourceLifecycle,
419 remote_access: bool,
420 ) -> ResourceEntry {
421 ResourceEntry {
422 config: Resource::new(resource),
423 lifecycle,
424 dependencies: Vec::new(),
425 remote_access,
426 enabled_when: None,
427 }
428 }
429
430 #[test]
433 fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
434 assert!(resource_entry(
435 Storage::new("archive".to_string()).build(),
436 ResourceLifecycle::Frozen,
437 true,
438 )
439 .has_remote_bindings());
440 assert!(!resource_entry(
441 Storage::new("archive".to_string()).build(),
442 ResourceLifecycle::Frozen,
443 false,
444 )
445 .has_remote_bindings());
446 assert!(!resource_entry(
447 Storage::new("archive".to_string()).build(),
448 ResourceLifecycle::Live,
449 true,
450 )
451 .has_remote_bindings());
452 let sandbox = crate::Sandbox::new("agents".to_string())
453 .code(crate::SandboxCode::Image {
454 image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
455 })
456 .egress(crate::SandboxEgress::Allow)
457 .lifecycle(crate::SandboxLifecyclePolicy {
458 max_lifetime_seconds: None,
459 idle_pause_seconds: None,
460 })
461 .build();
462 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
463 assert!(!resource_entry(
464 Worker::new("worker".to_string())
465 .code(WorkerCode::Image {
466 image: "example.com/worker:latest".to_string(),
467 })
468 .permissions("worker-execution".to_string())
469 .build(),
470 ResourceLifecycle::Frozen,
471 true,
472 )
473 .has_remote_bindings());
474 }
475
476 #[test]
477 fn test_stack_serialization() {
478 use crate::WorkerCode;
479
480 let storage = Storage::new("my-bucket".to_string())
481 .public_read(true)
482 .build();
483
484 let worker = Worker::new("my-worker".to_string())
485 .code(WorkerCode::Image {
486 image: "rust:latest".to_string(),
487 })
488 .permissions("execution".to_string())
489 .link(&storage)
490 .build();
491
492 let mut permissions = IndexMap::new();
494 let mut execution_profile = PermissionProfile::new();
495 execution_profile.0.insert(
496 "*".to_string(),
497 vec![
498 PermissionSetReference::from_name("storage/data-read"),
499 PermissionSetReference::from_name("storage/data-write"),
500 ],
501 );
502 permissions.insert("execution".to_string(), execution_profile);
503
504 let stack_builder = Stack::new("test-stack".to_string())
505 .add(storage, ResourceLifecycle::Frozen)
506 .add(worker.clone(), ResourceLifecycle::Live);
507
508 let stack = stack_builder
509 .permissions(PermissionsConfig {
510 profiles: permissions,
511 management: ManagementPermissions::Auto,
512 })
513 .build();
514
515 let serialized_stack =
517 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
518 let deserialized_stack: Stack =
519 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
520
521 assert_eq!(
523 stack, deserialized_stack,
524 "Original and deserialized stacks do not match."
525 );
526
527 let mut settings = insta::Settings::clone_current();
529 settings.set_sort_maps(true);
530 settings.bind(|| {
531 assert_json_snapshot!("stack_serialization_account_managed", stack);
532 });
533 }
534
535 #[test]
536 fn test_empty_stack_serialization() {
537 let stack_builder = Stack::new("empty-test-stack".to_string());
538
539 let stack = stack_builder
540 .permissions(PermissionsConfig::new()) .build();
542
543 let serialized_stack =
545 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
546 let deserialized_stack: Stack =
547 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
548
549 assert_eq!(
551 stack, deserialized_stack,
552 "Original and deserialized empty stacks do not match."
553 );
554
555 let mut settings = insta::Settings::clone_current();
557 settings.set_sort_maps(true);
558 settings.bind(|| {
559 assert_json_snapshot!("empty_stack_serialization_account", stack);
560 });
561 }
562
563 #[test]
564 fn stack_deserializes_resources_without_public_endpoints() {
565 let container = Container::new("api".to_string())
566 .code(ContainerCode::Image {
567 image: "example.com/api:latest".to_string(),
568 })
569 .cpu(ResourceSpec {
570 min: "0.5".to_string(),
571 desired: "1".to_string(),
572 })
573 .memory(ResourceSpec {
574 min: "512Mi".to_string(),
575 desired: "1Gi".to_string(),
576 })
577 .port(8080)
578 .permissions("container-execution".to_string())
579 .build();
580 let daemon = Daemon::new("agent".to_string())
581 .code(DaemonCode::Image {
582 image: "example.com/agent:latest".to_string(),
583 })
584 .permissions("daemon-execution".to_string())
585 .build();
586 let worker = Worker::new("worker".to_string())
587 .code(WorkerCode::Image {
588 image: "example.com/worker:latest".to_string(),
589 })
590 .permissions("worker-execution".to_string())
591 .build();
592 let stack = Stack::new("legacy-stack".to_string())
593 .add(container, ResourceLifecycle::Live)
594 .add(daemon, ResourceLifecycle::Live)
595 .add(worker, ResourceLifecycle::Live)
596 .build();
597
598 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
599 for resource_id in ["api", "agent", "worker"] {
600 legacy_json
601 .pointer_mut(&format!("/resources/{resource_id}/config"))
602 .and_then(serde_json::Value::as_object_mut)
603 .expect("resource config should be an object")
604 .remove("publicEndpoints");
605 }
606
607 let stack: Stack =
608 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
609
610 let container = stack
611 .resources
612 .get("api")
613 .and_then(|entry| entry.config.downcast_ref::<Container>())
614 .expect("api should be a container");
615 assert!(container.public_endpoints.is_empty());
616
617 let daemon = stack
618 .resources
619 .get("agent")
620 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
621 .expect("agent should be a daemon");
622 assert!(daemon.public_endpoints.is_empty());
623
624 let worker = stack
625 .resources
626 .get("worker")
627 .and_then(|entry| entry.config.downcast_ref::<Worker>())
628 .expect("worker should be a worker");
629 assert!(worker.public_endpoints.is_empty());
630 }
631
632 #[test]
633 fn test_stack_with_permissions() {
634 use crate::permissions::PermissionProfile;
635 use indexmap::IndexMap;
636
637 let storage = Storage::new("test-storage".to_string()).build();
639
640 let mut permission_profile = PermissionProfile::new();
642 permission_profile.0.insert(
643 "*".to_string(),
644 vec![PermissionSetReference::from_name("storage/data-read")],
645 );
646
647 let mut permissions = IndexMap::new();
648 permissions.insert("reader".to_string(), permission_profile);
649
650 let stack = Stack::new("test-permissions-stack".to_string())
651 .add(storage, ResourceLifecycle::Frozen)
652 .permissions(PermissionsConfig {
653 profiles: permissions,
654 management: ManagementPermissions::Auto,
655 })
656 .build();
657
658 assert_eq!(stack.permission_profiles().len(), 1);
660 assert!(stack.permission_profiles().contains_key("reader"));
661
662 let reader_profile = stack.permission_profiles().get("reader").unwrap();
663 assert_eq!(reader_profile.0.len(), 1);
664 assert!(reader_profile.0.contains_key("*"));
665
666 let global_permissions = reader_profile.0.get("*").unwrap();
667 assert_eq!(
668 global_permissions,
669 &vec![PermissionSetReference::from_name("storage/data-read")]
670 );
671
672 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
674 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
675 assert_eq!(stack, deserialized);
676 }
677
678 #[test]
679 fn test_stack_with_management_permissions() {
680 use crate::permissions::{ManagementPermissions, PermissionProfile};
681
682 let storage = Storage::new("test-storage".to_string()).build();
684
685 let mut management_profile = PermissionProfile::new();
687 management_profile.0.insert(
688 "*".to_string(),
689 vec![PermissionSetReference::from_name("vault/data-write")],
690 );
691
692 let stack_auto = Stack::new("test-auto-management-stack".to_string())
694 .add(storage.clone(), ResourceLifecycle::Frozen)
695 .management(ManagementPermissions::auto())
696 .build();
697
698 assert!(stack_auto.management().is_auto());
699 assert!(stack_auto.management().profile().is_none());
700
701 let stack_extend = Stack::new("test-extend-management-stack".to_string())
703 .add(storage.clone(), ResourceLifecycle::Frozen)
704 .management(ManagementPermissions::extend(management_profile.clone()))
705 .build();
706
707 assert!(stack_extend.management().is_extend());
708 assert_eq!(
709 stack_extend.management().profile().unwrap(),
710 &management_profile
711 );
712
713 let stack_override = Stack::new("test-override-management-stack".to_string())
715 .add(storage.clone(), ResourceLifecycle::Frozen)
716 .management(ManagementPermissions::override_(management_profile.clone()))
717 .build();
718
719 assert!(stack_override.management().is_override());
720 assert_eq!(
721 stack_override.management().profile().unwrap(),
722 &management_profile
723 );
724
725 let stack_default = Stack::new("test-default-management-stack".to_string())
727 .add(storage, ResourceLifecycle::Frozen)
728 .build();
729
730 assert!(stack_default.management().is_auto());
731
732 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
734 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
735 assert_eq!(stack_extend, deserialized);
736 }
737
738 fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
739 crate::Sandbox::new(id.to_string())
740 .code(crate::SandboxCode::Image {
741 image: image.to_string(),
742 })
743 .maybe_private_base_image(private_base_image.map(str::to_string))
744 .egress(crate::SandboxEgress::Deny)
745 .lifecycle(crate::SandboxLifecyclePolicy {
746 max_lifetime_seconds: None,
747 idle_pause_seconds: None,
748 })
749 .build()
750 }
751
752 #[test]
755 fn setup_owned_digest_is_stable_without_a_live_sandbox() {
756 let stack = Stack::new("golden".to_string())
757 .add(
758 Storage::new("ledger".to_string()).build(),
759 ResourceLifecycle::Frozen,
760 )
761 .add(
762 digest_sandbox(
763 "frozen-agents",
764 "s3://bucket/frozen.zip",
765 Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
766 ),
767 ResourceLifecycle::Frozen,
768 )
769 .add(
770 Storage::new("scratch".to_string()).build(),
771 ResourceLifecycle::Live,
772 )
773 .build();
774
775 assert_eq!(
776 stack.setup_owned_digest(),
777 "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
778 );
779 }
780
781 struct LiveSandbox {
782 image: &'static str,
783 private_base_image: Option<&'static str>,
784 egress: crate::SandboxEgress,
785 network_id: &'static str,
786 remote_access: bool,
787 }
788
789 const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
790
791 impl LiveSandbox {
792 fn installed() -> Self {
793 Self {
794 image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
795 private_base_image: Some(BASE_A),
796 egress: crate::SandboxEgress::Allow,
797 network_id: "net-a",
798 remote_access: false,
799 }
800 }
801
802 fn digest(self) -> String {
803 let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
804 sandbox.egress = self.egress;
805 let mut stack = Stack::new("stack".to_string())
806 .add(
807 crate::Network::new(self.network_id.to_string())
808 .settings(crate::NetworkSettings::Create {
809 cidr: Some("10.0.0.0/16".to_string()),
810 availability_zones: 2,
811 })
812 .build(),
813 ResourceLifecycle::Frozen,
814 )
815 .add(sandbox, ResourceLifecycle::Live)
816 .build();
817 stack
818 .resources
819 .get_mut("agents")
820 .expect("sandbox")
821 .remote_access = self.remote_access;
822 let entry = &stack.resources["agents"];
823 crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
824 &stack,
825 entry.config.downcast_ref().expect("sandbox"),
826 entry.lifecycle,
827 crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
828 )
829 .expect("the inputs resolve, so no case is compared by its whole configuration");
830 stack.setup_owned_digest()
831 }
832 }
833
834 #[test]
835 fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
836 let installed = LiveSandbox::installed().digest();
837
838 for (unchanged, why) in [
839 (
840 LiveSandbox {
841 image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
842 ..LiveSandbox::installed()
843 },
844 "a new bundle under the same prefix",
845 ),
846 (
847 LiveSandbox {
848 private_base_image: Some(
849 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
850 ),
851 ..LiveSandbox::installed()
852 },
853 "a new tag",
854 ),
855 (
856 LiveSandbox {
857 private_base_image: Some(
858 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
859 ),
860 ..LiveSandbox::installed()
861 },
862 "a digest",
863 ),
864 ] {
865 assert_eq!(
866 installed,
867 unchanged.digest(),
868 "{why} is the runtime's to roll"
869 );
870 }
871
872 for (changed, what) in [
873 (
874 LiveSandbox {
875 private_base_image: None,
876 ..LiveSandbox::installed()
877 },
878 "a dropped private base",
879 ),
880 (
881 LiveSandbox {
882 private_base_image: Some(
883 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
884 ),
885 ..LiveSandbox::installed()
886 },
887 "another repository",
888 ),
889 (
890 LiveSandbox {
891 private_base_image: Some(
892 "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
893 ),
894 ..LiveSandbox::installed()
895 },
896 "another account",
897 ),
898 (
899 LiveSandbox {
900 private_base_image: Some(
901 "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
902 ),
903 ..LiveSandbox::installed()
904 },
905 "the deployment's region",
906 ),
907 (
908 LiveSandbox {
909 egress: crate::SandboxEgress::Deny,
910 ..LiveSandbox::installed()
911 },
912 "egress allow to deny",
913 ),
914 (
915 LiveSandbox {
916 image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
917 ..LiveSandbox::installed()
918 },
919 "another bundle bucket",
920 ),
921 (
922 LiveSandbox {
923 image: "s3://bucket/other-prefix/v1/bundle.zip",
924 ..LiveSandbox::installed()
925 },
926 "another bundle prefix",
927 ),
928 (
929 LiveSandbox {
930 remote_access: true,
931 ..LiveSandbox::installed()
932 },
933 "a remote grant",
934 ),
935 ] {
936 assert_ne!(installed, changed.digest(), "{what} is setup-owned");
937 }
938
939 let deny = |network_id| {
940 LiveSandbox {
941 egress: crate::SandboxEgress::Deny,
942 network_id,
943 ..LiveSandbox::installed()
944 }
945 .digest()
946 };
947 assert_ne!(
948 deny("net-a"),
949 deny("net-b"),
950 "the connector's network is setup-owned"
951 );
952 }
953
954 #[test]
955 fn setup_owned_digest_is_order_independent() {
956 let first = Stack::new("first".to_string())
957 .add(
958 Storage::new("alpha".to_string()).build(),
959 ResourceLifecycle::Frozen,
960 )
961 .add(
962 Storage::new("beta".to_string()).build(),
963 ResourceLifecycle::Frozen,
964 )
965 .add(
966 Storage::new("live-one".to_string()).build(),
967 ResourceLifecycle::Live,
968 )
969 .build();
970 let second = Stack::new("second".to_string())
971 .add(
972 Storage::new("beta".to_string()).build(),
973 ResourceLifecycle::Frozen,
974 )
975 .add(
976 Storage::new("alpha".to_string()).build(),
977 ResourceLifecycle::Frozen,
978 )
979 .add(
980 Storage::new("live-two".to_string()).build(),
981 ResourceLifecycle::Live,
982 )
983 .build();
984
985 assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
986 }
987}