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 has_remote_bindings(&self) -> bool {
41 self.remote_access
42 }
43
44 pub fn is_remote_frozen_storage(&self) -> bool {
46 self.lifecycle == ResourceLifecycle::Frozen
47 && self.remote_access
48 && self.config.downcast_ref::<crate::Storage>().is_some()
49 }
50}
51
52#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[serde(rename_all = "camelCase")]
56#[builder(start_fn = new)]
57pub struct Stack {
58 #[builder(start_fn)]
60 pub id: String,
61 #[builder(field)]
63 pub resources: IndexMap<String, ResourceEntry>,
64 #[builder(field)]
66 #[serde(default)]
67 pub permissions: PermissionsConfig,
68 #[builder(field)]
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub supported_platforms: Option<Vec<Platform>>,
72 #[builder(field)]
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub inputs: Vec<StackInputDefinition>,
76}
77
78impl Stack {
79 pub fn frozen_resources_digest(&self) -> String {
82 let mut resources = self
83 .resources
84 .iter()
85 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
86 .map(|(id, entry)| {
87 let mut value =
88 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
89 canonicalize_json(&mut value);
90 (id, value)
91 })
92 .collect::<Vec<_>>();
93 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
94
95 let encoded = serde_json::to_vec(&resources)
96 .expect("canonical Frozen resource projection always serializes");
97 format!("{:x}", Sha256::digest(encoded))
98 }
99 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
101 self.resources.iter()
102 }
103
104 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
106 self.resources.iter_mut()
107 }
108
109 pub fn id(&self) -> &str {
110 &self.id
111 }
112
113 pub fn current() -> StackRef {
115 StackRef::Current
116 }
117
118 pub fn permissions(&self) -> &PermissionsConfig {
120 &self.permissions
121 }
122
123 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
125 &self.permissions.profiles
126 }
127
128 pub fn management(&self) -> &ManagementPermissions {
130 &self.permissions.management
131 }
132
133 pub fn supported_platforms(&self) -> Option<&[Platform]> {
135 self.supported_platforms.as_deref()
136 }
137
138 pub fn inputs(&self) -> &[StackInputDefinition] {
140 &self.inputs
141 }
142
143 pub fn supports_platform(&self, platform: &Platform) -> bool {
146 match &self.supported_platforms {
147 Some(platforms) => platforms.contains(platform),
148 None => true,
149 }
150 }
151}
152
153fn canonicalize_json(value: &mut serde_json::Value) {
154 match value {
155 serde_json::Value::Array(values) => {
156 for value in values {
157 canonicalize_json(value);
158 }
159 }
160 serde_json::Value::Object(object) => {
161 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
162 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
163 for (key, mut value) in entries {
164 canonicalize_json(&mut value);
165 object.insert(key, value);
166 }
167 }
168 _ => {}
169 }
170}
171
172impl StackBuilder {
173 pub fn add<T: crate::ResourceDefinition>(
177 self,
178 resource: T,
179 lifecycle: ResourceLifecycle,
180 ) -> Self {
181 self.add_with_dependencies(resource, lifecycle, vec![])
182 }
183
184 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
187 self,
188 resource: T,
189 lifecycle: ResourceLifecycle,
190 additional_dependencies: Vec<ResourceRef>,
191 ) -> Self {
192 let mut entry = Self::entry(resource, lifecycle);
193 entry.dependencies = additional_dependencies;
194 self.insert(entry)
195 }
196
197 #[doc(hidden)]
204 pub fn add_enabled_when<T: crate::ResourceDefinition>(
205 self,
206 resource: T,
207 lifecycle: ResourceLifecycle,
208 input_id: impl Into<String>,
209 ) -> Self {
210 let mut entry = Self::entry(resource, lifecycle);
211 entry.enabled_when = Some(input_id.into());
212 self.insert(entry)
213 }
214
215 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
218 self,
219 resource: T,
220 lifecycle: ResourceLifecycle,
221 ) -> Self {
222 let mut entry = Self::entry(resource, lifecycle);
223 entry.remote_access = true;
224 self.insert(entry)
225 }
226
227 fn entry<T: crate::ResourceDefinition>(
231 resource: T,
232 lifecycle: ResourceLifecycle,
233 ) -> ResourceEntry {
234 ResourceEntry {
235 config: Resource::new(resource),
236 lifecycle,
237 dependencies: Vec::new(),
238 remote_access: false,
239 enabled_when: None,
240 }
241 }
242
243 fn insert(mut self, entry: ResourceEntry) -> Self {
244 self.resources.insert(entry.config.id().to_string(), entry);
245 self
246 }
247
248 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
251 self.permissions = permissions;
252 self
253 }
254
255 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
267 self.permissions.profiles.insert(name.into(), profile);
268 self
269 }
270
271 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
273 self.supported_platforms = Some(platforms);
274 self
275 }
276
277 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
279 self.inputs = inputs;
280 self
281 }
282
283 pub fn management(mut self, management: ManagementPermissions) -> Self {
309 self.permissions.management = management;
310 self
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
317#[serde(rename_all = "camelCase")]
318pub enum StackRef {
319 Current,
321 External(String),
323}
324
325impl StackRef {
326 pub fn from_stack(stack: &Stack) -> Self {
328 StackRef::External(stack.id().to_string())
329 }
330}
331
332impl From<&Stack> for StackRef {
333 fn from(stack: &Stack) -> Self {
334 StackRef::External(stack.id().to_string())
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::resource::ResourceLifecycle;
342 use crate::{
343 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
344 Storage, Worker, WorkerCode,
345 };
346 use insta::assert_json_snapshot;
347
348 fn resource_entry<T: crate::ResourceDefinition>(
349 resource: T,
350 lifecycle: ResourceLifecycle,
351 remote_access: bool,
352 ) -> ResourceEntry {
353 ResourceEntry {
354 config: Resource::new(resource),
355 lifecycle,
356 dependencies: Vec::new(),
357 remote_access,
358 enabled_when: None,
359 }
360 }
361
362 #[test]
363 fn remote_frozen_storage_requires_storage_lifecycle_and_opt_in() {
364 assert!(resource_entry(
365 Storage::new("archive".to_string()).build(),
366 ResourceLifecycle::Frozen,
367 true,
368 )
369 .is_remote_frozen_storage());
370 assert!(!resource_entry(
371 Storage::new("archive".to_string()).build(),
372 ResourceLifecycle::Frozen,
373 false,
374 )
375 .is_remote_frozen_storage());
376 assert!(!resource_entry(
377 Storage::new("archive".to_string()).build(),
378 ResourceLifecycle::Live,
379 true,
380 )
381 .is_remote_frozen_storage());
382 assert!(!resource_entry(
383 Worker::new("worker".to_string())
384 .code(WorkerCode::Image {
385 image: "example.com/worker:latest".to_string(),
386 })
387 .permissions("worker-execution".to_string())
388 .build(),
389 ResourceLifecycle::Frozen,
390 true,
391 )
392 .is_remote_frozen_storage());
393 }
394
395 #[test]
396 fn remote_bindings_opt_in_is_resource_agnostic() {
397 let worker = resource_entry(
398 Worker::new("worker".to_string())
399 .code(WorkerCode::Image {
400 image: "example.com/worker:latest".to_string(),
401 })
402 .permissions("worker-execution".to_string())
403 .build(),
404 ResourceLifecycle::Live,
405 true,
406 );
407
408 assert!(worker.has_remote_bindings());
409 assert!(!worker.is_remote_frozen_storage());
410 }
411
412 #[test]
413 fn test_stack_serialization() {
414 use crate::WorkerCode;
415
416 let storage = Storage::new("my-bucket".to_string())
417 .public_read(true)
418 .build();
419
420 let worker = Worker::new("my-worker".to_string())
421 .code(WorkerCode::Image {
422 image: "rust:latest".to_string(),
423 })
424 .permissions("execution".to_string())
425 .link(&storage)
426 .build();
427
428 let mut permissions = IndexMap::new();
430 let mut execution_profile = PermissionProfile::new();
431 execution_profile.0.insert(
432 "*".to_string(),
433 vec![
434 PermissionSetReference::from_name("storage/data-read"),
435 PermissionSetReference::from_name("storage/data-write"),
436 ],
437 );
438 permissions.insert("execution".to_string(), execution_profile);
439
440 let stack_builder = Stack::new("test-stack".to_string())
441 .add(storage, ResourceLifecycle::Frozen)
442 .add(worker.clone(), ResourceLifecycle::Live);
443
444 let stack = stack_builder
445 .permissions(PermissionsConfig {
446 profiles: permissions,
447 management: ManagementPermissions::Auto,
448 })
449 .build();
450
451 let serialized_stack =
453 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
454 let deserialized_stack: Stack =
455 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
456
457 assert_eq!(
459 stack, deserialized_stack,
460 "Original and deserialized stacks do not match."
461 );
462
463 let mut settings = insta::Settings::clone_current();
465 settings.set_sort_maps(true);
466 settings.bind(|| {
467 assert_json_snapshot!("stack_serialization_account_managed", stack);
468 });
469 }
470
471 #[test]
472 fn test_empty_stack_serialization() {
473 let stack_builder = Stack::new("empty-test-stack".to_string());
474
475 let stack = stack_builder
476 .permissions(PermissionsConfig::new()) .build();
478
479 let serialized_stack =
481 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
482 let deserialized_stack: Stack =
483 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
484
485 assert_eq!(
487 stack, deserialized_stack,
488 "Original and deserialized empty stacks do not match."
489 );
490
491 let mut settings = insta::Settings::clone_current();
493 settings.set_sort_maps(true);
494 settings.bind(|| {
495 assert_json_snapshot!("empty_stack_serialization_account", stack);
496 });
497 }
498
499 #[test]
500 fn stack_deserializes_resources_without_public_endpoints() {
501 let container = Container::new("api".to_string())
502 .code(ContainerCode::Image {
503 image: "example.com/api:latest".to_string(),
504 })
505 .cpu(ResourceSpec {
506 min: "0.5".to_string(),
507 desired: "1".to_string(),
508 })
509 .memory(ResourceSpec {
510 min: "512Mi".to_string(),
511 desired: "1Gi".to_string(),
512 })
513 .port(8080)
514 .permissions("container-execution".to_string())
515 .build();
516 let daemon = Daemon::new("agent".to_string())
517 .code(DaemonCode::Image {
518 image: "example.com/agent:latest".to_string(),
519 })
520 .permissions("daemon-execution".to_string())
521 .build();
522 let worker = Worker::new("worker".to_string())
523 .code(WorkerCode::Image {
524 image: "example.com/worker:latest".to_string(),
525 })
526 .permissions("worker-execution".to_string())
527 .build();
528 let stack = Stack::new("legacy-stack".to_string())
529 .add(container, ResourceLifecycle::Live)
530 .add(daemon, ResourceLifecycle::Live)
531 .add(worker, ResourceLifecycle::Live)
532 .build();
533
534 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
535 for resource_id in ["api", "agent", "worker"] {
536 legacy_json
537 .pointer_mut(&format!("/resources/{resource_id}/config"))
538 .and_then(serde_json::Value::as_object_mut)
539 .expect("resource config should be an object")
540 .remove("publicEndpoints");
541 }
542
543 let stack: Stack =
544 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
545
546 let container = stack
547 .resources
548 .get("api")
549 .and_then(|entry| entry.config.downcast_ref::<Container>())
550 .expect("api should be a container");
551 assert!(container.public_endpoints.is_empty());
552
553 let daemon = stack
554 .resources
555 .get("agent")
556 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
557 .expect("agent should be a daemon");
558 assert!(daemon.public_endpoints.is_empty());
559
560 let worker = stack
561 .resources
562 .get("worker")
563 .and_then(|entry| entry.config.downcast_ref::<Worker>())
564 .expect("worker should be a worker");
565 assert!(worker.public_endpoints.is_empty());
566 }
567
568 #[test]
569 fn test_stack_with_permissions() {
570 use crate::permissions::PermissionProfile;
571 use indexmap::IndexMap;
572
573 let storage = Storage::new("test-storage".to_string()).build();
575
576 let mut permission_profile = PermissionProfile::new();
578 permission_profile.0.insert(
579 "*".to_string(),
580 vec![PermissionSetReference::from_name("storage/data-read")],
581 );
582
583 let mut permissions = IndexMap::new();
584 permissions.insert("reader".to_string(), permission_profile);
585
586 let stack = Stack::new("test-permissions-stack".to_string())
587 .add(storage, ResourceLifecycle::Frozen)
588 .permissions(PermissionsConfig {
589 profiles: permissions,
590 management: ManagementPermissions::Auto,
591 })
592 .build();
593
594 assert_eq!(stack.permission_profiles().len(), 1);
596 assert!(stack.permission_profiles().contains_key("reader"));
597
598 let reader_profile = stack.permission_profiles().get("reader").unwrap();
599 assert_eq!(reader_profile.0.len(), 1);
600 assert!(reader_profile.0.contains_key("*"));
601
602 let global_permissions = reader_profile.0.get("*").unwrap();
603 assert_eq!(
604 global_permissions,
605 &vec![PermissionSetReference::from_name("storage/data-read")]
606 );
607
608 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
610 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
611 assert_eq!(stack, deserialized);
612 }
613
614 #[test]
615 fn test_stack_with_management_permissions() {
616 use crate::permissions::{ManagementPermissions, PermissionProfile};
617
618 let storage = Storage::new("test-storage".to_string()).build();
620
621 let mut management_profile = PermissionProfile::new();
623 management_profile.0.insert(
624 "*".to_string(),
625 vec![PermissionSetReference::from_name("vault/data-write")],
626 );
627
628 let stack_auto = Stack::new("test-auto-management-stack".to_string())
630 .add(storage.clone(), ResourceLifecycle::Frozen)
631 .management(ManagementPermissions::auto())
632 .build();
633
634 assert!(stack_auto.management().is_auto());
635 assert!(stack_auto.management().profile().is_none());
636
637 let stack_extend = Stack::new("test-extend-management-stack".to_string())
639 .add(storage.clone(), ResourceLifecycle::Frozen)
640 .management(ManagementPermissions::extend(management_profile.clone()))
641 .build();
642
643 assert!(stack_extend.management().is_extend());
644 assert_eq!(
645 stack_extend.management().profile().unwrap(),
646 &management_profile
647 );
648
649 let stack_override = Stack::new("test-override-management-stack".to_string())
651 .add(storage.clone(), ResourceLifecycle::Frozen)
652 .management(ManagementPermissions::override_(management_profile.clone()))
653 .build();
654
655 assert!(stack_override.management().is_override());
656 assert_eq!(
657 stack_override.management().profile().unwrap(),
658 &management_profile
659 );
660
661 let stack_default = Stack::new("test-default-management-stack".to_string())
663 .add(storage, ResourceLifecycle::Frozen)
664 .build();
665
666 assert!(stack_default.management().is_auto());
667
668 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
670 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
671 assert_eq!(stack_extend, deserialized);
672 }
673
674 #[test]
675 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
676 let first = Stack::new("first".to_string())
677 .add(
678 Storage::new("alpha".to_string()).build(),
679 ResourceLifecycle::Frozen,
680 )
681 .add(
682 Storage::new("beta".to_string()).build(),
683 ResourceLifecycle::Frozen,
684 )
685 .add(
686 Storage::new("live-one".to_string()).build(),
687 ResourceLifecycle::Live,
688 )
689 .build();
690 let second = Stack::new("second".to_string())
691 .add(
692 Storage::new("beta".to_string()).build(),
693 ResourceLifecycle::Frozen,
694 )
695 .add(
696 Storage::new("alpha".to_string()).build(),
697 ResourceLifecycle::Frozen,
698 )
699 .add(
700 Storage::new("live-two".to_string()).build(),
701 ResourceLifecycle::Live,
702 )
703 .build();
704
705 assert_eq!(
706 first.frozen_resources_digest(),
707 second.frozen_resources_digest()
708 );
709 }
710}