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 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub display_name: Option<String>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub description: Option<String>,
18 pub config: Resource,
20 pub lifecycle: ResourceLifecycle,
22 pub dependencies: Vec<ResourceRef>,
25 #[serde(default)]
29 pub remote_access: bool,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub enabled_when: Option<String>,
38}
39
40impl ResourceEntry {
41 pub fn new<T: crate::ResourceDefinition>(resource: T, lifecycle: ResourceLifecycle) -> Self {
43 StackBuilder::entry(resource, lifecycle)
44 }
45
46 pub fn display_name(mut self, name: impl Into<String>) -> Self {
48 self.display_name = Some(name.into());
49 self
50 }
51
52 pub fn description(mut self, description: impl Into<String>) -> Self {
54 self.description = Some(description.into());
55 self
56 }
57
58 pub fn display_name_or_id(&self) -> &str {
59 self.display_name
60 .as_deref()
61 .unwrap_or_else(|| self.config.id())
62 }
63
64 pub fn combined_dependencies(&self) -> Vec<ResourceRef> {
66 let mut dependencies = self.config.get_dependencies();
67 dependencies.extend(self.dependencies.clone());
68 dependencies
69 }
70
71 pub fn has_remote_bindings(&self) -> bool {
77 crate::remote_bindings::remote_binding_for_entry(self).is_some()
78 }
79
80 pub fn publishes_binding_params(&self) -> bool {
84 self.remote_access
85 || self
86 .config
87 .downcast_ref::<crate::Vault>()
88 .is_some_and(|vault| {
89 matches!(
90 vault.id.as_str(),
91 crate::SECRETS_VAULT_ID | crate::DEPLOYER_SECRETS_VAULT_ID
92 )
93 })
94 }
95}
96
97#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
100#[serde(rename_all = "camelCase")]
101#[builder(start_fn = new)]
102pub struct Stack {
103 #[builder(start_fn)]
105 pub id: String,
106 #[builder(field)]
108 pub resources: IndexMap<String, ResourceEntry>,
109 #[builder(field)]
111 #[serde(default)]
112 pub permissions: PermissionsConfig,
113 #[builder(field)]
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub supported_platforms: Option<Vec<Platform>>,
117 #[builder(field)]
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub inputs: Vec<StackInputDefinition>,
121 #[builder(field)]
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub dynamic_container_repositories: Vec<String>,
126 #[builder(field)]
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub dynamic_container_image_resources: Vec<String>,
131 #[builder(field)]
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub operations: Option<crate::OperationsConfig>,
136}
137
138impl Stack {
139 pub fn queue_visibility_timeout_seconds(&self, queue_id: &str) -> u32 {
141 self.resources
142 .values()
143 .filter_map(|entry| {
144 let worker = entry.config.downcast_ref::<crate::Worker>()?;
145 worker
146 .triggers
147 .iter()
148 .filter_map(|trigger| match trigger {
149 crate::WorkerTrigger::Queue { queue, batch } if queue.id == queue_id => {
150 Some(worker.timeout_seconds.saturating_mul(6).saturating_add(
151 batch.as_ref().map_or(0, |batch| batch.max_window_seconds),
152 ))
153 }
154 _ => None,
155 })
156 .max()
157 })
158 .max()
159 .unwrap_or(30)
160 .clamp(30, 43_200)
161 }
162
163 pub fn container_linkers(&self, container_id: &str) -> Vec<ResourceRef> {
166 self.linkers(&ResourceRef::new(
167 crate::Container::RESOURCE_TYPE,
168 container_id,
169 ))
170 }
171
172 pub fn linkers(&self, target: &ResourceRef) -> Vec<ResourceRef> {
174 self.resources
175 .iter()
176 .filter_map(|(id, entry)| {
177 let links = if let Some(resource) = entry.config.downcast_ref::<crate::Worker>() {
178 &resource.links
179 } else if let Some(resource) = entry.config.downcast_ref::<crate::Container>() {
180 &resource.links
181 } else if let Some(resource) = entry.config.downcast_ref::<crate::Daemon>() {
182 &resource.links
183 } else if let Some(resource) = entry.config.downcast_ref::<crate::Build>() {
184 &resource.links
185 } else if let Some(resource) = entry.config.downcast_ref::<crate::Sandbox>() {
186 &resource.links
187 } else {
188 return None;
189 };
190 links
191 .contains(target)
192 .then(|| ResourceRef::new(entry.config.resource_type(), id.clone()))
193 })
194 .collect()
195 }
196
197 pub fn setup_owned_digest(&self) -> String {
201 let mut resources = self
202 .resources
203 .iter()
204 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
205 .map(|(id, entry)| {
206 let mut value =
207 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
208 let object = value.as_object_mut().expect("resource entry is an object");
209 object.remove("displayName");
210 object.remove("description");
211 if let Some(sandbox) = entry.config.downcast_ref::<crate::Sandbox>() {
212 value["config"]["egress"] = sandbox.setup_digest_value();
213 value["config"]
214 .as_object_mut()
215 .expect("resource config is an object")
216 .remove("defaultPolicy");
217 }
218 if entry
219 .config
220 .downcast_ref::<crate::AzureStorageAccount>()
221 .is_some()
222 {
223 if let Some(settings) = crate::AzureStorageAccount::setup_settings_digest(self)
224 {
225 value["azureStorageSettings"] = settings.into();
226 }
227 }
228 canonicalize_json(&mut value);
229 if entry.config.downcast_ref::<crate::Queue>().is_some()
232 && self.resources.values().any(|consumer| {
233 consumer
234 .config
235 .downcast_ref::<crate::Worker>()
236 .is_some_and(|worker| worker.queue_batch_config(id).is_some())
237 })
238 {
239 value["queueBatchVisibilityTimeout"] =
240 self.queue_visibility_timeout_seconds(id).into();
241 }
242 (id, value)
243 })
244 .collect::<Vec<_>>();
245 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
246
247 let mut live_sandbox_inputs = self
248 .resources
249 .iter()
250 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Live)
251 .filter_map(|(id, entry)| {
252 let sandbox = entry.config.downcast_ref::<crate::Sandbox>()?;
253 let mut inputs = crate::sandbox_setup_inputs::comparable_aws_sandbox_setup_inputs(
254 self,
255 sandbox,
256 entry.lifecycle,
257 );
258 for (_, value) in &mut inputs {
259 canonicalize_json(value);
260 }
261 Some((id, inputs))
262 })
263 .collect::<Vec<_>>();
264 live_sandbox_inputs.sort_unstable_by_key(|(id, _)| *id);
265
266 let encoded = if live_sandbox_inputs.is_empty() {
267 serde_json::to_vec(&resources)
268 } else {
269 serde_json::to_vec(&(&resources, &live_sandbox_inputs))
270 }
271 .expect("canonical setup-owned projection always serializes");
272 format!("{:x}", Sha256::digest(encoded))
273 }
274
275 pub fn setup_update_digest(&self) -> String {
278 let mut permissions = serde_json::to_value(&self.permissions)
279 .expect("permission profiles always serialize to JSON");
280 canonicalize_json(&mut permissions);
281 let encoded = serde_json::to_vec(&(self.setup_owned_digest(), permissions))
282 .expect("setup update projection always serializes");
283 format!("{:x}", Sha256::digest(encoded))
284 }
285
286 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
288 self.resources.iter()
289 }
290
291 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
293 self.resources.iter_mut()
294 }
295
296 pub fn id(&self) -> &str {
297 &self.id
298 }
299
300 pub fn current() -> StackRef {
302 StackRef::Current
303 }
304
305 pub fn permissions(&self) -> &PermissionsConfig {
307 &self.permissions
308 }
309
310 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
312 &self.permissions.profiles
313 }
314
315 pub fn management(&self) -> &ManagementPermissions {
317 &self.permissions.management
318 }
319
320 pub fn supported_platforms(&self) -> Option<&[Platform]> {
322 self.supported_platforms.as_deref()
323 }
324
325 pub fn inputs(&self) -> &[StackInputDefinition] {
327 &self.inputs
328 }
329
330 pub fn supports_platform(&self, platform: &Platform) -> bool {
333 match &self.supported_platforms {
334 Some(platforms) => platforms.contains(platform),
335 None => true,
336 }
337 }
338}
339
340fn canonicalize_json(value: &mut serde_json::Value) {
341 match value {
342 serde_json::Value::Array(values) => {
343 for value in values {
344 canonicalize_json(value);
345 }
346 }
347 serde_json::Value::Object(object) => {
348 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
349 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
350 for (key, mut value) in entries {
351 canonicalize_json(&mut value);
352 object.insert(key, value);
353 }
354 }
355 _ => {}
356 }
357}
358
359impl StackBuilder {
360 pub fn add_entry(self, entry: ResourceEntry) -> Self {
362 self.insert(entry)
363 }
364
365 pub fn add<T: crate::ResourceDefinition>(
369 self,
370 resource: T,
371 lifecycle: ResourceLifecycle,
372 ) -> Self {
373 self.add_with_dependencies(resource, lifecycle, vec![])
374 }
375
376 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
379 self,
380 resource: T,
381 lifecycle: ResourceLifecycle,
382 additional_dependencies: Vec<ResourceRef>,
383 ) -> Self {
384 let mut entry = Self::entry(resource, lifecycle);
385 entry.dependencies = additional_dependencies;
386 self.insert(entry)
387 }
388
389 #[doc(hidden)]
396 pub fn add_enabled_when<T: crate::ResourceDefinition>(
397 self,
398 resource: T,
399 lifecycle: ResourceLifecycle,
400 input_id: impl Into<String>,
401 ) -> Self {
402 let mut entry = Self::entry(resource, lifecycle);
403 entry.enabled_when = Some(input_id.into());
404 self.insert(entry)
405 }
406
407 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
410 self,
411 resource: T,
412 lifecycle: ResourceLifecycle,
413 ) -> Self {
414 let mut entry = Self::entry(resource, lifecycle);
415 entry.remote_access = true;
416 self.insert(entry)
417 }
418
419 fn entry<T: crate::ResourceDefinition>(
423 resource: T,
424 lifecycle: ResourceLifecycle,
425 ) -> ResourceEntry {
426 ResourceEntry {
427 config: Resource::new(resource),
428 lifecycle,
429 dependencies: Vec::new(),
430 display_name: None,
431 description: None,
432 remote_access: false,
433 enabled_when: None,
434 }
435 }
436
437 fn insert(mut self, entry: ResourceEntry) -> Self {
438 self.resources.insert(entry.config.id().to_string(), entry);
439 self
440 }
441
442 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
445 self.permissions = permissions;
446 self
447 }
448
449 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
461 self.permissions.profiles.insert(name.into(), profile);
462 self
463 }
464
465 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
467 self.supported_platforms = Some(platforms);
468 self
469 }
470
471 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
473 self.inputs = inputs;
474 self
475 }
476
477 pub fn operations(mut self, operations: crate::OperationsConfig) -> Self {
479 self.operations = Some(operations);
480 self
481 }
482
483 pub fn management(mut self, management: ManagementPermissions) -> Self {
509 self.permissions.management = management;
510 self
511 }
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
517#[serde(rename_all = "camelCase")]
518pub enum StackRef {
519 Current,
521 External(String),
523}
524
525impl StackRef {
526 pub fn from_stack(stack: &Stack) -> Self {
528 StackRef::External(stack.id().to_string())
529 }
530}
531
532impl From<&Stack> for StackRef {
533 fn from(stack: &Stack) -> Self {
534 StackRef::External(stack.id().to_string())
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::resource::ResourceLifecycle;
542 use crate::{
543 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
544 Storage, Worker, WorkerCode,
545 };
546 use insta::assert_json_snapshot;
547
548 fn resource_entry<T: crate::ResourceDefinition>(
549 resource: T,
550 lifecycle: ResourceLifecycle,
551 remote_access: bool,
552 ) -> ResourceEntry {
553 ResourceEntry {
554 config: Resource::new(resource),
555 lifecycle,
556 dependencies: Vec::new(),
557 display_name: None,
558 description: None,
559 remote_access,
560 enabled_when: None,
561 }
562 }
563
564 #[test]
567 fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
568 assert!(resource_entry(
569 Storage::new("archive".to_string()).build(),
570 ResourceLifecycle::Frozen,
571 true,
572 )
573 .has_remote_bindings());
574 assert!(!resource_entry(
575 Storage::new("archive".to_string()).build(),
576 ResourceLifecycle::Frozen,
577 false,
578 )
579 .has_remote_bindings());
580 assert!(!resource_entry(
581 Storage::new("archive".to_string()).build(),
582 ResourceLifecycle::Live,
583 true,
584 )
585 .has_remote_bindings());
586 let sandbox = crate::Sandbox::new("agents".to_string())
587 .code(crate::SandboxCode::Image {
588 image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
589 })
590 .egress(crate::SandboxEgress::Allow)
591 .lifecycle(crate::SandboxLifecyclePolicy {
592 max_lifetime_seconds: None,
593 idle_pause_seconds: None,
594 })
595 .build();
596 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
597 assert!(!resource_entry(
598 Worker::new("worker".to_string())
599 .code(WorkerCode::Image {
600 image: "example.com/worker:latest".to_string(),
601 })
602 .permissions("worker-execution".to_string())
603 .build(),
604 ResourceLifecycle::Frozen,
605 true,
606 )
607 .has_remote_bindings());
608 }
609
610 #[test]
611 fn test_stack_serialization() {
612 use crate::WorkerCode;
613
614 let storage = Storage::new("my-bucket".to_string())
615 .public_read(true)
616 .build();
617
618 let worker = Worker::new("my-worker".to_string())
619 .code(WorkerCode::Image {
620 image: "rust:latest".to_string(),
621 })
622 .permissions("execution".to_string())
623 .link(&storage)
624 .build();
625
626 let mut permissions = IndexMap::new();
628 let mut execution_profile = PermissionProfile::new();
629 execution_profile.0.insert(
630 "*".to_string(),
631 vec![
632 PermissionSetReference::from_name("storage/data-read"),
633 PermissionSetReference::from_name("storage/data-write"),
634 ],
635 );
636 permissions.insert("execution".to_string(), execution_profile);
637
638 let stack_builder = Stack::new("test-stack".to_string())
639 .add(storage, ResourceLifecycle::Frozen)
640 .add(worker.clone(), ResourceLifecycle::Live);
641
642 let stack = stack_builder
643 .permissions(PermissionsConfig {
644 profiles: permissions,
645 management: ManagementPermissions::Auto,
646 })
647 .build();
648
649 let serialized_stack =
651 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
652 let deserialized_stack: Stack =
653 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
654
655 assert_eq!(
657 stack, deserialized_stack,
658 "Original and deserialized stacks do not match."
659 );
660
661 let mut settings = insta::Settings::clone_current();
663 settings.set_sort_maps(true);
664 settings.bind(|| {
665 assert_json_snapshot!("stack_serialization_account_managed", stack);
666 });
667 }
668
669 #[test]
670 fn test_empty_stack_serialization() {
671 let stack_builder = Stack::new("empty-test-stack".to_string());
672
673 let stack = stack_builder
674 .permissions(PermissionsConfig::new()) .build();
676
677 let serialized_stack =
679 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
680 let deserialized_stack: Stack =
681 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
682
683 assert_eq!(
685 stack, deserialized_stack,
686 "Original and deserialized empty stacks do not match."
687 );
688
689 let mut settings = insta::Settings::clone_current();
691 settings.set_sort_maps(true);
692 settings.bind(|| {
693 assert_json_snapshot!("empty_stack_serialization_account", stack);
694 });
695 }
696
697 #[test]
698 fn stack_deserializes_resources_without_public_endpoints() {
699 let container = Container::new("api".to_string())
700 .code(ContainerCode::Image {
701 image: "example.com/api:latest".to_string(),
702 })
703 .cpu(ResourceSpec {
704 min: "0.5".to_string(),
705 desired: "1".to_string(),
706 })
707 .memory(ResourceSpec {
708 min: "512Mi".to_string(),
709 desired: "1Gi".to_string(),
710 })
711 .port(8080)
712 .permissions("container-execution".to_string())
713 .build();
714 let daemon = Daemon::new("agent".to_string())
715 .code(DaemonCode::Image {
716 image: "example.com/agent:latest".to_string(),
717 })
718 .permissions("daemon-execution".to_string())
719 .build();
720 let worker = Worker::new("worker".to_string())
721 .code(WorkerCode::Image {
722 image: "example.com/worker:latest".to_string(),
723 })
724 .permissions("worker-execution".to_string())
725 .build();
726 let stack = Stack::new("legacy-stack".to_string())
727 .add(container, ResourceLifecycle::Live)
728 .add(daemon, ResourceLifecycle::Live)
729 .add(worker, ResourceLifecycle::Live)
730 .build();
731
732 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
733 for resource_id in ["api", "agent", "worker"] {
734 legacy_json
735 .pointer_mut(&format!("/resources/{resource_id}/config"))
736 .and_then(serde_json::Value::as_object_mut)
737 .expect("resource config should be an object")
738 .remove("publicEndpoints");
739 }
740
741 let stack: Stack =
742 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
743
744 let container = stack
745 .resources
746 .get("api")
747 .and_then(|entry| entry.config.downcast_ref::<Container>())
748 .expect("api should be a container");
749 assert!(container.public_endpoints.is_empty());
750
751 let daemon = stack
752 .resources
753 .get("agent")
754 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
755 .expect("agent should be a daemon");
756 assert!(daemon.public_endpoints.is_empty());
757
758 let worker = stack
759 .resources
760 .get("worker")
761 .and_then(|entry| entry.config.downcast_ref::<Worker>())
762 .expect("worker should be a worker");
763 assert!(worker.public_endpoints.is_empty());
764 }
765
766 #[test]
767 fn test_stack_with_permissions() {
768 use crate::permissions::PermissionProfile;
769 use indexmap::IndexMap;
770
771 let storage = Storage::new("test-storage".to_string()).build();
773
774 let mut permission_profile = PermissionProfile::new();
776 permission_profile.0.insert(
777 "*".to_string(),
778 vec![PermissionSetReference::from_name("storage/data-read")],
779 );
780
781 let mut permissions = IndexMap::new();
782 permissions.insert("reader".to_string(), permission_profile);
783
784 let stack = Stack::new("test-permissions-stack".to_string())
785 .add(storage, ResourceLifecycle::Frozen)
786 .permissions(PermissionsConfig {
787 profiles: permissions,
788 management: ManagementPermissions::Auto,
789 })
790 .build();
791
792 assert_eq!(stack.permission_profiles().len(), 1);
794 assert!(stack.permission_profiles().contains_key("reader"));
795
796 let reader_profile = stack.permission_profiles().get("reader").unwrap();
797 assert_eq!(reader_profile.0.len(), 1);
798 assert!(reader_profile.0.contains_key("*"));
799
800 let global_permissions = reader_profile.0.get("*").unwrap();
801 assert_eq!(
802 global_permissions,
803 &vec![PermissionSetReference::from_name("storage/data-read")]
804 );
805
806 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
808 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
809 assert_eq!(stack, deserialized);
810 }
811
812 #[test]
813 fn test_stack_with_management_permissions() {
814 use crate::permissions::{ManagementPermissions, PermissionProfile};
815
816 let storage = Storage::new("test-storage".to_string()).build();
818
819 let mut management_profile = PermissionProfile::new();
821 management_profile.0.insert(
822 "*".to_string(),
823 vec![PermissionSetReference::from_name("vault/data-write")],
824 );
825
826 let stack_auto = Stack::new("test-auto-management-stack".to_string())
828 .add(storage.clone(), ResourceLifecycle::Frozen)
829 .management(ManagementPermissions::auto())
830 .build();
831
832 assert!(stack_auto.management().is_auto());
833 assert!(stack_auto.management().profile().is_none());
834
835 let stack_extend = Stack::new("test-extend-management-stack".to_string())
837 .add(storage.clone(), ResourceLifecycle::Frozen)
838 .management(ManagementPermissions::extend(management_profile.clone()))
839 .build();
840
841 assert!(stack_extend.management().is_extend());
842 assert_eq!(
843 stack_extend.management().profile().unwrap(),
844 &management_profile
845 );
846
847 let stack_override = Stack::new("test-override-management-stack".to_string())
849 .add(storage.clone(), ResourceLifecycle::Frozen)
850 .management(ManagementPermissions::override_(management_profile.clone()))
851 .build();
852
853 assert!(stack_override.management().is_override());
854 assert_eq!(
855 stack_override.management().profile().unwrap(),
856 &management_profile
857 );
858
859 let stack_default = Stack::new("test-default-management-stack".to_string())
861 .add(storage, ResourceLifecycle::Frozen)
862 .build();
863
864 assert!(stack_default.management().is_auto());
865
866 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
868 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
869 assert_eq!(stack_extend, deserialized);
870 }
871
872 #[test]
873 fn presentation_metadata_preserves_setup_fingerprints_and_old_entries() {
874 let original = Stack::new("metadata".to_string())
875 .add(
876 Storage::new("documents".to_string()).build(),
877 ResourceLifecycle::Frozen,
878 )
879 .build();
880 let mut documented = original.clone();
881 let entry = documented.resources.get_mut("documents").unwrap();
882 entry.display_name = Some("Document storage".to_string());
883 entry.description = Some("Uploaded documents and processed results.".to_string());
884 assert_eq!(
885 original.setup_owned_digest(),
886 documented.setup_owned_digest()
887 );
888 assert_eq!(
889 original.setup_update_digest(),
890 documented.setup_update_digest()
891 );
892 assert_eq!(
893 original.resources["documents"].config,
894 documented.resources["documents"].config
895 );
896 let old_json = serde_json::to_value(&original).unwrap();
897 assert!(old_json["resources"]["documents"]
898 .get("displayName")
899 .is_none());
900 let loaded: Stack = serde_json::from_value(old_json).unwrap();
901 assert_eq!(
902 loaded.resources["documents"].display_name_or_id(),
903 "documents"
904 );
905 assert!(loaded.resources["documents"].description.is_none());
906 let round_trip: Stack =
907 serde_json::from_value(serde_json::to_value(&documented).unwrap()).unwrap();
908 assert_eq!(round_trip, documented);
909 }
910
911 fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
912 crate::Sandbox::new(id.to_string())
913 .code(crate::SandboxCode::Image {
914 image: image.to_string(),
915 })
916 .maybe_private_base_image(private_base_image.map(str::to_string))
917 .egress(crate::SandboxEgress::None)
918 .lifecycle(crate::SandboxLifecyclePolicy {
919 max_lifetime_seconds: None,
920 idle_pause_seconds: None,
921 })
922 .build()
923 }
924
925 #[test]
928 fn setup_owned_digest_is_stable_without_a_live_sandbox() {
929 let stack = Stack::new("golden".to_string())
930 .add(
931 Storage::new("ledger".to_string()).build(),
932 ResourceLifecycle::Frozen,
933 )
934 .add(
935 digest_sandbox(
936 "frozen-agents",
937 "s3://bucket/frozen.zip",
938 Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
939 ),
940 ResourceLifecycle::Frozen,
941 )
942 .add(
943 Storage::new("scratch".to_string()).build(),
944 ResourceLifecycle::Live,
945 )
946 .build();
947
948 assert_eq!(
949 stack.setup_owned_digest(),
950 "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
951 );
952 }
953
954 struct LiveSandbox {
955 image: &'static str,
956 private_base_image: Option<&'static str>,
957 egress: crate::SandboxEgress,
958 network_id: &'static str,
959 remote_access: bool,
960 }
961
962 const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
963
964 impl LiveSandbox {
965 fn installed() -> Self {
966 Self {
967 image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
968 private_base_image: Some(BASE_A),
969 egress: crate::SandboxEgress::Allow,
970 network_id: "net-a",
971 remote_access: false,
972 }
973 }
974
975 fn digest(self) -> String {
976 let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
977 sandbox.egress = self.egress;
978 let mut stack = Stack::new("stack".to_string())
979 .add(
980 crate::Network::new(self.network_id.to_string())
981 .settings(crate::NetworkSettings::Create {
982 cidr: Some("10.0.0.0/16".to_string()),
983 availability_zones: 2,
984 })
985 .build(),
986 ResourceLifecycle::Frozen,
987 )
988 .add(sandbox, ResourceLifecycle::Live)
989 .build();
990 stack
991 .resources
992 .get_mut("agents")
993 .expect("sandbox")
994 .remote_access = self.remote_access;
995 let entry = &stack.resources["agents"];
996 crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
997 &stack,
998 entry.config.downcast_ref().expect("sandbox"),
999 entry.lifecycle,
1000 crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
1001 )
1002 .expect("the inputs resolve, so no case is compared by its whole configuration");
1003 stack.setup_owned_digest()
1004 }
1005 }
1006
1007 #[test]
1008 fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
1009 let installed = LiveSandbox::installed().digest();
1010
1011 for (unchanged, why) in [
1012 (
1013 LiveSandbox {
1014 image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
1015 ..LiveSandbox::installed()
1016 },
1017 "a new bundle under the same prefix",
1018 ),
1019 (
1020 LiveSandbox {
1021 private_base_image: Some(
1022 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
1023 ),
1024 ..LiveSandbox::installed()
1025 },
1026 "a new tag",
1027 ),
1028 (
1029 LiveSandbox {
1030 private_base_image: Some(
1031 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
1032 ),
1033 ..LiveSandbox::installed()
1034 },
1035 "a digest",
1036 ),
1037 (
1038 LiveSandbox {
1039 private_base_image: Some(
1040 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
1041 ),
1042 ..LiveSandbox::installed()
1043 },
1044 "another repository",
1045 ),
1046 (
1047 LiveSandbox {
1048 private_base_image: Some(
1049 "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
1050 ),
1051 ..LiveSandbox::installed()
1052 },
1053 "another account",
1054 ),
1055 (
1056 LiveSandbox {
1057 private_base_image: Some(
1058 "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
1059 ),
1060 ..LiveSandbox::installed()
1061 },
1062 "the deployment's region",
1063 ),
1064 (
1065 LiveSandbox {
1066 image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
1067 ..LiveSandbox::installed()
1068 },
1069 "another bundle bucket",
1070 ),
1071 ] {
1072 assert_eq!(
1073 installed,
1074 unchanged.digest(),
1075 "{why} is the runtime's to roll: a Live sandbox builds from the store and repository its setup granted"
1076 );
1077 }
1078
1079 for (changed, what) in [
1080 (
1081 LiveSandbox {
1082 private_base_image: None,
1083 ..LiveSandbox::installed()
1084 },
1085 "a dropped private base",
1086 ),
1087 (
1088 LiveSandbox {
1089 egress: crate::SandboxEgress::None,
1090 ..LiveSandbox::installed()
1091 },
1092 "egress allow to deny",
1093 ),
1094 (
1095 LiveSandbox {
1096 image: "s3://bucket/other-prefix/v1/bundle.zip",
1097 ..LiveSandbox::installed()
1098 },
1099 "another bundle prefix",
1100 ),
1101 (
1102 LiveSandbox {
1103 remote_access: true,
1104 ..LiveSandbox::installed()
1105 },
1106 "a remote grant",
1107 ),
1108 (
1109 LiveSandbox {
1110 private_base_image: Some(
1111 "123456789012.dkr.ecr.us-east-1.amazonaws.com/cache/team/base-a:1",
1112 ),
1113 ..LiveSandbox::installed()
1114 },
1115 "a base repository in another namespace",
1116 ),
1117 (
1118 LiveSandbox {
1119 private_base_image: Some(
1120 "123456789012.dkr.ecr.us-east-1.amazonaws.com/base-a:1",
1121 ),
1122 ..LiveSandbox::installed()
1123 },
1124 "a base repository with no namespace",
1125 ),
1126 ] {
1127 assert_ne!(installed, changed.digest(), "{what} is setup-owned");
1128 }
1129
1130 let deny = |network_id| {
1131 LiveSandbox {
1132 egress: crate::SandboxEgress::None,
1133 network_id,
1134 ..LiveSandbox::installed()
1135 }
1136 .digest()
1137 };
1138 assert_ne!(
1139 deny("net-a"),
1140 deny("net-b"),
1141 "the connector's network is setup-owned"
1142 );
1143 }
1144
1145 #[test]
1146 fn setup_owned_digest_is_order_independent() {
1147 let first = Stack::new("first".to_string())
1148 .add(
1149 Storage::new("alpha".to_string()).build(),
1150 ResourceLifecycle::Frozen,
1151 )
1152 .add(
1153 Storage::new("beta".to_string()).build(),
1154 ResourceLifecycle::Frozen,
1155 )
1156 .add(
1157 Storage::new("live-one".to_string()).build(),
1158 ResourceLifecycle::Live,
1159 )
1160 .build();
1161 let second = Stack::new("second".to_string())
1162 .add(
1163 Storage::new("beta".to_string()).build(),
1164 ResourceLifecycle::Frozen,
1165 )
1166 .add(
1167 Storage::new("alpha".to_string()).build(),
1168 ResourceLifecycle::Frozen,
1169 )
1170 .add(
1171 Storage::new("live-two".to_string()).build(),
1172 ResourceLifecycle::Live,
1173 )
1174 .build();
1175
1176 assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
1177 }
1178}