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 bundled_from: None,
590 })
591 .egress(crate::SandboxEgress::Allow)
592 .lifecycle(crate::SandboxLifecyclePolicy {
593 max_lifetime_seconds: None,
594 idle_pause_seconds: None,
595 })
596 .build();
597 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
598 assert!(!resource_entry(
599 Worker::new("worker".to_string())
600 .code(WorkerCode::Image {
601 image: "example.com/worker:latest".to_string(),
602 })
603 .permissions("worker-execution".to_string())
604 .build(),
605 ResourceLifecycle::Frozen,
606 true,
607 )
608 .has_remote_bindings());
609 }
610
611 #[test]
612 fn test_stack_serialization() {
613 use crate::WorkerCode;
614
615 let storage = Storage::new("my-bucket".to_string())
616 .public_read(true)
617 .build();
618
619 let worker = Worker::new("my-worker".to_string())
620 .code(WorkerCode::Image {
621 image: "rust:latest".to_string(),
622 })
623 .permissions("execution".to_string())
624 .link(&storage)
625 .build();
626
627 let mut permissions = IndexMap::new();
629 let mut execution_profile = PermissionProfile::new();
630 execution_profile.0.insert(
631 "*".to_string(),
632 vec![
633 PermissionSetReference::from_name("storage/data-read"),
634 PermissionSetReference::from_name("storage/data-write"),
635 ],
636 );
637 permissions.insert("execution".to_string(), execution_profile);
638
639 let stack_builder = Stack::new("test-stack".to_string())
640 .add(storage, ResourceLifecycle::Frozen)
641 .add(worker.clone(), ResourceLifecycle::Live);
642
643 let stack = stack_builder
644 .permissions(PermissionsConfig {
645 profiles: permissions,
646 management: ManagementPermissions::Auto,
647 })
648 .build();
649
650 let serialized_stack =
652 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
653 let deserialized_stack: Stack =
654 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
655
656 assert_eq!(
658 stack, deserialized_stack,
659 "Original and deserialized stacks do not match."
660 );
661
662 let mut settings = insta::Settings::clone_current();
664 settings.set_sort_maps(true);
665 settings.bind(|| {
666 assert_json_snapshot!("stack_serialization_account_managed", stack);
667 });
668 }
669
670 #[test]
671 fn test_empty_stack_serialization() {
672 let stack_builder = Stack::new("empty-test-stack".to_string());
673
674 let stack = stack_builder
675 .permissions(PermissionsConfig::new()) .build();
677
678 let serialized_stack =
680 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
681 let deserialized_stack: Stack =
682 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
683
684 assert_eq!(
686 stack, deserialized_stack,
687 "Original and deserialized empty stacks do not match."
688 );
689
690 let mut settings = insta::Settings::clone_current();
692 settings.set_sort_maps(true);
693 settings.bind(|| {
694 assert_json_snapshot!("empty_stack_serialization_account", stack);
695 });
696 }
697
698 #[test]
699 fn stack_deserializes_resources_without_public_endpoints() {
700 let container = Container::new("api".to_string())
701 .code(ContainerCode::Image {
702 image: "example.com/api:latest".to_string(),
703 })
704 .cpu(ResourceSpec {
705 min: "0.5".to_string(),
706 desired: "1".to_string(),
707 })
708 .memory(ResourceSpec {
709 min: "512Mi".to_string(),
710 desired: "1Gi".to_string(),
711 })
712 .port(8080)
713 .permissions("container-execution".to_string())
714 .build();
715 let daemon = Daemon::new("agent".to_string())
716 .code(DaemonCode::Image {
717 image: "example.com/agent:latest".to_string(),
718 })
719 .permissions("daemon-execution".to_string())
720 .build();
721 let worker = Worker::new("worker".to_string())
722 .code(WorkerCode::Image {
723 image: "example.com/worker:latest".to_string(),
724 })
725 .permissions("worker-execution".to_string())
726 .build();
727 let stack = Stack::new("legacy-stack".to_string())
728 .add(container, ResourceLifecycle::Live)
729 .add(daemon, ResourceLifecycle::Live)
730 .add(worker, ResourceLifecycle::Live)
731 .build();
732
733 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
734 for resource_id in ["api", "agent", "worker"] {
735 legacy_json
736 .pointer_mut(&format!("/resources/{resource_id}/config"))
737 .and_then(serde_json::Value::as_object_mut)
738 .expect("resource config should be an object")
739 .remove("publicEndpoints");
740 }
741
742 let stack: Stack =
743 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
744
745 let container = stack
746 .resources
747 .get("api")
748 .and_then(|entry| entry.config.downcast_ref::<Container>())
749 .expect("api should be a container");
750 assert!(container.public_endpoints.is_empty());
751
752 let daemon = stack
753 .resources
754 .get("agent")
755 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
756 .expect("agent should be a daemon");
757 assert!(daemon.public_endpoints.is_empty());
758
759 let worker = stack
760 .resources
761 .get("worker")
762 .and_then(|entry| entry.config.downcast_ref::<Worker>())
763 .expect("worker should be a worker");
764 assert!(worker.public_endpoints.is_empty());
765 }
766
767 #[test]
768 fn test_stack_with_permissions() {
769 use crate::permissions::PermissionProfile;
770 use indexmap::IndexMap;
771
772 let storage = Storage::new("test-storage".to_string()).build();
774
775 let mut permission_profile = PermissionProfile::new();
777 permission_profile.0.insert(
778 "*".to_string(),
779 vec![PermissionSetReference::from_name("storage/data-read")],
780 );
781
782 let mut permissions = IndexMap::new();
783 permissions.insert("reader".to_string(), permission_profile);
784
785 let stack = Stack::new("test-permissions-stack".to_string())
786 .add(storage, ResourceLifecycle::Frozen)
787 .permissions(PermissionsConfig {
788 profiles: permissions,
789 management: ManagementPermissions::Auto,
790 })
791 .build();
792
793 assert_eq!(stack.permission_profiles().len(), 1);
795 assert!(stack.permission_profiles().contains_key("reader"));
796
797 let reader_profile = stack.permission_profiles().get("reader").unwrap();
798 assert_eq!(reader_profile.0.len(), 1);
799 assert!(reader_profile.0.contains_key("*"));
800
801 let global_permissions = reader_profile.0.get("*").unwrap();
802 assert_eq!(
803 global_permissions,
804 &vec![PermissionSetReference::from_name("storage/data-read")]
805 );
806
807 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
809 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
810 assert_eq!(stack, deserialized);
811 }
812
813 #[test]
814 fn test_stack_with_management_permissions() {
815 use crate::permissions::{ManagementPermissions, PermissionProfile};
816
817 let storage = Storage::new("test-storage".to_string()).build();
819
820 let mut management_profile = PermissionProfile::new();
822 management_profile.0.insert(
823 "*".to_string(),
824 vec![PermissionSetReference::from_name("vault/data-write")],
825 );
826
827 let stack_auto = Stack::new("test-auto-management-stack".to_string())
829 .add(storage.clone(), ResourceLifecycle::Frozen)
830 .management(ManagementPermissions::auto())
831 .build();
832
833 assert!(stack_auto.management().is_auto());
834 assert!(stack_auto.management().profile().is_none());
835
836 let stack_extend = Stack::new("test-extend-management-stack".to_string())
838 .add(storage.clone(), ResourceLifecycle::Frozen)
839 .management(ManagementPermissions::extend(management_profile.clone()))
840 .build();
841
842 assert!(stack_extend.management().is_extend());
843 assert_eq!(
844 stack_extend.management().profile().unwrap(),
845 &management_profile
846 );
847
848 let stack_override = Stack::new("test-override-management-stack".to_string())
850 .add(storage.clone(), ResourceLifecycle::Frozen)
851 .management(ManagementPermissions::override_(management_profile.clone()))
852 .build();
853
854 assert!(stack_override.management().is_override());
855 assert_eq!(
856 stack_override.management().profile().unwrap(),
857 &management_profile
858 );
859
860 let stack_default = Stack::new("test-default-management-stack".to_string())
862 .add(storage, ResourceLifecycle::Frozen)
863 .build();
864
865 assert!(stack_default.management().is_auto());
866
867 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
869 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
870 assert_eq!(stack_extend, deserialized);
871 }
872
873 #[test]
874 fn presentation_metadata_preserves_setup_fingerprints_and_old_entries() {
875 let original = Stack::new("metadata".to_string())
876 .add(
877 Storage::new("documents".to_string()).build(),
878 ResourceLifecycle::Frozen,
879 )
880 .build();
881 let mut documented = original.clone();
882 let entry = documented.resources.get_mut("documents").unwrap();
883 entry.display_name = Some("Document storage".to_string());
884 entry.description = Some("Uploaded documents and processed results.".to_string());
885 assert_eq!(
886 original.setup_owned_digest(),
887 documented.setup_owned_digest()
888 );
889 assert_eq!(
890 original.setup_update_digest(),
891 documented.setup_update_digest()
892 );
893 assert_eq!(
894 original.resources["documents"].config,
895 documented.resources["documents"].config
896 );
897 let old_json = serde_json::to_value(&original).unwrap();
898 assert!(old_json["resources"]["documents"]
899 .get("displayName")
900 .is_none());
901 let loaded: Stack = serde_json::from_value(old_json).unwrap();
902 assert_eq!(
903 loaded.resources["documents"].display_name_or_id(),
904 "documents"
905 );
906 assert!(loaded.resources["documents"].description.is_none());
907 let round_trip: Stack =
908 serde_json::from_value(serde_json::to_value(&documented).unwrap()).unwrap();
909 assert_eq!(round_trip, documented);
910 }
911
912 fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
913 crate::Sandbox::new(id.to_string())
914 .code(crate::SandboxCode::Image {
915 image: image.to_string(),
916 bundled_from: None,
917 })
918 .maybe_private_base_image(private_base_image.map(str::to_string))
919 .egress(crate::SandboxEgress::None)
920 .lifecycle(crate::SandboxLifecyclePolicy {
921 max_lifetime_seconds: None,
922 idle_pause_seconds: None,
923 })
924 .build()
925 }
926
927 #[test]
930 fn setup_owned_digest_is_stable_without_a_live_sandbox() {
931 let stack = Stack::new("golden".to_string())
932 .add(
933 Storage::new("ledger".to_string()).build(),
934 ResourceLifecycle::Frozen,
935 )
936 .add(
937 digest_sandbox(
938 "frozen-agents",
939 "s3://bucket/frozen.zip",
940 Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
941 ),
942 ResourceLifecycle::Frozen,
943 )
944 .add(
945 Storage::new("scratch".to_string()).build(),
946 ResourceLifecycle::Live,
947 )
948 .build();
949
950 assert_eq!(
951 stack.setup_owned_digest(),
952 "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
953 );
954 }
955
956 struct LiveSandbox {
957 image: &'static str,
958 private_base_image: Option<&'static str>,
959 egress: crate::SandboxEgress,
960 network_id: &'static str,
961 remote_access: bool,
962 }
963
964 const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
965
966 impl LiveSandbox {
967 fn installed() -> Self {
968 Self {
969 image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
970 private_base_image: Some(BASE_A),
971 egress: crate::SandboxEgress::Allow,
972 network_id: "net-a",
973 remote_access: false,
974 }
975 }
976
977 fn digest(self) -> String {
978 let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
979 sandbox.egress = self.egress;
980 let mut stack = Stack::new("stack".to_string())
981 .add(
982 crate::Network::new(self.network_id.to_string())
983 .settings(crate::NetworkSettings::Create {
984 cidr: Some("10.0.0.0/16".to_string()),
985 availability_zones: 2,
986 })
987 .build(),
988 ResourceLifecycle::Frozen,
989 )
990 .add(sandbox, ResourceLifecycle::Live)
991 .build();
992 stack
993 .resources
994 .get_mut("agents")
995 .expect("sandbox")
996 .remote_access = self.remote_access;
997 let entry = &stack.resources["agents"];
998 crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
999 &stack,
1000 entry.config.downcast_ref().expect("sandbox"),
1001 entry.lifecycle,
1002 crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
1003 )
1004 .expect("the inputs resolve, so no case is compared by its whole configuration");
1005 stack.setup_owned_digest()
1006 }
1007 }
1008
1009 #[test]
1010 fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
1011 let installed = LiveSandbox::installed().digest();
1012
1013 for (unchanged, why) in [
1014 (
1015 LiveSandbox {
1016 image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
1017 ..LiveSandbox::installed()
1018 },
1019 "a new bundle under the same prefix",
1020 ),
1021 (
1022 LiveSandbox {
1023 private_base_image: Some(
1024 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
1025 ),
1026 ..LiveSandbox::installed()
1027 },
1028 "a new tag",
1029 ),
1030 (
1031 LiveSandbox {
1032 private_base_image: Some(
1033 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
1034 ),
1035 ..LiveSandbox::installed()
1036 },
1037 "a digest",
1038 ),
1039 (
1040 LiveSandbox {
1041 private_base_image: Some(
1042 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
1043 ),
1044 ..LiveSandbox::installed()
1045 },
1046 "another repository",
1047 ),
1048 (
1049 LiveSandbox {
1050 private_base_image: Some(
1051 "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
1052 ),
1053 ..LiveSandbox::installed()
1054 },
1055 "another account",
1056 ),
1057 (
1058 LiveSandbox {
1059 private_base_image: Some(
1060 "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
1061 ),
1062 ..LiveSandbox::installed()
1063 },
1064 "the deployment's region",
1065 ),
1066 (
1067 LiveSandbox {
1068 image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
1069 ..LiveSandbox::installed()
1070 },
1071 "another bundle bucket",
1072 ),
1073 ] {
1074 assert_eq!(
1075 installed,
1076 unchanged.digest(),
1077 "{why} is the runtime's to roll: a Live sandbox builds from the store and repository its setup granted"
1078 );
1079 }
1080
1081 for (changed, what) in [
1082 (
1083 LiveSandbox {
1084 private_base_image: None,
1085 ..LiveSandbox::installed()
1086 },
1087 "a dropped private base",
1088 ),
1089 (
1090 LiveSandbox {
1091 egress: crate::SandboxEgress::None,
1092 ..LiveSandbox::installed()
1093 },
1094 "egress allow to deny",
1095 ),
1096 (
1097 LiveSandbox {
1098 image: "s3://bucket/other-prefix/v1/bundle.zip",
1099 ..LiveSandbox::installed()
1100 },
1101 "another bundle prefix",
1102 ),
1103 (
1104 LiveSandbox {
1105 remote_access: true,
1106 ..LiveSandbox::installed()
1107 },
1108 "a remote grant",
1109 ),
1110 (
1111 LiveSandbox {
1112 private_base_image: Some(
1113 "123456789012.dkr.ecr.us-east-1.amazonaws.com/cache/team/base-a:1",
1114 ),
1115 ..LiveSandbox::installed()
1116 },
1117 "a base repository in another namespace",
1118 ),
1119 (
1120 LiveSandbox {
1121 private_base_image: Some(
1122 "123456789012.dkr.ecr.us-east-1.amazonaws.com/base-a:1",
1123 ),
1124 ..LiveSandbox::installed()
1125 },
1126 "a base repository with no namespace",
1127 ),
1128 ] {
1129 assert_ne!(installed, changed.digest(), "{what} is setup-owned");
1130 }
1131
1132 let deny = |network_id| {
1133 LiveSandbox {
1134 egress: crate::SandboxEgress::None,
1135 network_id,
1136 ..LiveSandbox::installed()
1137 }
1138 .digest()
1139 };
1140 assert_ne!(
1141 deny("net-a"),
1142 deny("net-b"),
1143 "the connector's network is setup-owned"
1144 );
1145 }
1146
1147 #[test]
1148 fn setup_owned_digest_is_order_independent() {
1149 let first = Stack::new("first".to_string())
1150 .add(
1151 Storage::new("alpha".to_string()).build(),
1152 ResourceLifecycle::Frozen,
1153 )
1154 .add(
1155 Storage::new("beta".to_string()).build(),
1156 ResourceLifecycle::Frozen,
1157 )
1158 .add(
1159 Storage::new("live-one".to_string()).build(),
1160 ResourceLifecycle::Live,
1161 )
1162 .build();
1163 let second = Stack::new("second".to_string())
1164 .add(
1165 Storage::new("beta".to_string()).build(),
1166 ResourceLifecycle::Frozen,
1167 )
1168 .add(
1169 Storage::new("alpha".to_string()).build(),
1170 ResourceLifecycle::Frozen,
1171 )
1172 .add(
1173 Storage::new("live-two".to_string()).build(),
1174 ResourceLifecycle::Live,
1175 )
1176 .build();
1177
1178 assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
1179 }
1180}