Skip to main content

alien_core/
stack.rs

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    /// Resource configuration (can be any type of resource)
12    pub config: Resource,
13    /// Lifecycle management configuration for this resource
14    pub lifecycle: ResourceLifecycle,
15    /// Additional dependencies for this resource beyond those defined in the resource itself.
16    /// The total dependencies are: resource.get_dependencies() + this list
17    pub dependencies: Vec<ResourceRef>,
18    /// Enable remote bindings for this resource (BYOB use case).
19    /// When true, binding params are synced to StackState's `remote_binding_params`.
20    /// Default: false (prevents sensitive data in synced state).
21    #[serde(default)]
22    pub remote_access: bool,
23}
24
25/// A bag of resources, unaware of any cloud.
26#[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    /// Unique identifier for the stack
32    #[builder(start_fn)]
33    pub id: String,
34    /// Map of resource IDs to their configurations and lifecycle settings
35    #[builder(field)]
36    pub resources: IndexMap<String, ResourceEntry>,
37    /// Combined permissions configuration containing both profiles and management
38    #[builder(field)]
39    #[serde(default)]
40    pub permissions: PermissionsConfig,
41    /// Which platforms this stack supports. When None, all platforms are supported.
42    #[builder(field)]
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub supported_platforms: Option<Vec<Platform>>,
45    /// Input definitions required before setup or deployment can proceed.
46    #[builder(field)]
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub inputs: Vec<StackInputDefinition>,
49}
50
51impl Stack {
52    /// Returns an iterator over the resources in the stack, including their lifecycle state.
53    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
54        self.resources.iter()
55    }
56
57    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
58    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    /// Create a reference to the current stack
67    pub fn current() -> StackRef {
68        StackRef::Current
69    }
70
71    /// Returns the permissions configuration for the stack.
72    pub fn permissions(&self) -> &PermissionsConfig {
73        &self.permissions
74    }
75
76    /// Returns the permission profiles for the stack.
77    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
78        &self.permissions.profiles
79    }
80
81    /// Returns the management permissions configuration for the stack.
82    pub fn management(&self) -> &ManagementPermissions {
83        &self.permissions.management
84    }
85
86    /// Returns the supported platforms, or None if all platforms are supported.
87    pub fn supported_platforms(&self) -> Option<&[Platform]> {
88        self.supported_platforms.as_deref()
89    }
90
91    /// Returns stack input definitions.
92    pub fn inputs(&self) -> &[StackInputDefinition] {
93        &self.inputs
94    }
95
96    /// Returns true if the given platform is supported by this stack.
97    /// When supported_platforms is None, all platforms are supported.
98    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    /// Adds a resource to the stack with its lifecycle state.
108    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
109    /// Use add_with_dependencies() if you need to specify additional dependencies.
110    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    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
119    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
120    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    /// Adds a resource with remote access enabled.
140    /// When remote_access is true, binding params are synced to StackState for external access.
141    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    /// Sets the permissions configuration for the stack.
160    /// This defines access control for compute services in the stack.
161    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
162        self.permissions = permissions;
163        self
164    }
165
166    /// Add a single permission profile to the stack - allows fluent chaining
167    ///
168    /// # Example
169    /// ```rust
170    /// # use alien_core::{Stack, permissions::PermissionProfile};
171    /// Stack::new("my-stack".to_string())
172    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
173    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
174    ///     .build()
175    /// # ;
176    /// ```
177    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    /// Sets the supported platforms for this stack.
183    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
184        self.supported_platforms = Some(platforms);
185        self
186    }
187
188    /// Sets stack input definitions.
189    pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
190        self.inputs = inputs;
191        self
192    }
193
194    /// Sets the management permissions configuration for the stack.
195    /// This defines how management permissions are derived and configured.
196    ///
197    /// # Examples
198    /// ```rust
199    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
200    /// // Auto-derived management permissions (default)
201    /// Stack::new("my-stack".to_string())
202    ///     .management(ManagementPermissions::auto())
203    ///     .build();
204    ///
205    /// // Extend auto-derived permissions
206    /// Stack::new("my-stack".to_string())
207    ///     .management(ManagementPermissions::extend(
208    ///         PermissionProfile::new().global(["vault/data-write"])
209    ///     ))
210    ///     .build();
211    ///
212    /// // Override auto-derived permissions entirely
213    /// Stack::new("my-stack".to_string())
214    ///     .management(ManagementPermissions::override_(
215    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
216    ///     ))
217    ///     .build();
218    /// ```
219    pub fn management(mut self, management: ManagementPermissions) -> Self {
220        self.permissions.management = management;
221        self
222    }
223}
224
225/// Reference to a stack for management permissions
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
228#[serde(rename_all = "camelCase")]
229pub enum StackRef {
230    /// Reference to the current stack being built
231    Current,
232    /// Reference to another stack by ID
233    External(String),
234}
235
236impl StackRef {
237    /// Create a StackRef from a stack reference
238    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        // Create permission profiles for the new system
276        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        // Serialize and Deserialize
299        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 equality
305        assert_eq!(
306            stack, deserialized_stack,
307            "Original and deserialized stacks do not match."
308        );
309
310        // Verify snapshot (sort maps to be deterministic across Rust versions)
311        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()) // Empty permissions for existing tests
324            .build();
325
326        // Serialize and Deserialize
327        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 equality
333        assert_eq!(
334            stack, deserialized_stack,
335            "Original and deserialized empty stacks do not match."
336        );
337
338        // Verify snapshot (sort maps to be deterministic across Rust versions)
339        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        // Create a simple stack with permissions
421        let storage = Storage::new("test-storage".to_string()).build();
422
423        // Create a permission profile
424        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        // Verify permissions are accessible
442        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        // Test serialization/deserialization
456        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        // Create a simple stack with management permissions
466        let storage = Storage::new("test-storage".to_string()).build();
467
468        // Create a permission profile for management
469        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        // Test auto management permissions (default)
476        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        // Test extend management permissions
485        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        // Test override management permissions
497        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        // Test default management permissions
509        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        // Test serialization/deserialization with management
516        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}