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]
374 fn remote_bindings_require_a_registered_frozen_resource_and_opt_in() {
375 assert!(resource_entry(
376 Storage::new("archive".to_string()).build(),
377 ResourceLifecycle::Frozen,
378 true,
379 )
380 .has_remote_bindings());
381 assert!(!resource_entry(
382 Storage::new("archive".to_string()).build(),
383 ResourceLifecycle::Frozen,
384 false,
385 )
386 .has_remote_bindings());
387 assert!(!resource_entry(
388 Storage::new("archive".to_string()).build(),
389 ResourceLifecycle::Live,
390 true,
391 )
392 .has_remote_bindings());
393 assert!(!resource_entry(
394 Worker::new("worker".to_string())
395 .code(WorkerCode::Image {
396 image: "example.com/worker:latest".to_string(),
397 })
398 .permissions("worker-execution".to_string())
399 .build(),
400 ResourceLifecycle::Frozen,
401 true,
402 )
403 .has_remote_bindings());
404 }
405
406 #[test]
407 fn test_stack_serialization() {
408 use crate::WorkerCode;
409
410 let storage = Storage::new("my-bucket".to_string())
411 .public_read(true)
412 .build();
413
414 let worker = Worker::new("my-worker".to_string())
415 .code(WorkerCode::Image {
416 image: "rust:latest".to_string(),
417 })
418 .permissions("execution".to_string())
419 .link(&storage)
420 .build();
421
422 let mut permissions = IndexMap::new();
424 let mut execution_profile = PermissionProfile::new();
425 execution_profile.0.insert(
426 "*".to_string(),
427 vec![
428 PermissionSetReference::from_name("storage/data-read"),
429 PermissionSetReference::from_name("storage/data-write"),
430 ],
431 );
432 permissions.insert("execution".to_string(), execution_profile);
433
434 let stack_builder = Stack::new("test-stack".to_string())
435 .add(storage, ResourceLifecycle::Frozen)
436 .add(worker.clone(), ResourceLifecycle::Live);
437
438 let stack = stack_builder
439 .permissions(PermissionsConfig {
440 profiles: permissions,
441 management: ManagementPermissions::Auto,
442 })
443 .build();
444
445 let serialized_stack =
447 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
448 let deserialized_stack: Stack =
449 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
450
451 assert_eq!(
453 stack, deserialized_stack,
454 "Original and deserialized stacks do not match."
455 );
456
457 let mut settings = insta::Settings::clone_current();
459 settings.set_sort_maps(true);
460 settings.bind(|| {
461 assert_json_snapshot!("stack_serialization_account_managed", stack);
462 });
463 }
464
465 #[test]
466 fn test_empty_stack_serialization() {
467 let stack_builder = Stack::new("empty-test-stack".to_string());
468
469 let stack = stack_builder
470 .permissions(PermissionsConfig::new()) .build();
472
473 let serialized_stack =
475 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
476 let deserialized_stack: Stack =
477 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
478
479 assert_eq!(
481 stack, deserialized_stack,
482 "Original and deserialized empty stacks do not match."
483 );
484
485 let mut settings = insta::Settings::clone_current();
487 settings.set_sort_maps(true);
488 settings.bind(|| {
489 assert_json_snapshot!("empty_stack_serialization_account", stack);
490 });
491 }
492
493 #[test]
494 fn stack_deserializes_resources_without_public_endpoints() {
495 let container = Container::new("api".to_string())
496 .code(ContainerCode::Image {
497 image: "example.com/api:latest".to_string(),
498 })
499 .cpu(ResourceSpec {
500 min: "0.5".to_string(),
501 desired: "1".to_string(),
502 })
503 .memory(ResourceSpec {
504 min: "512Mi".to_string(),
505 desired: "1Gi".to_string(),
506 })
507 .port(8080)
508 .permissions("container-execution".to_string())
509 .build();
510 let daemon = Daemon::new("agent".to_string())
511 .code(DaemonCode::Image {
512 image: "example.com/agent:latest".to_string(),
513 })
514 .permissions("daemon-execution".to_string())
515 .build();
516 let worker = Worker::new("worker".to_string())
517 .code(WorkerCode::Image {
518 image: "example.com/worker:latest".to_string(),
519 })
520 .permissions("worker-execution".to_string())
521 .build();
522 let stack = Stack::new("legacy-stack".to_string())
523 .add(container, ResourceLifecycle::Live)
524 .add(daemon, ResourceLifecycle::Live)
525 .add(worker, ResourceLifecycle::Live)
526 .build();
527
528 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
529 for resource_id in ["api", "agent", "worker"] {
530 legacy_json
531 .pointer_mut(&format!("/resources/{resource_id}/config"))
532 .and_then(serde_json::Value::as_object_mut)
533 .expect("resource config should be an object")
534 .remove("publicEndpoints");
535 }
536
537 let stack: Stack =
538 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
539
540 let container = stack
541 .resources
542 .get("api")
543 .and_then(|entry| entry.config.downcast_ref::<Container>())
544 .expect("api should be a container");
545 assert!(container.public_endpoints.is_empty());
546
547 let daemon = stack
548 .resources
549 .get("agent")
550 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
551 .expect("agent should be a daemon");
552 assert!(daemon.public_endpoints.is_empty());
553
554 let worker = stack
555 .resources
556 .get("worker")
557 .and_then(|entry| entry.config.downcast_ref::<Worker>())
558 .expect("worker should be a worker");
559 assert!(worker.public_endpoints.is_empty());
560 }
561
562 #[test]
563 fn test_stack_with_permissions() {
564 use crate::permissions::PermissionProfile;
565 use indexmap::IndexMap;
566
567 let storage = Storage::new("test-storage".to_string()).build();
569
570 let mut permission_profile = PermissionProfile::new();
572 permission_profile.0.insert(
573 "*".to_string(),
574 vec![PermissionSetReference::from_name("storage/data-read")],
575 );
576
577 let mut permissions = IndexMap::new();
578 permissions.insert("reader".to_string(), permission_profile);
579
580 let stack = Stack::new("test-permissions-stack".to_string())
581 .add(storage, ResourceLifecycle::Frozen)
582 .permissions(PermissionsConfig {
583 profiles: permissions,
584 management: ManagementPermissions::Auto,
585 })
586 .build();
587
588 assert_eq!(stack.permission_profiles().len(), 1);
590 assert!(stack.permission_profiles().contains_key("reader"));
591
592 let reader_profile = stack.permission_profiles().get("reader").unwrap();
593 assert_eq!(reader_profile.0.len(), 1);
594 assert!(reader_profile.0.contains_key("*"));
595
596 let global_permissions = reader_profile.0.get("*").unwrap();
597 assert_eq!(
598 global_permissions,
599 &vec![PermissionSetReference::from_name("storage/data-read")]
600 );
601
602 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
604 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
605 assert_eq!(stack, deserialized);
606 }
607
608 #[test]
609 fn test_stack_with_management_permissions() {
610 use crate::permissions::{ManagementPermissions, PermissionProfile};
611
612 let storage = Storage::new("test-storage".to_string()).build();
614
615 let mut management_profile = PermissionProfile::new();
617 management_profile.0.insert(
618 "*".to_string(),
619 vec![PermissionSetReference::from_name("vault/data-write")],
620 );
621
622 let stack_auto = Stack::new("test-auto-management-stack".to_string())
624 .add(storage.clone(), ResourceLifecycle::Frozen)
625 .management(ManagementPermissions::auto())
626 .build();
627
628 assert!(stack_auto.management().is_auto());
629 assert!(stack_auto.management().profile().is_none());
630
631 let stack_extend = Stack::new("test-extend-management-stack".to_string())
633 .add(storage.clone(), ResourceLifecycle::Frozen)
634 .management(ManagementPermissions::extend(management_profile.clone()))
635 .build();
636
637 assert!(stack_extend.management().is_extend());
638 assert_eq!(
639 stack_extend.management().profile().unwrap(),
640 &management_profile
641 );
642
643 let stack_override = Stack::new("test-override-management-stack".to_string())
645 .add(storage.clone(), ResourceLifecycle::Frozen)
646 .management(ManagementPermissions::override_(management_profile.clone()))
647 .build();
648
649 assert!(stack_override.management().is_override());
650 assert_eq!(
651 stack_override.management().profile().unwrap(),
652 &management_profile
653 );
654
655 let stack_default = Stack::new("test-default-management-stack".to_string())
657 .add(storage, ResourceLifecycle::Frozen)
658 .build();
659
660 assert!(stack_default.management().is_auto());
661
662 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
664 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
665 assert_eq!(stack_extend, deserialized);
666 }
667
668 #[test]
669 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
670 let first = Stack::new("first".to_string())
671 .add(
672 Storage::new("alpha".to_string()).build(),
673 ResourceLifecycle::Frozen,
674 )
675 .add(
676 Storage::new("beta".to_string()).build(),
677 ResourceLifecycle::Frozen,
678 )
679 .add(
680 Storage::new("live-one".to_string()).build(),
681 ResourceLifecycle::Live,
682 )
683 .build();
684 let second = Stack::new("second".to_string())
685 .add(
686 Storage::new("beta".to_string()).build(),
687 ResourceLifecycle::Frozen,
688 )
689 .add(
690 Storage::new("alpha".to_string()).build(),
691 ResourceLifecycle::Frozen,
692 )
693 .add(
694 Storage::new("live-two".to_string()).build(),
695 ResourceLifecycle::Live,
696 )
697 .build();
698
699 assert_eq!(
700 first.frozen_resources_digest(),
701 second.frozen_resources_digest()
702 );
703 }
704}