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