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}
25
26#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30#[builder(start_fn = new)]
31pub struct Stack {
32 #[builder(start_fn)]
34 pub id: String,
35 #[builder(field)]
37 pub resources: IndexMap<String, ResourceEntry>,
38 #[builder(field)]
40 #[serde(default)]
41 pub permissions: PermissionsConfig,
42 #[builder(field)]
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub supported_platforms: Option<Vec<Platform>>,
46 #[builder(field)]
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub inputs: Vec<StackInputDefinition>,
50}
51
52impl Stack {
53 pub fn frozen_resources_digest(&self) -> String {
56 let mut resources = self
57 .resources
58 .iter()
59 .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
60 .map(|(id, entry)| {
61 let mut value =
62 serde_json::to_value(entry).expect("resource entries always serialize to JSON");
63 canonicalize_json(&mut value);
64 (id, value)
65 })
66 .collect::<Vec<_>>();
67 resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
68
69 let encoded = serde_json::to_vec(&resources)
70 .expect("canonical Frozen resource projection always serializes");
71 format!("{:x}", Sha256::digest(encoded))
72 }
73 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
75 self.resources.iter()
76 }
77
78 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
80 self.resources.iter_mut()
81 }
82
83 pub fn id(&self) -> &str {
84 &self.id
85 }
86
87 pub fn current() -> StackRef {
89 StackRef::Current
90 }
91
92 pub fn permissions(&self) -> &PermissionsConfig {
94 &self.permissions
95 }
96
97 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
99 &self.permissions.profiles
100 }
101
102 pub fn management(&self) -> &ManagementPermissions {
104 &self.permissions.management
105 }
106
107 pub fn supported_platforms(&self) -> Option<&[Platform]> {
109 self.supported_platforms.as_deref()
110 }
111
112 pub fn inputs(&self) -> &[StackInputDefinition] {
114 &self.inputs
115 }
116
117 pub fn supports_platform(&self, platform: &Platform) -> bool {
120 match &self.supported_platforms {
121 Some(platforms) => platforms.contains(platform),
122 None => true,
123 }
124 }
125}
126
127fn canonicalize_json(value: &mut serde_json::Value) {
128 match value {
129 serde_json::Value::Array(values) => {
130 for value in values {
131 canonicalize_json(value);
132 }
133 }
134 serde_json::Value::Object(object) => {
135 let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
136 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
137 for (key, mut value) in entries {
138 canonicalize_json(&mut value);
139 object.insert(key, value);
140 }
141 }
142 _ => {}
143 }
144}
145
146impl StackBuilder {
147 pub fn add<T: crate::ResourceDefinition>(
151 self,
152 resource: T,
153 lifecycle: ResourceLifecycle,
154 ) -> Self {
155 self.add_with_dependencies(resource, lifecycle, vec![])
156 }
157
158 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
161 mut self,
162 resource: T,
163 lifecycle: ResourceLifecycle,
164 additional_dependencies: Vec<ResourceRef>,
165 ) -> Self {
166 let resource = Resource::new(resource);
167 self.resources.insert(
168 resource.id().to_string(),
169 ResourceEntry {
170 config: resource,
171 lifecycle,
172 dependencies: additional_dependencies,
173 remote_access: false,
174 },
175 );
176 self
177 }
178
179 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
182 mut self,
183 resource: T,
184 lifecycle: ResourceLifecycle,
185 ) -> Self {
186 let resource = Resource::new(resource);
187 self.resources.insert(
188 resource.id().to_string(),
189 ResourceEntry {
190 config: resource,
191 lifecycle,
192 dependencies: vec![],
193 remote_access: true,
194 },
195 );
196 self
197 }
198
199 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
202 self.permissions = permissions;
203 self
204 }
205
206 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
218 self.permissions.profiles.insert(name.into(), profile);
219 self
220 }
221
222 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
224 self.supported_platforms = Some(platforms);
225 self
226 }
227
228 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
230 self.inputs = inputs;
231 self
232 }
233
234 pub fn management(mut self, management: ManagementPermissions) -> Self {
260 self.permissions.management = management;
261 self
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268#[serde(rename_all = "camelCase")]
269pub enum StackRef {
270 Current,
272 External(String),
274}
275
276impl StackRef {
277 pub fn from_stack(stack: &Stack) -> Self {
279 StackRef::External(stack.id().to_string())
280 }
281}
282
283impl From<&Stack> for StackRef {
284 fn from(stack: &Stack) -> Self {
285 StackRef::External(stack.id().to_string())
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::resource::ResourceLifecycle;
293 use crate::{
294 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
295 Storage, Worker, WorkerCode,
296 };
297 use insta::assert_json_snapshot;
298
299 #[test]
300 fn test_stack_serialization() {
301 use crate::WorkerCode;
302
303 let storage = Storage::new("my-bucket".to_string())
304 .public_read(true)
305 .build();
306
307 let worker = Worker::new("my-worker".to_string())
308 .code(WorkerCode::Image {
309 image: "rust:latest".to_string(),
310 })
311 .permissions("execution".to_string())
312 .link(&storage)
313 .build();
314
315 let mut permissions = IndexMap::new();
317 let mut execution_profile = PermissionProfile::new();
318 execution_profile.0.insert(
319 "*".to_string(),
320 vec![
321 PermissionSetReference::from_name("storage/data-read"),
322 PermissionSetReference::from_name("storage/data-write"),
323 ],
324 );
325 permissions.insert("execution".to_string(), execution_profile);
326
327 let stack_builder = Stack::new("test-stack".to_string())
328 .add(storage, ResourceLifecycle::Frozen)
329 .add(worker.clone(), ResourceLifecycle::Live);
330
331 let stack = stack_builder
332 .permissions(PermissionsConfig {
333 profiles: permissions,
334 management: ManagementPermissions::Auto,
335 })
336 .build();
337
338 let serialized_stack =
340 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
341 let deserialized_stack: Stack =
342 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
343
344 assert_eq!(
346 stack, deserialized_stack,
347 "Original and deserialized stacks do not match."
348 );
349
350 let mut settings = insta::Settings::clone_current();
352 settings.set_sort_maps(true);
353 settings.bind(|| {
354 assert_json_snapshot!("stack_serialization_account_managed", stack);
355 });
356 }
357
358 #[test]
359 fn test_empty_stack_serialization() {
360 let stack_builder = Stack::new("empty-test-stack".to_string());
361
362 let stack = stack_builder
363 .permissions(PermissionsConfig::new()) .build();
365
366 let serialized_stack =
368 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
369 let deserialized_stack: Stack =
370 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
371
372 assert_eq!(
374 stack, deserialized_stack,
375 "Original and deserialized empty stacks do not match."
376 );
377
378 let mut settings = insta::Settings::clone_current();
380 settings.set_sort_maps(true);
381 settings.bind(|| {
382 assert_json_snapshot!("empty_stack_serialization_account", stack);
383 });
384 }
385
386 #[test]
387 fn stack_deserializes_resources_without_public_endpoints() {
388 let container = Container::new("api".to_string())
389 .code(ContainerCode::Image {
390 image: "example.com/api:latest".to_string(),
391 })
392 .cpu(ResourceSpec {
393 min: "0.5".to_string(),
394 desired: "1".to_string(),
395 })
396 .memory(ResourceSpec {
397 min: "512Mi".to_string(),
398 desired: "1Gi".to_string(),
399 })
400 .port(8080)
401 .permissions("container-execution".to_string())
402 .build();
403 let daemon = Daemon::new("agent".to_string())
404 .code(DaemonCode::Image {
405 image: "example.com/agent:latest".to_string(),
406 })
407 .permissions("daemon-execution".to_string())
408 .build();
409 let worker = Worker::new("worker".to_string())
410 .code(WorkerCode::Image {
411 image: "example.com/worker:latest".to_string(),
412 })
413 .permissions("worker-execution".to_string())
414 .build();
415 let stack = Stack::new("legacy-stack".to_string())
416 .add(container, ResourceLifecycle::Live)
417 .add(daemon, ResourceLifecycle::Live)
418 .add(worker, ResourceLifecycle::Live)
419 .build();
420
421 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
422 for resource_id in ["api", "agent", "worker"] {
423 legacy_json
424 .pointer_mut(&format!("/resources/{resource_id}/config"))
425 .and_then(serde_json::Value::as_object_mut)
426 .expect("resource config should be an object")
427 .remove("publicEndpoints");
428 }
429
430 let stack: Stack =
431 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
432
433 let container = stack
434 .resources
435 .get("api")
436 .and_then(|entry| entry.config.downcast_ref::<Container>())
437 .expect("api should be a container");
438 assert!(container.public_endpoints.is_empty());
439
440 let daemon = stack
441 .resources
442 .get("agent")
443 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
444 .expect("agent should be a daemon");
445 assert!(daemon.public_endpoints.is_empty());
446
447 let worker = stack
448 .resources
449 .get("worker")
450 .and_then(|entry| entry.config.downcast_ref::<Worker>())
451 .expect("worker should be a worker");
452 assert!(worker.public_endpoints.is_empty());
453 }
454
455 #[test]
456 fn test_stack_with_permissions() {
457 use crate::permissions::PermissionProfile;
458 use indexmap::IndexMap;
459
460 let storage = Storage::new("test-storage".to_string()).build();
462
463 let mut permission_profile = PermissionProfile::new();
465 permission_profile.0.insert(
466 "*".to_string(),
467 vec![PermissionSetReference::from_name("storage/data-read")],
468 );
469
470 let mut permissions = IndexMap::new();
471 permissions.insert("reader".to_string(), permission_profile);
472
473 let stack = Stack::new("test-permissions-stack".to_string())
474 .add(storage, ResourceLifecycle::Frozen)
475 .permissions(PermissionsConfig {
476 profiles: permissions,
477 management: ManagementPermissions::Auto,
478 })
479 .build();
480
481 assert_eq!(stack.permission_profiles().len(), 1);
483 assert!(stack.permission_profiles().contains_key("reader"));
484
485 let reader_profile = stack.permission_profiles().get("reader").unwrap();
486 assert_eq!(reader_profile.0.len(), 1);
487 assert!(reader_profile.0.contains_key("*"));
488
489 let global_permissions = reader_profile.0.get("*").unwrap();
490 assert_eq!(
491 global_permissions,
492 &vec![PermissionSetReference::from_name("storage/data-read")]
493 );
494
495 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
497 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
498 assert_eq!(stack, deserialized);
499 }
500
501 #[test]
502 fn test_stack_with_management_permissions() {
503 use crate::permissions::{ManagementPermissions, PermissionProfile};
504
505 let storage = Storage::new("test-storage".to_string()).build();
507
508 let mut management_profile = PermissionProfile::new();
510 management_profile.0.insert(
511 "*".to_string(),
512 vec![PermissionSetReference::from_name("vault/data-write")],
513 );
514
515 let stack_auto = Stack::new("test-auto-management-stack".to_string())
517 .add(storage.clone(), ResourceLifecycle::Frozen)
518 .management(ManagementPermissions::auto())
519 .build();
520
521 assert!(stack_auto.management().is_auto());
522 assert!(stack_auto.management().profile().is_none());
523
524 let stack_extend = Stack::new("test-extend-management-stack".to_string())
526 .add(storage.clone(), ResourceLifecycle::Frozen)
527 .management(ManagementPermissions::extend(management_profile.clone()))
528 .build();
529
530 assert!(stack_extend.management().is_extend());
531 assert_eq!(
532 stack_extend.management().profile().unwrap(),
533 &management_profile
534 );
535
536 let stack_override = Stack::new("test-override-management-stack".to_string())
538 .add(storage.clone(), ResourceLifecycle::Frozen)
539 .management(ManagementPermissions::override_(management_profile.clone()))
540 .build();
541
542 assert!(stack_override.management().is_override());
543 assert_eq!(
544 stack_override.management().profile().unwrap(),
545 &management_profile
546 );
547
548 let stack_default = Stack::new("test-default-management-stack".to_string())
550 .add(storage, ResourceLifecycle::Frozen)
551 .build();
552
553 assert!(stack_default.management().is_auto());
554
555 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
557 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
558 assert_eq!(stack_extend, deserialized);
559 }
560
561 #[test]
562 fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
563 let first = Stack::new("first".to_string())
564 .add(
565 Storage::new("alpha".to_string()).build(),
566 ResourceLifecycle::Frozen,
567 )
568 .add(
569 Storage::new("beta".to_string()).build(),
570 ResourceLifecycle::Frozen,
571 )
572 .add(
573 Storage::new("live-one".to_string()).build(),
574 ResourceLifecycle::Live,
575 )
576 .build();
577 let second = Stack::new("second".to_string())
578 .add(
579 Storage::new("beta".to_string()).build(),
580 ResourceLifecycle::Frozen,
581 )
582 .add(
583 Storage::new("alpha".to_string()).build(),
584 ResourceLifecycle::Frozen,
585 )
586 .add(
587 Storage::new("live-two".to_string()).build(),
588 ResourceLifecycle::Live,
589 )
590 .build();
591
592 assert_eq!(
593 first.frozen_resources_digest(),
594 second.frozen_resources_digest()
595 );
596 }
597}