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