1use crate::permissions::{ManagementPermissions, PermissionProfile, PermissionsConfig};
2use crate::{Platform, Resource, ResourceLifecycle, ResourceRef, StackInputDefinition};
3use bon::Builder;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10#[serde(rename_all = "camelCase")]
11pub struct ResourceEntry {
12 pub config: Resource,
14 pub lifecycle: ResourceLifecycle,
16 pub dependencies: Vec<ResourceRef>,
19 #[serde(default)]
23 pub remote_access: bool,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub enabled_when: Option<String>,
32}
33
34impl ResourceEntry {
35 pub fn combined_dependencies(&self) -> Vec<ResourceRef> {
37 let mut dependencies = self.config.get_dependencies();
38 dependencies.extend(self.dependencies.clone());
39 dependencies
40 }
41
42 pub fn has_remote_bindings(&self) -> bool {
48 crate::remote_bindings::remote_binding_for_entry(self).is_some()
49 }
50
51 pub fn publishes_binding_params(&self) -> bool {
55 self.remote_access
56 || self
57 .config
58 .downcast_ref::<crate::Vault>()
59 .is_some_and(|vault| vault.id == "secrets")
60 }
61}
62
63#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(rename_all = "camelCase")]
67#[builder(start_fn = new)]
68pub struct Stack {
69 #[builder(start_fn)]
71 pub id: String,
72 #[builder(field)]
74 pub resources: IndexMap<String, ResourceEntry>,
75 #[builder(field)]
77 #[serde(default)]
78 pub permissions: PermissionsConfig,
79 #[builder(field)]
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub supported_platforms: Option<Vec<Platform>>,
83 #[builder(field)]
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub inputs: Vec<StackInputDefinition>,
87 #[builder(field)]
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub dynamic_container_repositories: Vec<String>,
92 #[builder(field)]
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub dynamic_container_image_resources: Vec<String>,
97}
98
99impl Stack {
100 pub fn frozen_resources_digest(&self) -> String {
103 let mut resources = self
104 .resources
105 .iter()
106 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
107 .map(|(id, entry)| {
108 let mut value =
109 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
110 canonicalize_json(&mut value);
111 (id, value)
112 })
113 .collect::<Vec<_>>();
114 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
115
116 let encoded = serde_json::to_vec(&resources)
117 .expect("canonical Frozen resource projection always serializes");
118 format!("{:x}", Sha256::digest(encoded))
119 }
120 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
122 self.resources.iter()
123 }
124
125 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
127 self.resources.iter_mut()
128 }
129
130 pub fn id(&self) -> &str {
131 &self.id
132 }
133
134 pub fn current() -> StackRef {
136 StackRef::Current
137 }
138
139 pub fn permissions(&self) -> &PermissionsConfig {
141 &self.permissions
142 }
143
144 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
146 &self.permissions.profiles
147 }
148
149 pub fn management(&self) -> &ManagementPermissions {
151 &self.permissions.management
152 }
153
154 pub fn supported_platforms(&self) -> Option<&[Platform]> {
156 self.supported_platforms.as_deref()
157 }
158
159 pub fn inputs(&self) -> &[StackInputDefinition] {
161 &self.inputs
162 }
163
164 pub fn supports_platform(&self, platform: &Platform) -> bool {
167 match &self.supported_platforms {
168 Some(platforms) => platforms.contains(platform),
169 None => true,
170 }
171 }
172}
173
174fn canonicalize_json(value: &mut serde_json::Value) {
175 match value {
176 serde_json::Value::Array(values) => {
177 for value in values {
178 canonicalize_json(value);
179 }
180 }
181 serde_json::Value::Object(object) => {
182 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
183 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
184 for (key, mut value) in entries {
185 canonicalize_json(&mut value);
186 object.insert(key, value);
187 }
188 }
189 _ => {}
190 }
191}
192
193impl StackBuilder {
194 pub fn add<T: crate::ResourceDefinition>(
198 self,
199 resource: T,
200 lifecycle: ResourceLifecycle,
201 ) -> Self {
202 self.add_with_dependencies(resource, lifecycle, vec![])
203 }
204
205 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
208 self,
209 resource: T,
210 lifecycle: ResourceLifecycle,
211 additional_dependencies: Vec<ResourceRef>,
212 ) -> Self {
213 let mut entry = Self::entry(resource, lifecycle);
214 entry.dependencies = additional_dependencies;
215 self.insert(entry)
216 }
217
218 #[doc(hidden)]
225 pub fn add_enabled_when<T: crate::ResourceDefinition>(
226 self,
227 resource: T,
228 lifecycle: ResourceLifecycle,
229 input_id: impl Into<String>,
230 ) -> Self {
231 let mut entry = Self::entry(resource, lifecycle);
232 entry.enabled_when = Some(input_id.into());
233 self.insert(entry)
234 }
235
236 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
239 self,
240 resource: T,
241 lifecycle: ResourceLifecycle,
242 ) -> Self {
243 let mut entry = Self::entry(resource, lifecycle);
244 entry.remote_access = true;
245 self.insert(entry)
246 }
247
248 fn entry<T: crate::ResourceDefinition>(
252 resource: T,
253 lifecycle: ResourceLifecycle,
254 ) -> ResourceEntry {
255 ResourceEntry {
256 config: Resource::new(resource),
257 lifecycle,
258 dependencies: Vec::new(),
259 remote_access: false,
260 enabled_when: None,
261 }
262 }
263
264 fn insert(mut self, entry: ResourceEntry) -> Self {
265 self.resources.insert(entry.config.id().to_string(), entry);
266 self
267 }
268
269 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
272 self.permissions = permissions;
273 self
274 }
275
276 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
288 self.permissions.profiles.insert(name.into(), profile);
289 self
290 }
291
292 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
294 self.supported_platforms = Some(platforms);
295 self
296 }
297
298 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
300 self.inputs = inputs;
301 self
302 }
303
304 pub fn management(mut self, management: ManagementPermissions) -> Self {
330 self.permissions.management = management;
331 self
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
338#[serde(rename_all = "camelCase")]
339pub enum StackRef {
340 Current,
342 External(String),
344}
345
346impl StackRef {
347 pub fn from_stack(stack: &Stack) -> Self {
349 StackRef::External(stack.id().to_string())
350 }
351}
352
353impl From<&Stack> for StackRef {
354 fn from(stack: &Stack) -> Self {
355 StackRef::External(stack.id().to_string())
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::resource::ResourceLifecycle;
363 use crate::{
364 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
365 Storage, Worker, WorkerCode,
366 };
367 use insta::assert_json_snapshot;
368
369 fn resource_entry<T: crate::ResourceDefinition>(
370 resource: T,
371 lifecycle: ResourceLifecycle,
372 remote_access: bool,
373 ) -> ResourceEntry {
374 ResourceEntry {
375 config: Resource::new(resource),
376 lifecycle,
377 dependencies: Vec::new(),
378 remote_access,
379 enabled_when: None,
380 }
381 }
382
383 #[test]
386 fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
387 assert!(resource_entry(
388 Storage::new("archive".to_string()).build(),
389 ResourceLifecycle::Frozen,
390 true,
391 )
392 .has_remote_bindings());
393 assert!(!resource_entry(
394 Storage::new("archive".to_string()).build(),
395 ResourceLifecycle::Frozen,
396 false,
397 )
398 .has_remote_bindings());
399 assert!(!resource_entry(
400 Storage::new("archive".to_string()).build(),
401 ResourceLifecycle::Live,
402 true,
403 )
404 .has_remote_bindings());
405 let sandbox = crate::Sandbox::new("agents".to_string())
406 .code(crate::SandboxCode::Image {
407 image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
408 })
409 .egress(crate::SandboxEgress::Allow)
410 .lifecycle(crate::SandboxLifecyclePolicy {
411 max_lifetime_seconds: None,
412 idle_pause_seconds: None,
413 })
414 .build();
415 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
416 assert!(!resource_entry(
417 Worker::new("worker".to_string())
418 .code(WorkerCode::Image {
419 image: "example.com/worker:latest".to_string(),
420 })
421 .permissions("worker-execution".to_string())
422 .build(),
423 ResourceLifecycle::Frozen,
424 true,
425 )
426 .has_remote_bindings());
427 }
428
429 #[test]
430 fn test_stack_serialization() {
431 use crate::WorkerCode;
432
433 let storage = Storage::new("my-bucket".to_string())
434 .public_read(true)
435 .build();
436
437 let worker = Worker::new("my-worker".to_string())
438 .code(WorkerCode::Image {
439 image: "rust:latest".to_string(),
440 })
441 .permissions("execution".to_string())
442 .link(&storage)
443 .build();
444
445 let mut permissions = IndexMap::new();
447 let mut execution_profile = PermissionProfile::new();
448 execution_profile.0.insert(
449 "*".to_string(),
450 vec![
451 PermissionSetReference::from_name("storage/data-read"),
452 PermissionSetReference::from_name("storage/data-write"),
453 ],
454 );
455 permissions.insert("execution".to_string(), execution_profile);
456
457 let stack_builder = Stack::new("test-stack".to_string())
458 .add(storage, ResourceLifecycle::Frozen)
459 .add(worker.clone(), ResourceLifecycle::Live);
460
461 let stack = stack_builder
462 .permissions(PermissionsConfig {
463 profiles: permissions,
464 management: ManagementPermissions::Auto,
465 })
466 .build();
467
468 let serialized_stack =
470 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
471 let deserialized_stack: Stack =
472 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
473
474 assert_eq!(
476 stack, deserialized_stack,
477 "Original and deserialized stacks do not match."
478 );
479
480 let mut settings = insta::Settings::clone_current();
482 settings.set_sort_maps(true);
483 settings.bind(|| {
484 assert_json_snapshot!("stack_serialization_account_managed", stack);
485 });
486 }
487
488 #[test]
489 fn test_empty_stack_serialization() {
490 let stack_builder = Stack::new("empty-test-stack".to_string());
491
492 let stack = stack_builder
493 .permissions(PermissionsConfig::new()) .build();
495
496 let serialized_stack =
498 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
499 let deserialized_stack: Stack =
500 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
501
502 assert_eq!(
504 stack, deserialized_stack,
505 "Original and deserialized empty stacks do not match."
506 );
507
508 let mut settings = insta::Settings::clone_current();
510 settings.set_sort_maps(true);
511 settings.bind(|| {
512 assert_json_snapshot!("empty_stack_serialization_account", stack);
513 });
514 }
515
516 #[test]
517 fn stack_deserializes_resources_without_public_endpoints() {
518 let container = Container::new("api".to_string())
519 .code(ContainerCode::Image {
520 image: "example.com/api:latest".to_string(),
521 })
522 .cpu(ResourceSpec {
523 min: "0.5".to_string(),
524 desired: "1".to_string(),
525 })
526 .memory(ResourceSpec {
527 min: "512Mi".to_string(),
528 desired: "1Gi".to_string(),
529 })
530 .port(8080)
531 .permissions("container-execution".to_string())
532 .build();
533 let daemon = Daemon::new("agent".to_string())
534 .code(DaemonCode::Image {
535 image: "example.com/agent:latest".to_string(),
536 })
537 .permissions("daemon-execution".to_string())
538 .build();
539 let worker = Worker::new("worker".to_string())
540 .code(WorkerCode::Image {
541 image: "example.com/worker:latest".to_string(),
542 })
543 .permissions("worker-execution".to_string())
544 .build();
545 let stack = Stack::new("legacy-stack".to_string())
546 .add(container, ResourceLifecycle::Live)
547 .add(daemon, ResourceLifecycle::Live)
548 .add(worker, ResourceLifecycle::Live)
549 .build();
550
551 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
552 for resource_id in ["api", "agent", "worker"] {
553 legacy_json
554 .pointer_mut(&format!("/resources/{resource_id}/config"))
555 .and_then(serde_json::Value::as_object_mut)
556 .expect("resource config should be an object")
557 .remove("publicEndpoints");
558 }
559
560 let stack: Stack =
561 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
562
563 let container = stack
564 .resources
565 .get("api")
566 .and_then(|entry| entry.config.downcast_ref::<Container>())
567 .expect("api should be a container");
568 assert!(container.public_endpoints.is_empty());
569
570 let daemon = stack
571 .resources
572 .get("agent")
573 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
574 .expect("agent should be a daemon");
575 assert!(daemon.public_endpoints.is_empty());
576
577 let worker = stack
578 .resources
579 .get("worker")
580 .and_then(|entry| entry.config.downcast_ref::<Worker>())
581 .expect("worker should be a worker");
582 assert!(worker.public_endpoints.is_empty());
583 }
584
585 #[test]
586 fn test_stack_with_permissions() {
587 use crate::permissions::PermissionProfile;
588 use indexmap::IndexMap;
589
590 let storage = Storage::new("test-storage".to_string()).build();
592
593 let mut permission_profile = PermissionProfile::new();
595 permission_profile.0.insert(
596 "*".to_string(),
597 vec![PermissionSetReference::from_name("storage/data-read")],
598 );
599
600 let mut permissions = IndexMap::new();
601 permissions.insert("reader".to_string(), permission_profile);
602
603 let stack = Stack::new("test-permissions-stack".to_string())
604 .add(storage, ResourceLifecycle::Frozen)
605 .permissions(PermissionsConfig {
606 profiles: permissions,
607 management: ManagementPermissions::Auto,
608 })
609 .build();
610
611 assert_eq!(stack.permission_profiles().len(), 1);
613 assert!(stack.permission_profiles().contains_key("reader"));
614
615 let reader_profile = stack.permission_profiles().get("reader").unwrap();
616 assert_eq!(reader_profile.0.len(), 1);
617 assert!(reader_profile.0.contains_key("*"));
618
619 let global_permissions = reader_profile.0.get("*").unwrap();
620 assert_eq!(
621 global_permissions,
622 &vec![PermissionSetReference::from_name("storage/data-read")]
623 );
624
625 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
627 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
628 assert_eq!(stack, deserialized);
629 }
630
631 #[test]
632 fn test_stack_with_management_permissions() {
633 use crate::permissions::{ManagementPermissions, PermissionProfile};
634
635 let storage = Storage::new("test-storage".to_string()).build();
637
638 let mut management_profile = PermissionProfile::new();
640 management_profile.0.insert(
641 "*".to_string(),
642 vec![PermissionSetReference::from_name("vault/data-write")],
643 );
644
645 let stack_auto = Stack::new("test-auto-management-stack".to_string())
647 .add(storage.clone(), ResourceLifecycle::Frozen)
648 .management(ManagementPermissions::auto())
649 .build();
650
651 assert!(stack_auto.management().is_auto());
652 assert!(stack_auto.management().profile().is_none());
653
654 let stack_extend = Stack::new("test-extend-management-stack".to_string())
656 .add(storage.clone(), ResourceLifecycle::Frozen)
657 .management(ManagementPermissions::extend(management_profile.clone()))
658 .build();
659
660 assert!(stack_extend.management().is_extend());
661 assert_eq!(
662 stack_extend.management().profile().unwrap(),
663 &management_profile
664 );
665
666 let stack_override = Stack::new("test-override-management-stack".to_string())
668 .add(storage.clone(), ResourceLifecycle::Frozen)
669 .management(ManagementPermissions::override_(management_profile.clone()))
670 .build();
671
672 assert!(stack_override.management().is_override());
673 assert_eq!(
674 stack_override.management().profile().unwrap(),
675 &management_profile
676 );
677
678 let stack_default = Stack::new("test-default-management-stack".to_string())
680 .add(storage, ResourceLifecycle::Frozen)
681 .build();
682
683 assert!(stack_default.management().is_auto());
684
685 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
687 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
688 assert_eq!(stack_extend, deserialized);
689 }
690
691 #[test]
692 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
693 let first = Stack::new("first".to_string())
694 .add(
695 Storage::new("alpha".to_string()).build(),
696 ResourceLifecycle::Frozen,
697 )
698 .add(
699 Storage::new("beta".to_string()).build(),
700 ResourceLifecycle::Frozen,
701 )
702 .add(
703 Storage::new("live-one".to_string()).build(),
704 ResourceLifecycle::Live,
705 )
706 .build();
707 let second = Stack::new("second".to_string())
708 .add(
709 Storage::new("beta".to_string()).build(),
710 ResourceLifecycle::Frozen,
711 )
712 .add(
713 Storage::new("alpha".to_string()).build(),
714 ResourceLifecycle::Frozen,
715 )
716 .add(
717 Storage::new("live-two".to_string()).build(),
718 ResourceLifecycle::Live,
719 )
720 .build();
721
722 assert_eq!(
723 first.frozen_resources_digest(),
724 second.frozen_resources_digest()
725 );
726 }
727}