1use crate::permissions::{ManagementPermissions, PermissionProfile, PermissionsConfig};
2use crate::{Platform, Resource, ResourceLifecycle, ResourceRef, StackInputDefinition};
3use bon::Builder;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
9#[serde(rename_all = "camelCase")]
10pub struct ResourceEntry {
11 pub config: Resource,
13 pub lifecycle: ResourceLifecycle,
15 pub dependencies: Vec<ResourceRef>,
18 #[serde(default)]
22 pub remote_access: bool,
23}
24
25#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29#[builder(start_fn = new)]
30pub struct Stack {
31 #[builder(start_fn)]
33 pub id: String,
34 #[builder(field)]
36 pub resources: IndexMap<String, ResourceEntry>,
37 #[builder(field)]
39 #[serde(default)]
40 pub permissions: PermissionsConfig,
41 #[builder(field)]
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub supported_platforms: Option<Vec<Platform>>,
45 #[builder(field)]
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub inputs: Vec<StackInputDefinition>,
49}
50
51impl Stack {
52 pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
54 self.resources.iter()
55 }
56
57 pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
59 self.resources.iter_mut()
60 }
61
62 pub fn id(&self) -> &str {
63 &self.id
64 }
65
66 pub fn current() -> StackRef {
68 StackRef::Current
69 }
70
71 pub fn permissions(&self) -> &PermissionsConfig {
73 &self.permissions
74 }
75
76 pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
78 &self.permissions.profiles
79 }
80
81 pub fn management(&self) -> &ManagementPermissions {
83 &self.permissions.management
84 }
85
86 pub fn supported_platforms(&self) -> Option<&[Platform]> {
88 self.supported_platforms.as_deref()
89 }
90
91 pub fn inputs(&self) -> &[StackInputDefinition] {
93 &self.inputs
94 }
95
96 pub fn supports_platform(&self, platform: &Platform) -> bool {
99 match &self.supported_platforms {
100 Some(platforms) => platforms.contains(platform),
101 None => true,
102 }
103 }
104}
105
106impl StackBuilder {
107 pub fn add<T: crate::ResourceDefinition>(
111 self,
112 resource: T,
113 lifecycle: ResourceLifecycle,
114 ) -> Self {
115 self.add_with_dependencies(resource, lifecycle, vec![])
116 }
117
118 pub fn add_with_dependencies<T: crate::ResourceDefinition>(
121 mut self,
122 resource: T,
123 lifecycle: ResourceLifecycle,
124 additional_dependencies: Vec<ResourceRef>,
125 ) -> Self {
126 let resource = Resource::new(resource);
127 self.resources.insert(
128 resource.id().to_string(),
129 ResourceEntry {
130 config: resource,
131 lifecycle,
132 dependencies: additional_dependencies,
133 remote_access: false,
134 },
135 );
136 self
137 }
138
139 pub fn add_with_remote_access<T: crate::ResourceDefinition>(
142 mut self,
143 resource: T,
144 lifecycle: ResourceLifecycle,
145 ) -> Self {
146 let resource = Resource::new(resource);
147 self.resources.insert(
148 resource.id().to_string(),
149 ResourceEntry {
150 config: resource,
151 lifecycle,
152 dependencies: vec![],
153 remote_access: true,
154 },
155 );
156 self
157 }
158
159 pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
162 self.permissions = permissions;
163 self
164 }
165
166 pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
178 self.permissions.profiles.insert(name.into(), profile);
179 self
180 }
181
182 pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
184 self.supported_platforms = Some(platforms);
185 self
186 }
187
188 pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
190 self.inputs = inputs;
191 self
192 }
193
194 pub fn management(mut self, management: ManagementPermissions) -> Self {
220 self.permissions.management = management;
221 self
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
228#[serde(rename_all = "camelCase")]
229pub enum StackRef {
230 Current,
232 External(String),
234}
235
236impl StackRef {
237 pub fn from_stack(stack: &Stack) -> Self {
239 StackRef::External(stack.id().to_string())
240 }
241}
242
243impl From<&Stack> for StackRef {
244 fn from(stack: &Stack) -> Self {
245 StackRef::External(stack.id().to_string())
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::resource::ResourceLifecycle;
253 use crate::{
254 Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
255 Storage, Worker, WorkerCode,
256 };
257 use insta::assert_json_snapshot;
258
259 #[test]
260 fn test_stack_serialization() {
261 use crate::WorkerCode;
262
263 let storage = Storage::new("my-bucket".to_string())
264 .public_read(true)
265 .build();
266
267 let worker = Worker::new("my-worker".to_string())
268 .code(WorkerCode::Image {
269 image: "rust:latest".to_string(),
270 })
271 .permissions("execution".to_string())
272 .link(&storage)
273 .build();
274
275 let mut permissions = IndexMap::new();
277 let mut execution_profile = PermissionProfile::new();
278 execution_profile.0.insert(
279 "*".to_string(),
280 vec![
281 PermissionSetReference::from_name("storage/data-read"),
282 PermissionSetReference::from_name("storage/data-write"),
283 ],
284 );
285 permissions.insert("execution".to_string(), execution_profile);
286
287 let stack_builder = Stack::new("test-stack".to_string())
288 .add(storage, ResourceLifecycle::Frozen)
289 .add(worker.clone(), ResourceLifecycle::Live);
290
291 let stack = stack_builder
292 .permissions(PermissionsConfig {
293 profiles: permissions,
294 management: ManagementPermissions::Auto,
295 })
296 .build();
297
298 let serialized_stack =
300 serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
301 let deserialized_stack: Stack =
302 serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
303
304 assert_eq!(
306 stack, deserialized_stack,
307 "Original and deserialized stacks do not match."
308 );
309
310 let mut settings = insta::Settings::clone_current();
312 settings.set_sort_maps(true);
313 settings.bind(|| {
314 assert_json_snapshot!("stack_serialization_account_managed", stack);
315 });
316 }
317
318 #[test]
319 fn test_empty_stack_serialization() {
320 let stack_builder = Stack::new("empty-test-stack".to_string());
321
322 let stack = stack_builder
323 .permissions(PermissionsConfig::new()) .build();
325
326 let serialized_stack =
328 serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
329 let deserialized_stack: Stack =
330 serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
331
332 assert_eq!(
334 stack, deserialized_stack,
335 "Original and deserialized empty stacks do not match."
336 );
337
338 let mut settings = insta::Settings::clone_current();
340 settings.set_sort_maps(true);
341 settings.bind(|| {
342 assert_json_snapshot!("empty_stack_serialization_account", stack);
343 });
344 }
345
346 #[test]
347 fn stack_deserializes_resources_without_public_endpoints() {
348 let container = Container::new("api".to_string())
349 .code(ContainerCode::Image {
350 image: "example.com/api:latest".to_string(),
351 })
352 .cpu(ResourceSpec {
353 min: "0.5".to_string(),
354 desired: "1".to_string(),
355 })
356 .memory(ResourceSpec {
357 min: "512Mi".to_string(),
358 desired: "1Gi".to_string(),
359 })
360 .port(8080)
361 .permissions("container-execution".to_string())
362 .build();
363 let daemon = Daemon::new("agent".to_string())
364 .code(DaemonCode::Image {
365 image: "example.com/agent:latest".to_string(),
366 })
367 .permissions("daemon-execution".to_string())
368 .build();
369 let worker = Worker::new("worker".to_string())
370 .code(WorkerCode::Image {
371 image: "example.com/worker:latest".to_string(),
372 })
373 .permissions("worker-execution".to_string())
374 .build();
375 let stack = Stack::new("legacy-stack".to_string())
376 .add(container, ResourceLifecycle::Live)
377 .add(daemon, ResourceLifecycle::Live)
378 .add(worker, ResourceLifecycle::Live)
379 .build();
380
381 let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
382 for resource_id in ["api", "agent", "worker"] {
383 legacy_json
384 .pointer_mut(&format!("/resources/{resource_id}/config"))
385 .and_then(serde_json::Value::as_object_mut)
386 .expect("resource config should be an object")
387 .remove("publicEndpoints");
388 }
389
390 let stack: Stack =
391 serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
392
393 let container = stack
394 .resources
395 .get("api")
396 .and_then(|entry| entry.config.downcast_ref::<Container>())
397 .expect("api should be a container");
398 assert!(container.public_endpoints.is_empty());
399
400 let daemon = stack
401 .resources
402 .get("agent")
403 .and_then(|entry| entry.config.downcast_ref::<Daemon>())
404 .expect("agent should be a daemon");
405 assert!(daemon.public_endpoints.is_empty());
406
407 let worker = stack
408 .resources
409 .get("worker")
410 .and_then(|entry| entry.config.downcast_ref::<Worker>())
411 .expect("worker should be a worker");
412 assert!(worker.public_endpoints.is_empty());
413 }
414
415 #[test]
416 fn test_stack_with_permissions() {
417 use crate::permissions::PermissionProfile;
418 use indexmap::IndexMap;
419
420 let storage = Storage::new("test-storage".to_string()).build();
422
423 let mut permission_profile = PermissionProfile::new();
425 permission_profile.0.insert(
426 "*".to_string(),
427 vec![PermissionSetReference::from_name("storage/data-read")],
428 );
429
430 let mut permissions = IndexMap::new();
431 permissions.insert("reader".to_string(), permission_profile);
432
433 let stack = Stack::new("test-permissions-stack".to_string())
434 .add(storage, ResourceLifecycle::Frozen)
435 .permissions(PermissionsConfig {
436 profiles: permissions,
437 management: ManagementPermissions::Auto,
438 })
439 .build();
440
441 assert_eq!(stack.permission_profiles().len(), 1);
443 assert!(stack.permission_profiles().contains_key("reader"));
444
445 let reader_profile = stack.permission_profiles().get("reader").unwrap();
446 assert_eq!(reader_profile.0.len(), 1);
447 assert!(reader_profile.0.contains_key("*"));
448
449 let global_permissions = reader_profile.0.get("*").unwrap();
450 assert_eq!(
451 global_permissions,
452 &vec![PermissionSetReference::from_name("storage/data-read")]
453 );
454
455 let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
457 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
458 assert_eq!(stack, deserialized);
459 }
460
461 #[test]
462 fn test_stack_with_management_permissions() {
463 use crate::permissions::{ManagementPermissions, PermissionProfile};
464
465 let storage = Storage::new("test-storage".to_string()).build();
467
468 let mut management_profile = PermissionProfile::new();
470 management_profile.0.insert(
471 "*".to_string(),
472 vec![PermissionSetReference::from_name("vault/data-write")],
473 );
474
475 let stack_auto = Stack::new("test-auto-management-stack".to_string())
477 .add(storage.clone(), ResourceLifecycle::Frozen)
478 .management(ManagementPermissions::auto())
479 .build();
480
481 assert!(stack_auto.management().is_auto());
482 assert!(stack_auto.management().profile().is_none());
483
484 let stack_extend = Stack::new("test-extend-management-stack".to_string())
486 .add(storage.clone(), ResourceLifecycle::Frozen)
487 .management(ManagementPermissions::extend(management_profile.clone()))
488 .build();
489
490 assert!(stack_extend.management().is_extend());
491 assert_eq!(
492 stack_extend.management().profile().unwrap(),
493 &management_profile
494 );
495
496 let stack_override = Stack::new("test-override-management-stack".to_string())
498 .add(storage.clone(), ResourceLifecycle::Frozen)
499 .management(ManagementPermissions::override_(management_profile.clone()))
500 .build();
501
502 assert!(stack_override.management().is_override());
503 assert_eq!(
504 stack_override.management().profile().unwrap(),
505 &management_profile
506 );
507
508 let stack_default = Stack::new("test-default-management-stack".to_string())
510 .add(storage, ResourceLifecycle::Frozen)
511 .build();
512
513 assert!(stack_default.management().is_auto());
514
515 let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
517 let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
518 assert_eq!(stack_extend, deserialized);
519 }
520}