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