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}
88
89impl Stack {
90 pub fn frozen_resources_digest(&self) -> String {
93 let mut resources = self
94 .resources
95 .iter()
96 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
97 .map(|(id, entry)| {
98 let mut value =
99 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
100 canonicalize_json(&mut value);
101 (id, value)
102 })
103 .collect::<Vec<_>>();
104 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
105
106 let encoded = serde_json::to_vec(&resources)
107 .expect("canonical Frozen resource projection always serializes");
108 format!("{:x}", Sha256::digest(encoded))
109 }
110 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
112 self.resources.iter()
113 }
114
115 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
117 self.resources.iter_mut()
118 }
119
120 pub fn id(&self) -> &str {
121 &self.id
122 }
123
124 pub fn current() -> StackRef {
126 StackRef::Current
127 }
128
129 pub fn permissions(&self) -> &PermissionsConfig {
131 &self.permissions
132 }
133
134 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
136 &self.permissions.profiles
137 }
138
139 pub fn management(&self) -> &ManagementPermissions {
141 &self.permissions.management
142 }
143
144 pub fn supported_platforms(&self) -> Option<&[Platform]> {
146 self.supported_platforms.as_deref()
147 }
148
149 pub fn inputs(&self) -> &[StackInputDefinition] {
151 &self.inputs
152 }
153
154 pub fn supports_platform(&self, platform: &Platform) -> bool {
157 match &self.supported_platforms {
158 Some(platforms) => platforms.contains(platform),
159 None => true,
160 }
161 }
162}
163
164fn canonicalize_json(value: &mut serde_json::Value) {
165 match value {
166 serde_json::Value::Array(values) => {
167 for value in values {
168 canonicalize_json(value);
169 }
170 }
171 serde_json::Value::Object(object) => {
172 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
173 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
174 for (key, mut value) in entries {
175 canonicalize_json(&mut value);
176 object.insert(key, value);
177 }
178 }
179 _ => {}
180 }
181}
182
183impl StackBuilder {
184 pub fn add<T: crate::ResourceDefinition>(
188 self,
189 resource: T,
190 lifecycle: ResourceLifecycle,
191 ) -> Self {
192 self.add_with_dependencies(resource, lifecycle, vec![])
193 }
194
195 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
198 self,
199 resource: T,
200 lifecycle: ResourceLifecycle,
201 additional_dependencies: Vec<ResourceRef>,
202 ) -> Self {
203 let mut entry = Self::entry(resource, lifecycle);
204 entry.dependencies = additional_dependencies;
205 self.insert(entry)
206 }
207
208 #[doc(hidden)]
215 pub fn add_enabled_when<T: crate::ResourceDefinition>(
216 self,
217 resource: T,
218 lifecycle: ResourceLifecycle,
219 input_id: impl Into<String>,
220 ) -> Self {
221 let mut entry = Self::entry(resource, lifecycle);
222 entry.enabled_when = Some(input_id.into());
223 self.insert(entry)
224 }
225
226 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
229 self,
230 resource: T,
231 lifecycle: ResourceLifecycle,
232 ) -> Self {
233 let mut entry = Self::entry(resource, lifecycle);
234 entry.remote_access = true;
235 self.insert(entry)
236 }
237
238 fn entry<T: crate::ResourceDefinition>(
242 resource: T,
243 lifecycle: ResourceLifecycle,
244 ) -> ResourceEntry {
245 ResourceEntry {
246 config: Resource::new(resource),
247 lifecycle,
248 dependencies: Vec::new(),
249 remote_access: false,
250 enabled_when: None,
251 }
252 }
253
254 fn insert(mut self, entry: ResourceEntry) -> Self {
255 self.resources.insert(entry.config.id().to_string(), entry);
256 self
257 }
258
259 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
262 self.permissions = permissions;
263 self
264 }
265
266 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
278 self.permissions.profiles.insert(name.into(), profile);
279 self
280 }
281
282 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
284 self.supported_platforms = Some(platforms);
285 self
286 }
287
288 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
290 self.inputs = inputs;
291 self
292 }
293
294 pub fn management(mut self, management: ManagementPermissions) -> Self {
320 self.permissions.management = management;
321 self
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
328#[serde(rename_all = "camelCase")]
329pub enum StackRef {
330 Current,
332 External(String),
334}
335
336impl StackRef {
337 pub fn from_stack(stack: &Stack) -> Self {
339 StackRef::External(stack.id().to_string())
340 }
341}
342
343impl From<&Stack> for StackRef {
344 fn from(stack: &Stack) -> Self {
345 StackRef::External(stack.id().to_string())
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::resource::ResourceLifecycle;
353 use crate::{
354 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
355 Storage, Worker, WorkerCode,
356 };
357 use insta::assert_json_snapshot;
358
359 fn resource_entry<T: crate::ResourceDefinition>(
360 resource: T,
361 lifecycle: ResourceLifecycle,
362 remote_access: bool,
363 ) -> ResourceEntry {
364 ResourceEntry {
365 config: Resource::new(resource),
366 lifecycle,
367 dependencies: Vec::new(),
368 remote_access,
369 enabled_when: None,
370 }
371 }
372
373 #[test]
376 fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
377 assert!(resource_entry(
378 Storage::new("archive".to_string()).build(),
379 ResourceLifecycle::Frozen,
380 true,
381 )
382 .has_remote_bindings());
383 assert!(!resource_entry(
384 Storage::new("archive".to_string()).build(),
385 ResourceLifecycle::Frozen,
386 false,
387 )
388 .has_remote_bindings());
389 assert!(!resource_entry(
390 Storage::new("archive".to_string()).build(),
391 ResourceLifecycle::Live,
392 true,
393 )
394 .has_remote_bindings());
395 let sandbox = crate::Sandbox::new("agents".to_string())
396 .code(crate::SandboxCode::Image {
397 image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
398 })
399 .egress(crate::SandboxEgress::Allow)
400 .lifecycle(crate::SandboxLifecyclePolicy {
401 max_lifetime_seconds: None,
402 idle_pause_seconds: None,
403 })
404 .build();
405 assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
406 assert!(!resource_entry(
407 Worker::new("worker".to_string())
408 .code(WorkerCode::Image {
409 image: "example.com/worker:latest".to_string(),
410 })
411 .permissions("worker-execution".to_string())
412 .build(),
413 ResourceLifecycle::Frozen,
414 true,
415 )
416 .has_remote_bindings());
417 }
418
419 #[test]
420 fn test_stack_serialization() {
421 use crate::WorkerCode;
422
423 let storage = Storage::new("my-bucket".to_string())
424 .public_read(true)
425 .build();
426
427 let worker = Worker::new("my-worker".to_string())
428 .code(WorkerCode::Image {
429 image: "rust:latest".to_string(),
430 })
431 .permissions("execution".to_string())
432 .link(&storage)
433 .build();
434
435 let mut permissions = IndexMap::new();
437 let mut execution_profile = PermissionProfile::new();
438 execution_profile.0.insert(
439 "*".to_string(),
440 vec![
441 PermissionSetReference::from_name("storage/data-read"),
442 PermissionSetReference::from_name("storage/data-write"),
443 ],
444 );
445 permissions.insert("execution".to_string(), execution_profile);
446
447 let stack_builder = Stack::new("test-stack".to_string())
448 .add(storage, ResourceLifecycle::Frozen)
449 .add(worker.clone(), ResourceLifecycle::Live);
450
451 let stack = stack_builder
452 .permissions(PermissionsConfig {
453 profiles: permissions,
454 management: ManagementPermissions::Auto,
455 })
456 .build();
457
458 let serialized_stack =
460 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
461 let deserialized_stack: Stack =
462 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
463
464 assert_eq!(
466 stack, deserialized_stack,
467 "Original and deserialized stacks do not match."
468 );
469
470 let mut settings = insta::Settings::clone_current();
472 settings.set_sort_maps(true);
473 settings.bind(|| {
474 assert_json_snapshot!("stack_serialization_account_managed", stack);
475 });
476 }
477
478 #[test]
479 fn test_empty_stack_serialization() {
480 let stack_builder = Stack::new("empty-test-stack".to_string());
481
482 let stack = stack_builder
483 .permissions(PermissionsConfig::new()) .build();
485
486 let serialized_stack =
488 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
489 let deserialized_stack: Stack =
490 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
491
492 assert_eq!(
494 stack, deserialized_stack,
495 "Original and deserialized empty stacks do not match."
496 );
497
498 let mut settings = insta::Settings::clone_current();
500 settings.set_sort_maps(true);
501 settings.bind(|| {
502 assert_json_snapshot!("empty_stack_serialization_account", stack);
503 });
504 }
505
506 #[test]
507 fn stack_deserializes_resources_without_public_endpoints() {
508 let container = Container::new("api".to_string())
509 .code(ContainerCode::Image {
510 image: "example.com/api:latest".to_string(),
511 })
512 .cpu(ResourceSpec {
513 min: "0.5".to_string(),
514 desired: "1".to_string(),
515 })
516 .memory(ResourceSpec {
517 min: "512Mi".to_string(),
518 desired: "1Gi".to_string(),
519 })
520 .port(8080)
521 .permissions("container-execution".to_string())
522 .build();
523 let daemon = Daemon::new("agent".to_string())
524 .code(DaemonCode::Image {
525 image: "example.com/agent:latest".to_string(),
526 })
527 .permissions("daemon-execution".to_string())
528 .build();
529 let worker = Worker::new("worker".to_string())
530 .code(WorkerCode::Image {
531 image: "example.com/worker:latest".to_string(),
532 })
533 .permissions("worker-execution".to_string())
534 .build();
535 let stack = Stack::new("legacy-stack".to_string())
536 .add(container, ResourceLifecycle::Live)
537 .add(daemon, ResourceLifecycle::Live)
538 .add(worker, ResourceLifecycle::Live)
539 .build();
540
541 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
542 for resource_id in ["api", "agent", "worker"] {
543 legacy_json
544 .pointer_mut(&format!("/resources/{resource_id}/config"))
545 .and_then(serde_json::Value::as_object_mut)
546 .expect("resource config should be an object")
547 .remove("publicEndpoints");
548 }
549
550 let stack: Stack =
551 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
552
553 let container = stack
554 .resources
555 .get("api")
556 .and_then(|entry| entry.config.downcast_ref::<Container>())
557 .expect("api should be a container");
558 assert!(container.public_endpoints.is_empty());
559
560 let daemon = stack
561 .resources
562 .get("agent")
563 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
564 .expect("agent should be a daemon");
565 assert!(daemon.public_endpoints.is_empty());
566
567 let worker = stack
568 .resources
569 .get("worker")
570 .and_then(|entry| entry.config.downcast_ref::<Worker>())
571 .expect("worker should be a worker");
572 assert!(worker.public_endpoints.is_empty());
573 }
574
575 #[test]
576 fn test_stack_with_permissions() {
577 use crate::permissions::PermissionProfile;
578 use indexmap::IndexMap;
579
580 let storage = Storage::new("test-storage".to_string()).build();
582
583 let mut permission_profile = PermissionProfile::new();
585 permission_profile.0.insert(
586 "*".to_string(),
587 vec![PermissionSetReference::from_name("storage/data-read")],
588 );
589
590 let mut permissions = IndexMap::new();
591 permissions.insert("reader".to_string(), permission_profile);
592
593 let stack = Stack::new("test-permissions-stack".to_string())
594 .add(storage, ResourceLifecycle::Frozen)
595 .permissions(PermissionsConfig {
596 profiles: permissions,
597 management: ManagementPermissions::Auto,
598 })
599 .build();
600
601 assert_eq!(stack.permission_profiles().len(), 1);
603 assert!(stack.permission_profiles().contains_key("reader"));
604
605 let reader_profile = stack.permission_profiles().get("reader").unwrap();
606 assert_eq!(reader_profile.0.len(), 1);
607 assert!(reader_profile.0.contains_key("*"));
608
609 let global_permissions = reader_profile.0.get("*").unwrap();
610 assert_eq!(
611 global_permissions,
612 &vec![PermissionSetReference::from_name("storage/data-read")]
613 );
614
615 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
617 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
618 assert_eq!(stack, deserialized);
619 }
620
621 #[test]
622 fn test_stack_with_management_permissions() {
623 use crate::permissions::{ManagementPermissions, PermissionProfile};
624
625 let storage = Storage::new("test-storage".to_string()).build();
627
628 let mut management_profile = PermissionProfile::new();
630 management_profile.0.insert(
631 "*".to_string(),
632 vec![PermissionSetReference::from_name("vault/data-write")],
633 );
634
635 let stack_auto = Stack::new("test-auto-management-stack".to_string())
637 .add(storage.clone(), ResourceLifecycle::Frozen)
638 .management(ManagementPermissions::auto())
639 .build();
640
641 assert!(stack_auto.management().is_auto());
642 assert!(stack_auto.management().profile().is_none());
643
644 let stack_extend = Stack::new("test-extend-management-stack".to_string())
646 .add(storage.clone(), ResourceLifecycle::Frozen)
647 .management(ManagementPermissions::extend(management_profile.clone()))
648 .build();
649
650 assert!(stack_extend.management().is_extend());
651 assert_eq!(
652 stack_extend.management().profile().unwrap(),
653 &management_profile
654 );
655
656 let stack_override = Stack::new("test-override-management-stack".to_string())
658 .add(storage.clone(), ResourceLifecycle::Frozen)
659 .management(ManagementPermissions::override_(management_profile.clone()))
660 .build();
661
662 assert!(stack_override.management().is_override());
663 assert_eq!(
664 stack_override.management().profile().unwrap(),
665 &management_profile
666 );
667
668 let stack_default = Stack::new("test-default-management-stack".to_string())
670 .add(storage, ResourceLifecycle::Frozen)
671 .build();
672
673 assert!(stack_default.management().is_auto());
674
675 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
677 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
678 assert_eq!(stack_extend, deserialized);
679 }
680
681 #[test]
682 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
683 let first = Stack::new("first".to_string())
684 .add(
685 Storage::new("alpha".to_string()).build(),
686 ResourceLifecycle::Frozen,
687 )
688 .add(
689 Storage::new("beta".to_string()).build(),
690 ResourceLifecycle::Frozen,
691 )
692 .add(
693 Storage::new("live-one".to_string()).build(),
694 ResourceLifecycle::Live,
695 )
696 .build();
697 let second = Stack::new("second".to_string())
698 .add(
699 Storage::new("beta".to_string()).build(),
700 ResourceLifecycle::Frozen,
701 )
702 .add(
703 Storage::new("alpha".to_string()).build(),
704 ResourceLifecycle::Frozen,
705 )
706 .add(
707 Storage::new("live-two".to_string()).build(),
708 ResourceLifecycle::Live,
709 )
710 .build();
711
712 assert_eq!(
713 first.frozen_resources_digest(),
714 second.frozen_resources_digest()
715 );
716 }
717}