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 resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
152 self.resources.iter()
153 }
154
155 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
157 self.resources.iter_mut()
158 }
159
160 pub fn id(&self) -> &str {
161 &self.id
162 }
163
164 pub fn current() -> StackRef {
166 StackRef::Current
167 }
168
169 pub fn permissions(&self) -> &PermissionsConfig {
171 &self.permissions
172 }
173
174 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
176 &self.permissions.profiles
177 }
178
179 pub fn management(&self) -> &ManagementPermissions {
181 &self.permissions.management
182 }
183
184 pub fn supported_platforms(&self) -> Option<&[Platform]> {
186 self.supported_platforms.as_deref()
187 }
188
189 pub fn inputs(&self) -> &[StackInputDefinition] {
191 &self.inputs
192 }
193
194 pub fn supports_platform(&self, platform: &Platform) -> bool {
197 match &self.supported_platforms {
198 Some(platforms) => platforms.contains(platform),
199 None => true,
200 }
201 }
202}
203
204fn canonicalize_json(value: &mut serde_json::Value) {
205 match value {
206 serde_json::Value::Array(values) => {
207 for value in values {
208 canonicalize_json(value);
209 }
210 }
211 serde_json::Value::Object(object) => {
212 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
213 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
214 for (key, mut value) in entries {
215 canonicalize_json(&mut value);
216 object.insert(key, value);
217 }
218 }
219 _ => {}
220 }
221}
222
223impl StackBuilder {
224 pub fn add<T: crate::ResourceDefinition>(
228 self,
229 resource: T,
230 lifecycle: ResourceLifecycle,
231 ) -> Self {
232 self.add_with_dependencies(resource, lifecycle, vec![])
233 }
234
235 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
238 self,
239 resource: T,
240 lifecycle: ResourceLifecycle,
241 additional_dependencies: Vec<ResourceRef>,
242 ) -> Self {
243 let mut entry = Self::entry(resource, lifecycle);
244 entry.dependencies = additional_dependencies;
245 self.insert(entry)
246 }
247
248 #[doc(hidden)]
255 pub fn add_enabled_when<T: crate::ResourceDefinition>(
256 self,
257 resource: T,
258 lifecycle: ResourceLifecycle,
259 input_id: impl Into<String>,
260 ) -> Self {
261 let mut entry = Self::entry(resource, lifecycle);
262 entry.enabled_when = Some(input_id.into());
263 self.insert(entry)
264 }
265
266 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
269 self,
270 resource: T,
271 lifecycle: ResourceLifecycle,
272 ) -> Self {
273 let mut entry = Self::entry(resource, lifecycle);
274 entry.remote_access = true;
275 self.insert(entry)
276 }
277
278 fn entry<T: crate::ResourceDefinition>(
282 resource: T,
283 lifecycle: ResourceLifecycle,
284 ) -> ResourceEntry {
285 ResourceEntry {
286 config: Resource::new(resource),
287 lifecycle,
288 dependencies: Vec::new(),
289 remote_access: false,
290 enabled_when: None,
291 }
292 }
293
294 fn insert(mut self, entry: ResourceEntry) -> Self {
295 self.resources.insert(entry.config.id().to_string(), entry);
296 self
297 }
298
299 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
302 self.permissions = permissions;
303 self
304 }
305
306 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
318 self.permissions.profiles.insert(name.into(), profile);
319 self
320 }
321
322 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
324 self.supported_platforms = Some(platforms);
325 self
326 }
327
328 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
330 self.inputs = inputs;
331 self
332 }
333
334 pub fn operations(mut self, operations: crate::OperationsConfig) -> Self {
336 self.operations = Some(operations);
337 self
338 }
339
340 pub fn management(mut self, management: ManagementPermissions) -> Self {
366 self.permissions.management = management;
367 self
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
374#[serde(rename_all = "camelCase")]
375pub enum StackRef {
376 Current,
378 External(String),
380}
381
382impl StackRef {
383 pub fn from_stack(stack: &Stack) -> Self {
385 StackRef::External(stack.id().to_string())
386 }
387}
388
389impl From<&Stack> for StackRef {
390 fn from(stack: &Stack) -> Self {
391 StackRef::External(stack.id().to_string())
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::resource::ResourceLifecycle;
399 use crate::{
400 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
401 Storage, Worker, WorkerCode,
402 };
403 use insta::assert_json_snapshot;
404
405 fn resource_entry<T: crate::ResourceDefinition>(
406 resource: T,
407 lifecycle: ResourceLifecycle,
408 remote_access: bool,
409 ) -> ResourceEntry {
410 ResourceEntry {
411 config: Resource::new(resource),
412 lifecycle,
413 dependencies: Vec::new(),
414 remote_access,
415 enabled_when: None,
416 }
417 }
418
419 #[test]
422 fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
423 assert!(resource_entry(
424 Storage::new("archive".to_string()).build(),
425 ResourceLifecycle::Frozen,
426 true,
427 )
428 .has_remote_bindings());
429 assert!(!resource_entry(
430 Storage::new("archive".to_string()).build(),
431 ResourceLifecycle::Frozen,
432 false,
433 )
434 .has_remote_bindings());
435 assert!(!resource_entry(
436 Storage::new("archive".to_string()).build(),
437 ResourceLifecycle::Live,
438 true,
439 )
440 .has_remote_bindings());
441 let sandbox = crate::Sandbox::new("agents".to_string())
442 .code(crate::SandboxCode::Image {
443 image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
444 })
445 .egress(crate::SandboxEgress::Allow)
446 .lifecycle(crate::SandboxLifecyclePolicy {
447 max_lifetime_seconds: None,
448 idle_pause_seconds: None,
449 })
450 .build();
451 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
452 assert!(!resource_entry(
453 Worker::new("worker".to_string())
454 .code(WorkerCode::Image {
455 image: "example.com/worker:latest".to_string(),
456 })
457 .permissions("worker-execution".to_string())
458 .build(),
459 ResourceLifecycle::Frozen,
460 true,
461 )
462 .has_remote_bindings());
463 }
464
465 #[test]
466 fn test_stack_serialization() {
467 use crate::WorkerCode;
468
469 let storage = Storage::new("my-bucket".to_string())
470 .public_read(true)
471 .build();
472
473 let worker = Worker::new("my-worker".to_string())
474 .code(WorkerCode::Image {
475 image: "rust:latest".to_string(),
476 })
477 .permissions("execution".to_string())
478 .link(&storage)
479 .build();
480
481 let mut permissions = IndexMap::new();
483 let mut execution_profile = PermissionProfile::new();
484 execution_profile.0.insert(
485 "*".to_string(),
486 vec![
487 PermissionSetReference::from_name("storage/data-read"),
488 PermissionSetReference::from_name("storage/data-write"),
489 ],
490 );
491 permissions.insert("execution".to_string(), execution_profile);
492
493 let stack_builder = Stack::new("test-stack".to_string())
494 .add(storage, ResourceLifecycle::Frozen)
495 .add(worker.clone(), ResourceLifecycle::Live);
496
497 let stack = stack_builder
498 .permissions(PermissionsConfig {
499 profiles: permissions,
500 management: ManagementPermissions::Auto,
501 })
502 .build();
503
504 let serialized_stack =
506 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
507 let deserialized_stack: Stack =
508 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
509
510 assert_eq!(
512 stack, deserialized_stack,
513 "Original and deserialized stacks do not match."
514 );
515
516 let mut settings = insta::Settings::clone_current();
518 settings.set_sort_maps(true);
519 settings.bind(|| {
520 assert_json_snapshot!("stack_serialization_account_managed", stack);
521 });
522 }
523
524 #[test]
525 fn test_empty_stack_serialization() {
526 let stack_builder = Stack::new("empty-test-stack".to_string());
527
528 let stack = stack_builder
529 .permissions(PermissionsConfig::new()) .build();
531
532 let serialized_stack =
534 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
535 let deserialized_stack: Stack =
536 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
537
538 assert_eq!(
540 stack, deserialized_stack,
541 "Original and deserialized empty stacks do not match."
542 );
543
544 let mut settings = insta::Settings::clone_current();
546 settings.set_sort_maps(true);
547 settings.bind(|| {
548 assert_json_snapshot!("empty_stack_serialization_account", stack);
549 });
550 }
551
552 #[test]
553 fn stack_deserializes_resources_without_public_endpoints() {
554 let container = Container::new("api".to_string())
555 .code(ContainerCode::Image {
556 image: "example.com/api:latest".to_string(),
557 })
558 .cpu(ResourceSpec {
559 min: "0.5".to_string(),
560 desired: "1".to_string(),
561 })
562 .memory(ResourceSpec {
563 min: "512Mi".to_string(),
564 desired: "1Gi".to_string(),
565 })
566 .port(8080)
567 .permissions("container-execution".to_string())
568 .build();
569 let daemon = Daemon::new("agent".to_string())
570 .code(DaemonCode::Image {
571 image: "example.com/agent:latest".to_string(),
572 })
573 .permissions("daemon-execution".to_string())
574 .build();
575 let worker = Worker::new("worker".to_string())
576 .code(WorkerCode::Image {
577 image: "example.com/worker:latest".to_string(),
578 })
579 .permissions("worker-execution".to_string())
580 .build();
581 let stack = Stack::new("legacy-stack".to_string())
582 .add(container, ResourceLifecycle::Live)
583 .add(daemon, ResourceLifecycle::Live)
584 .add(worker, ResourceLifecycle::Live)
585 .build();
586
587 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
588 for resource_id in ["api", "agent", "worker"] {
589 legacy_json
590 .pointer_mut(&format!("/resources/{resource_id}/config"))
591 .and_then(serde_json::Value::as_object_mut)
592 .expect("resource config should be an object")
593 .remove("publicEndpoints");
594 }
595
596 let stack: Stack =
597 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
598
599 let container = stack
600 .resources
601 .get("api")
602 .and_then(|entry| entry.config.downcast_ref::<Container>())
603 .expect("api should be a container");
604 assert!(container.public_endpoints.is_empty());
605
606 let daemon = stack
607 .resources
608 .get("agent")
609 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
610 .expect("agent should be a daemon");
611 assert!(daemon.public_endpoints.is_empty());
612
613 let worker = stack
614 .resources
615 .get("worker")
616 .and_then(|entry| entry.config.downcast_ref::<Worker>())
617 .expect("worker should be a worker");
618 assert!(worker.public_endpoints.is_empty());
619 }
620
621 #[test]
622 fn test_stack_with_permissions() {
623 use crate::permissions::PermissionProfile;
624 use indexmap::IndexMap;
625
626 let storage = Storage::new("test-storage".to_string()).build();
628
629 let mut permission_profile = PermissionProfile::new();
631 permission_profile.0.insert(
632 "*".to_string(),
633 vec![PermissionSetReference::from_name("storage/data-read")],
634 );
635
636 let mut permissions = IndexMap::new();
637 permissions.insert("reader".to_string(), permission_profile);
638
639 let stack = Stack::new("test-permissions-stack".to_string())
640 .add(storage, ResourceLifecycle::Frozen)
641 .permissions(PermissionsConfig {
642 profiles: permissions,
643 management: ManagementPermissions::Auto,
644 })
645 .build();
646
647 assert_eq!(stack.permission_profiles().len(), 1);
649 assert!(stack.permission_profiles().contains_key("reader"));
650
651 let reader_profile = stack.permission_profiles().get("reader").unwrap();
652 assert_eq!(reader_profile.0.len(), 1);
653 assert!(reader_profile.0.contains_key("*"));
654
655 let global_permissions = reader_profile.0.get("*").unwrap();
656 assert_eq!(
657 global_permissions,
658 &vec![PermissionSetReference::from_name("storage/data-read")]
659 );
660
661 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
663 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
664 assert_eq!(stack, deserialized);
665 }
666
667 #[test]
668 fn test_stack_with_management_permissions() {
669 use crate::permissions::{ManagementPermissions, PermissionProfile};
670
671 let storage = Storage::new("test-storage".to_string()).build();
673
674 let mut management_profile = PermissionProfile::new();
676 management_profile.0.insert(
677 "*".to_string(),
678 vec![PermissionSetReference::from_name("vault/data-write")],
679 );
680
681 let stack_auto = Stack::new("test-auto-management-stack".to_string())
683 .add(storage.clone(), ResourceLifecycle::Frozen)
684 .management(ManagementPermissions::auto())
685 .build();
686
687 assert!(stack_auto.management().is_auto());
688 assert!(stack_auto.management().profile().is_none());
689
690 let stack_extend = Stack::new("test-extend-management-stack".to_string())
692 .add(storage.clone(), ResourceLifecycle::Frozen)
693 .management(ManagementPermissions::extend(management_profile.clone()))
694 .build();
695
696 assert!(stack_extend.management().is_extend());
697 assert_eq!(
698 stack_extend.management().profile().unwrap(),
699 &management_profile
700 );
701
702 let stack_override = Stack::new("test-override-management-stack".to_string())
704 .add(storage.clone(), ResourceLifecycle::Frozen)
705 .management(ManagementPermissions::override_(management_profile.clone()))
706 .build();
707
708 assert!(stack_override.management().is_override());
709 assert_eq!(
710 stack_override.management().profile().unwrap(),
711 &management_profile
712 );
713
714 let stack_default = Stack::new("test-default-management-stack".to_string())
716 .add(storage, ResourceLifecycle::Frozen)
717 .build();
718
719 assert!(stack_default.management().is_auto());
720
721 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
723 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
724 assert_eq!(stack_extend, deserialized);
725 }
726
727 fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
728 crate::Sandbox::new(id.to_string())
729 .code(crate::SandboxCode::Image {
730 image: image.to_string(),
731 })
732 .maybe_private_base_image(private_base_image.map(str::to_string))
733 .egress(crate::SandboxEgress::Deny)
734 .lifecycle(crate::SandboxLifecyclePolicy {
735 max_lifetime_seconds: None,
736 idle_pause_seconds: None,
737 })
738 .build()
739 }
740
741 #[test]
744 fn setup_owned_digest_is_stable_without_a_live_sandbox() {
745 let stack = Stack::new("golden".to_string())
746 .add(
747 Storage::new("ledger".to_string()).build(),
748 ResourceLifecycle::Frozen,
749 )
750 .add(
751 digest_sandbox(
752 "frozen-agents",
753 "s3://bucket/frozen.zip",
754 Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
755 ),
756 ResourceLifecycle::Frozen,
757 )
758 .add(
759 Storage::new("scratch".to_string()).build(),
760 ResourceLifecycle::Live,
761 )
762 .build();
763
764 assert_eq!(
765 stack.setup_owned_digest(),
766 "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
767 );
768 }
769
770 struct LiveSandbox {
771 image: &'static str,
772 private_base_image: Option<&'static str>,
773 egress: crate::SandboxEgress,
774 network_id: &'static str,
775 remote_access: bool,
776 }
777
778 const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
779
780 impl LiveSandbox {
781 fn installed() -> Self {
782 Self {
783 image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
784 private_base_image: Some(BASE_A),
785 egress: crate::SandboxEgress::Allow,
786 network_id: "net-a",
787 remote_access: false,
788 }
789 }
790
791 fn digest(self) -> String {
792 let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
793 sandbox.egress = self.egress;
794 let mut stack = Stack::new("stack".to_string())
795 .add(
796 crate::Network::new(self.network_id.to_string())
797 .settings(crate::NetworkSettings::Create {
798 cidr: Some("10.0.0.0/16".to_string()),
799 availability_zones: 2,
800 })
801 .build(),
802 ResourceLifecycle::Frozen,
803 )
804 .add(sandbox, ResourceLifecycle::Live)
805 .build();
806 stack
807 .resources
808 .get_mut("agents")
809 .expect("sandbox")
810 .remote_access = self.remote_access;
811 let entry = &stack.resources["agents"];
812 crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
813 &stack,
814 entry.config.downcast_ref().expect("sandbox"),
815 entry.lifecycle,
816 crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
817 )
818 .expect("the inputs resolve, so no case is compared by its whole configuration");
819 stack.setup_owned_digest()
820 }
821 }
822
823 #[test]
824 fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
825 let installed = LiveSandbox::installed().digest();
826
827 for (unchanged, why) in [
828 (
829 LiveSandbox {
830 image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
831 ..LiveSandbox::installed()
832 },
833 "a new bundle under the same prefix",
834 ),
835 (
836 LiveSandbox {
837 private_base_image: Some(
838 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
839 ),
840 ..LiveSandbox::installed()
841 },
842 "a new tag",
843 ),
844 (
845 LiveSandbox {
846 private_base_image: Some(
847 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
848 ),
849 ..LiveSandbox::installed()
850 },
851 "a digest",
852 ),
853 ] {
854 assert_eq!(
855 installed,
856 unchanged.digest(),
857 "{why} is the runtime's to roll"
858 );
859 }
860
861 for (changed, what) in [
862 (
863 LiveSandbox {
864 private_base_image: None,
865 ..LiveSandbox::installed()
866 },
867 "a dropped private base",
868 ),
869 (
870 LiveSandbox {
871 private_base_image: Some(
872 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
873 ),
874 ..LiveSandbox::installed()
875 },
876 "another repository",
877 ),
878 (
879 LiveSandbox {
880 private_base_image: Some(
881 "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
882 ),
883 ..LiveSandbox::installed()
884 },
885 "another account",
886 ),
887 (
888 LiveSandbox {
889 private_base_image: Some(
890 "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
891 ),
892 ..LiveSandbox::installed()
893 },
894 "the deployment's region",
895 ),
896 (
897 LiveSandbox {
898 egress: crate::SandboxEgress::Deny,
899 ..LiveSandbox::installed()
900 },
901 "egress allow to deny",
902 ),
903 (
904 LiveSandbox {
905 image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
906 ..LiveSandbox::installed()
907 },
908 "another bundle bucket",
909 ),
910 (
911 LiveSandbox {
912 image: "s3://bucket/other-prefix/v1/bundle.zip",
913 ..LiveSandbox::installed()
914 },
915 "another bundle prefix",
916 ),
917 (
918 LiveSandbox {
919 remote_access: true,
920 ..LiveSandbox::installed()
921 },
922 "a remote grant",
923 ),
924 ] {
925 assert_ne!(installed, changed.digest(), "{what} is setup-owned");
926 }
927
928 let deny = |network_id| {
929 LiveSandbox {
930 egress: crate::SandboxEgress::Deny,
931 network_id,
932 ..LiveSandbox::installed()
933 }
934 .digest()
935 };
936 assert_ne!(
937 deny("net-a"),
938 deny("net-b"),
939 "the connector's network is setup-owned"
940 );
941 }
942
943 #[test]
944 fn setup_owned_digest_is_order_independent() {
945 let first = Stack::new("first".to_string())
946 .add(
947 Storage::new("alpha".to_string()).build(),
948 ResourceLifecycle::Frozen,
949 )
950 .add(
951 Storage::new("beta".to_string()).build(),
952 ResourceLifecycle::Frozen,
953 )
954 .add(
955 Storage::new("live-one".to_string()).build(),
956 ResourceLifecycle::Live,
957 )
958 .build();
959 let second = Stack::new("second".to_string())
960 .add(
961 Storage::new("beta".to_string()).build(),
962 ResourceLifecycle::Frozen,
963 )
964 .add(
965 Storage::new("alpha".to_string()).build(),
966 ResourceLifecycle::Frozen,
967 )
968 .add(
969 Storage::new("live-two".to_string()).build(),
970 ResourceLifecycle::Live,
971 )
972 .build();
973
974 assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
975 }
976}