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
34#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(rename_all = "camelCase")]
38#[builder(start_fn = new)]
39pub struct Stack {
40 #[builder(start_fn)]
42 pub id: String,
43 #[builder(field)]
45 pub resources: IndexMap<String, ResourceEntry>,
46 #[builder(field)]
48 #[serde(default)]
49 pub permissions: PermissionsConfig,
50 #[builder(field)]
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub supported_platforms: Option<Vec<Platform>>,
54 #[builder(field)]
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub inputs: Vec<StackInputDefinition>,
58}
59
60impl Stack {
61 pub fn frozen_resources_digest(&self) -> String {
64 let mut resources = self
65 .resources
66 .iter()
67 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
68 .map(|(id, entry)| {
69 let mut value =
70 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
71 canonicalize_json(&mut value);
72 (id, value)
73 })
74 .collect::<Vec<_>>();
75 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
76
77 let encoded = serde_json::to_vec(&resources)
78 .expect("canonical Frozen resource projection always serializes");
79 format!("{:x}", Sha256::digest(encoded))
80 }
81 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
83 self.resources.iter()
84 }
85
86 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
88 self.resources.iter_mut()
89 }
90
91 pub fn id(&self) -> &str {
92 &self.id
93 }
94
95 pub fn current() -> StackRef {
97 StackRef::Current
98 }
99
100 pub fn permissions(&self) -> &PermissionsConfig {
102 &self.permissions
103 }
104
105 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
107 &self.permissions.profiles
108 }
109
110 pub fn management(&self) -> &ManagementPermissions {
112 &self.permissions.management
113 }
114
115 pub fn supported_platforms(&self) -> Option<&[Platform]> {
117 self.supported_platforms.as_deref()
118 }
119
120 pub fn inputs(&self) -> &[StackInputDefinition] {
122 &self.inputs
123 }
124
125 pub fn supports_platform(&self, platform: &Platform) -> bool {
128 match &self.supported_platforms {
129 Some(platforms) => platforms.contains(platform),
130 None => true,
131 }
132 }
133}
134
135fn canonicalize_json(value: &mut serde_json::Value) {
136 match value {
137 serde_json::Value::Array(values) => {
138 for value in values {
139 canonicalize_json(value);
140 }
141 }
142 serde_json::Value::Object(object) => {
143 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
144 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
145 for (key, mut value) in entries {
146 canonicalize_json(&mut value);
147 object.insert(key, value);
148 }
149 }
150 _ => {}
151 }
152}
153
154impl StackBuilder {
155 pub fn add<T: crate::ResourceDefinition>(
159 self,
160 resource: T,
161 lifecycle: ResourceLifecycle,
162 ) -> Self {
163 self.add_with_dependencies(resource, lifecycle, vec![])
164 }
165
166 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
169 self,
170 resource: T,
171 lifecycle: ResourceLifecycle,
172 additional_dependencies: Vec<ResourceRef>,
173 ) -> Self {
174 let mut entry = Self::entry(resource, lifecycle);
175 entry.dependencies = additional_dependencies;
176 self.insert(entry)
177 }
178
179 #[doc(hidden)]
186 pub fn add_enabled_when<T: crate::ResourceDefinition>(
187 self,
188 resource: T,
189 lifecycle: ResourceLifecycle,
190 input_id: impl Into<String>,
191 ) -> Self {
192 let mut entry = Self::entry(resource, lifecycle);
193 entry.enabled_when = Some(input_id.into());
194 self.insert(entry)
195 }
196
197 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
200 self,
201 resource: T,
202 lifecycle: ResourceLifecycle,
203 ) -> Self {
204 let mut entry = Self::entry(resource, lifecycle);
205 entry.remote_access = true;
206 self.insert(entry)
207 }
208
209 fn entry<T: crate::ResourceDefinition>(
213 resource: T,
214 lifecycle: ResourceLifecycle,
215 ) -> ResourceEntry {
216 ResourceEntry {
217 config: Resource::new(resource),
218 lifecycle,
219 dependencies: Vec::new(),
220 remote_access: false,
221 enabled_when: None,
222 }
223 }
224
225 fn insert(mut self, entry: ResourceEntry) -> Self {
226 self.resources.insert(entry.config.id().to_string(), entry);
227 self
228 }
229
230 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
233 self.permissions = permissions;
234 self
235 }
236
237 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
249 self.permissions.profiles.insert(name.into(), profile);
250 self
251 }
252
253 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
255 self.supported_platforms = Some(platforms);
256 self
257 }
258
259 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
261 self.inputs = inputs;
262 self
263 }
264
265 pub fn management(mut self, management: ManagementPermissions) -> Self {
291 self.permissions.management = management;
292 self
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
299#[serde(rename_all = "camelCase")]
300pub enum StackRef {
301 Current,
303 External(String),
305}
306
307impl StackRef {
308 pub fn from_stack(stack: &Stack) -> Self {
310 StackRef::External(stack.id().to_string())
311 }
312}
313
314impl From<&Stack> for StackRef {
315 fn from(stack: &Stack) -> Self {
316 StackRef::External(stack.id().to_string())
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::resource::ResourceLifecycle;
324 use crate::{
325 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
326 Storage, Worker, WorkerCode,
327 };
328 use insta::assert_json_snapshot;
329
330 #[test]
331 fn test_stack_serialization() {
332 use crate::WorkerCode;
333
334 let storage = Storage::new("my-bucket".to_string())
335 .public_read(true)
336 .build();
337
338 let worker = Worker::new("my-worker".to_string())
339 .code(WorkerCode::Image {
340 image: "rust:latest".to_string(),
341 })
342 .permissions("execution".to_string())
343 .link(&storage)
344 .build();
345
346 let mut permissions = IndexMap::new();
348 let mut execution_profile = PermissionProfile::new();
349 execution_profile.0.insert(
350 "*".to_string(),
351 vec![
352 PermissionSetReference::from_name("storage/data-read"),
353 PermissionSetReference::from_name("storage/data-write"),
354 ],
355 );
356 permissions.insert("execution".to_string(), execution_profile);
357
358 let stack_builder = Stack::new("test-stack".to_string())
359 .add(storage, ResourceLifecycle::Frozen)
360 .add(worker.clone(), ResourceLifecycle::Live);
361
362 let stack = stack_builder
363 .permissions(PermissionsConfig {
364 profiles: permissions,
365 management: ManagementPermissions::Auto,
366 })
367 .build();
368
369 let serialized_stack =
371 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
372 let deserialized_stack: Stack =
373 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
374
375 assert_eq!(
377 stack, deserialized_stack,
378 "Original and deserialized stacks do not match."
379 );
380
381 let mut settings = insta::Settings::clone_current();
383 settings.set_sort_maps(true);
384 settings.bind(|| {
385 assert_json_snapshot!("stack_serialization_account_managed", stack);
386 });
387 }
388
389 #[test]
390 fn test_empty_stack_serialization() {
391 let stack_builder = Stack::new("empty-test-stack".to_string());
392
393 let stack = stack_builder
394 .permissions(PermissionsConfig::new()) .build();
396
397 let serialized_stack =
399 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
400 let deserialized_stack: Stack =
401 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
402
403 assert_eq!(
405 stack, deserialized_stack,
406 "Original and deserialized empty stacks do not match."
407 );
408
409 let mut settings = insta::Settings::clone_current();
411 settings.set_sort_maps(true);
412 settings.bind(|| {
413 assert_json_snapshot!("empty_stack_serialization_account", stack);
414 });
415 }
416
417 #[test]
418 fn stack_deserializes_resources_without_public_endpoints() {
419 let container = Container::new("api".to_string())
420 .code(ContainerCode::Image {
421 image: "example.com/api:latest".to_string(),
422 })
423 .cpu(ResourceSpec {
424 min: "0.5".to_string(),
425 desired: "1".to_string(),
426 })
427 .memory(ResourceSpec {
428 min: "512Mi".to_string(),
429 desired: "1Gi".to_string(),
430 })
431 .port(8080)
432 .permissions("container-execution".to_string())
433 .build();
434 let daemon = Daemon::new("agent".to_string())
435 .code(DaemonCode::Image {
436 image: "example.com/agent:latest".to_string(),
437 })
438 .permissions("daemon-execution".to_string())
439 .build();
440 let worker = Worker::new("worker".to_string())
441 .code(WorkerCode::Image {
442 image: "example.com/worker:latest".to_string(),
443 })
444 .permissions("worker-execution".to_string())
445 .build();
446 let stack = Stack::new("legacy-stack".to_string())
447 .add(container, ResourceLifecycle::Live)
448 .add(daemon, ResourceLifecycle::Live)
449 .add(worker, ResourceLifecycle::Live)
450 .build();
451
452 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
453 for resource_id in ["api", "agent", "worker"] {
454 legacy_json
455 .pointer_mut(&format!("/resources/{resource_id}/config"))
456 .and_then(serde_json::Value::as_object_mut)
457 .expect("resource config should be an object")
458 .remove("publicEndpoints");
459 }
460
461 let stack: Stack =
462 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
463
464 let container = stack
465 .resources
466 .get("api")
467 .and_then(|entry| entry.config.downcast_ref::<Container>())
468 .expect("api should be a container");
469 assert!(container.public_endpoints.is_empty());
470
471 let daemon = stack
472 .resources
473 .get("agent")
474 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
475 .expect("agent should be a daemon");
476 assert!(daemon.public_endpoints.is_empty());
477
478 let worker = stack
479 .resources
480 .get("worker")
481 .and_then(|entry| entry.config.downcast_ref::<Worker>())
482 .expect("worker should be a worker");
483 assert!(worker.public_endpoints.is_empty());
484 }
485
486 #[test]
487 fn test_stack_with_permissions() {
488 use crate::permissions::PermissionProfile;
489 use indexmap::IndexMap;
490
491 let storage = Storage::new("test-storage".to_string()).build();
493
494 let mut permission_profile = PermissionProfile::new();
496 permission_profile.0.insert(
497 "*".to_string(),
498 vec![PermissionSetReference::from_name("storage/data-read")],
499 );
500
501 let mut permissions = IndexMap::new();
502 permissions.insert("reader".to_string(), permission_profile);
503
504 let stack = Stack::new("test-permissions-stack".to_string())
505 .add(storage, ResourceLifecycle::Frozen)
506 .permissions(PermissionsConfig {
507 profiles: permissions,
508 management: ManagementPermissions::Auto,
509 })
510 .build();
511
512 assert_eq!(stack.permission_profiles().len(), 1);
514 assert!(stack.permission_profiles().contains_key("reader"));
515
516 let reader_profile = stack.permission_profiles().get("reader").unwrap();
517 assert_eq!(reader_profile.0.len(), 1);
518 assert!(reader_profile.0.contains_key("*"));
519
520 let global_permissions = reader_profile.0.get("*").unwrap();
521 assert_eq!(
522 global_permissions,
523 &vec![PermissionSetReference::from_name("storage/data-read")]
524 );
525
526 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
528 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
529 assert_eq!(stack, deserialized);
530 }
531
532 #[test]
533 fn test_stack_with_management_permissions() {
534 use crate::permissions::{ManagementPermissions, PermissionProfile};
535
536 let storage = Storage::new("test-storage".to_string()).build();
538
539 let mut management_profile = PermissionProfile::new();
541 management_profile.0.insert(
542 "*".to_string(),
543 vec![PermissionSetReference::from_name("vault/data-write")],
544 );
545
546 let stack_auto = Stack::new("test-auto-management-stack".to_string())
548 .add(storage.clone(), ResourceLifecycle::Frozen)
549 .management(ManagementPermissions::auto())
550 .build();
551
552 assert!(stack_auto.management().is_auto());
553 assert!(stack_auto.management().profile().is_none());
554
555 let stack_extend = Stack::new("test-extend-management-stack".to_string())
557 .add(storage.clone(), ResourceLifecycle::Frozen)
558 .management(ManagementPermissions::extend(management_profile.clone()))
559 .build();
560
561 assert!(stack_extend.management().is_extend());
562 assert_eq!(
563 stack_extend.management().profile().unwrap(),
564 &management_profile
565 );
566
567 let stack_override = Stack::new("test-override-management-stack".to_string())
569 .add(storage.clone(), ResourceLifecycle::Frozen)
570 .management(ManagementPermissions::override_(management_profile.clone()))
571 .build();
572
573 assert!(stack_override.management().is_override());
574 assert_eq!(
575 stack_override.management().profile().unwrap(),
576 &management_profile
577 );
578
579 let stack_default = Stack::new("test-default-management-stack".to_string())
581 .add(storage, ResourceLifecycle::Frozen)
582 .build();
583
584 assert!(stack_default.management().is_auto());
585
586 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
588 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
589 assert_eq!(stack_extend, deserialized);
590 }
591
592 #[test]
593 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
594 let first = Stack::new("first".to_string())
595 .add(
596 Storage::new("alpha".to_string()).build(),
597 ResourceLifecycle::Frozen,
598 )
599 .add(
600 Storage::new("beta".to_string()).build(),
601 ResourceLifecycle::Frozen,
602 )
603 .add(
604 Storage::new("live-one".to_string()).build(),
605 ResourceLifecycle::Live,
606 )
607 .build();
608 let second = Stack::new("second".to_string())
609 .add(
610 Storage::new("beta".to_string()).build(),
611 ResourceLifecycle::Frozen,
612 )
613 .add(
614 Storage::new("alpha".to_string()).build(),
615 ResourceLifecycle::Frozen,
616 )
617 .add(
618 Storage::new("live-two".to_string()).build(),
619 ResourceLifecycle::Live,
620 )
621 .build();
622
623 assert_eq!(
624 first.frozen_resources_digest(),
625 second.frozen_resources_digest()
626 );
627 }
628}