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