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