Skip to main content

alien_core/
stack.rs

1use crate::permissions::{ManagementPermissions, PermissionProfile, PermissionsConfig};
2use crate::{Platform, Resource, ResourceLifecycle, ResourceRef};
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}
46
47impl Stack {
48    /// Returns an iterator over the resources in the stack, including their lifecycle state.
49    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
50        self.resources.iter()
51    }
52
53    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
54    pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
55        self.resources.iter_mut()
56    }
57
58    pub fn id(&self) -> &str {
59        &self.id
60    }
61
62    /// Create a reference to the current stack
63    pub fn current() -> StackRef {
64        StackRef::Current
65    }
66
67    /// Returns the permissions configuration for the stack.
68    pub fn permissions(&self) -> &PermissionsConfig {
69        &self.permissions
70    }
71
72    /// Returns the permission profiles for the stack.
73    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
74        &self.permissions.profiles
75    }
76
77    /// Returns the management permissions configuration for the stack.
78    pub fn management(&self) -> &ManagementPermissions {
79        &self.permissions.management
80    }
81
82    /// Returns the supported platforms, or None if all platforms are supported.
83    pub fn supported_platforms(&self) -> Option<&[Platform]> {
84        self.supported_platforms.as_deref()
85    }
86
87    /// Returns true if the given platform is supported by this stack.
88    /// When supported_platforms is None, all platforms are supported.
89    pub fn supports_platform(&self, platform: &Platform) -> bool {
90        match &self.supported_platforms {
91            Some(platforms) => platforms.contains(platform),
92            None => true,
93        }
94    }
95}
96
97impl StackBuilder {
98    /// Adds a resource to the stack with its lifecycle state.
99    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
100    /// Use add_with_dependencies() if you need to specify additional dependencies.
101    pub fn add<T: crate::ResourceDefinition>(
102        self,
103        resource: T,
104        lifecycle: ResourceLifecycle,
105    ) -> Self {
106        self.add_with_dependencies(resource, lifecycle, vec![])
107    }
108
109    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
110    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
111    pub fn add_with_dependencies<T: crate::ResourceDefinition>(
112        mut self,
113        resource: T,
114        lifecycle: ResourceLifecycle,
115        additional_dependencies: Vec<ResourceRef>,
116    ) -> Self {
117        let resource = Resource::new(resource);
118        self.resources.insert(
119            resource.id().to_string(),
120            ResourceEntry {
121                config: resource,
122                lifecycle,
123                dependencies: additional_dependencies,
124                remote_access: false,
125            },
126        );
127        self
128    }
129
130    /// Adds a resource with remote access enabled.
131    /// When remote_access is true, binding params are synced to StackState for external access.
132    pub fn add_with_remote_access<T: crate::ResourceDefinition>(
133        mut self,
134        resource: T,
135        lifecycle: ResourceLifecycle,
136    ) -> Self {
137        let resource = Resource::new(resource);
138        self.resources.insert(
139            resource.id().to_string(),
140            ResourceEntry {
141                config: resource,
142                lifecycle,
143                dependencies: vec![],
144                remote_access: true,
145            },
146        );
147        self
148    }
149
150    /// Sets the permissions configuration for the stack.
151    /// This defines access control for compute services in the stack.
152    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
153        self.permissions = permissions;
154        self
155    }
156
157    /// Add a single permission profile to the stack - allows fluent chaining
158    ///
159    /// # Example
160    /// ```rust
161    /// # use alien_core::{Stack, permissions::PermissionProfile};
162    /// Stack::new("my-stack".to_string())
163    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
164    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
165    ///     .build()
166    /// # ;
167    /// ```
168    pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
169        self.permissions.profiles.insert(name.into(), profile);
170        self
171    }
172
173    /// Sets the supported platforms for this stack.
174    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
175        self.supported_platforms = Some(platforms);
176        self
177    }
178
179    /// Sets the management permissions configuration for the stack.
180    /// This defines how management permissions are derived and configured.
181    ///
182    /// # Examples
183    /// ```rust
184    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
185    /// // Auto-derived management permissions (default)
186    /// Stack::new("my-stack".to_string())
187    ///     .management(ManagementPermissions::auto())
188    ///     .build();
189    ///
190    /// // Extend auto-derived permissions
191    /// Stack::new("my-stack".to_string())
192    ///     .management(ManagementPermissions::extend(
193    ///         PermissionProfile::new().global(["vault/data-write"])
194    ///     ))
195    ///     .build();
196    ///
197    /// // Override auto-derived permissions entirely
198    /// Stack::new("my-stack".to_string())
199    ///     .management(ManagementPermissions::override_(
200    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
201    ///     ))
202    ///     .build();
203    /// ```
204    pub fn management(mut self, management: ManagementPermissions) -> Self {
205        self.permissions.management = management;
206        self
207    }
208}
209
210/// Reference to a stack for management permissions
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
213#[serde(rename_all = "camelCase")]
214pub enum StackRef {
215    /// Reference to the current stack being built
216    Current,
217    /// Reference to another stack by ID
218    External(String),
219}
220
221impl StackRef {
222    /// Create a StackRef from a stack reference
223    pub fn from_stack(stack: &Stack) -> Self {
224        StackRef::External(stack.id().to_string())
225    }
226}
227
228impl From<&Stack> for StackRef {
229    fn from(stack: &Stack) -> Self {
230        StackRef::External(stack.id().to_string())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::resource::ResourceLifecycle;
238    use crate::{PermissionSetReference, Storage, Worker};
239    use insta::assert_json_snapshot;
240
241    #[test]
242    fn test_stack_serialization() {
243        use crate::WorkerCode;
244
245        let storage = Storage::new("my-bucket".to_string())
246            .public_read(true)
247            .build();
248
249        let worker = Worker::new("my-worker".to_string())
250            .code(WorkerCode::Image {
251                image: "rust:latest".to_string(),
252            })
253            .permissions("execution".to_string())
254            .link(&storage)
255            .build();
256
257        // Create permission profiles for the new system
258        let mut permissions = IndexMap::new();
259        let mut execution_profile = PermissionProfile::new();
260        execution_profile.0.insert(
261            "*".to_string(),
262            vec![
263                PermissionSetReference::from_name("storage/data-read"),
264                PermissionSetReference::from_name("storage/data-write"),
265            ],
266        );
267        permissions.insert("execution".to_string(), execution_profile);
268
269        let stack_builder = Stack::new("test-stack".to_string())
270            .add(storage, ResourceLifecycle::Frozen)
271            .add(worker.clone(), ResourceLifecycle::Live);
272
273        let stack = stack_builder
274            .permissions(PermissionsConfig {
275                profiles: permissions,
276                management: ManagementPermissions::Auto,
277            })
278            .build();
279
280        // Serialize and Deserialize
281        let serialized_stack =
282            serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
283        let deserialized_stack: Stack =
284            serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
285
286        // Assert equality
287        assert_eq!(
288            stack, deserialized_stack,
289            "Original and deserialized stacks do not match."
290        );
291
292        // Verify snapshot (sort maps to be deterministic across Rust versions)
293        let mut settings = insta::Settings::clone_current();
294        settings.set_sort_maps(true);
295        settings.bind(|| {
296            assert_json_snapshot!("stack_serialization_account_managed", stack);
297        });
298    }
299
300    #[test]
301    fn test_empty_stack_serialization() {
302        let stack_builder = Stack::new("empty-test-stack".to_string());
303
304        let stack = stack_builder
305            .permissions(PermissionsConfig::new()) // Empty permissions for existing tests
306            .build();
307
308        // Serialize and Deserialize
309        let serialized_stack =
310            serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
311        let deserialized_stack: Stack =
312            serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
313
314        // Assert equality
315        assert_eq!(
316            stack, deserialized_stack,
317            "Original and deserialized empty stacks do not match."
318        );
319
320        // Verify snapshot (sort maps to be deterministic across Rust versions)
321        let mut settings = insta::Settings::clone_current();
322        settings.set_sort_maps(true);
323        settings.bind(|| {
324            assert_json_snapshot!("empty_stack_serialization_account", stack);
325        });
326    }
327
328    #[test]
329    fn test_stack_with_permissions() {
330        use crate::permissions::PermissionProfile;
331        use indexmap::IndexMap;
332
333        // Create a simple stack with permissions
334        let storage = Storage::new("test-storage".to_string()).build();
335
336        // Create a permission profile
337        let mut permission_profile = PermissionProfile::new();
338        permission_profile.0.insert(
339            "*".to_string(),
340            vec![PermissionSetReference::from_name("storage/data-read")],
341        );
342
343        let mut permissions = IndexMap::new();
344        permissions.insert("reader".to_string(), permission_profile);
345
346        let stack = Stack::new("test-permissions-stack".to_string())
347            .add(storage, ResourceLifecycle::Frozen)
348            .permissions(PermissionsConfig {
349                profiles: permissions,
350                management: ManagementPermissions::Auto,
351            })
352            .build();
353
354        // Verify permissions are accessible
355        assert_eq!(stack.permission_profiles().len(), 1);
356        assert!(stack.permission_profiles().contains_key("reader"));
357
358        let reader_profile = stack.permission_profiles().get("reader").unwrap();
359        assert_eq!(reader_profile.0.len(), 1);
360        assert!(reader_profile.0.contains_key("*"));
361
362        let global_permissions = reader_profile.0.get("*").unwrap();
363        assert_eq!(
364            global_permissions,
365            &vec![PermissionSetReference::from_name("storage/data-read")]
366        );
367
368        // Test serialization/deserialization
369        let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
370        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
371        assert_eq!(stack, deserialized);
372    }
373
374    #[test]
375    fn test_stack_with_management_permissions() {
376        use crate::permissions::{ManagementPermissions, PermissionProfile};
377
378        // Create a simple stack with management permissions
379        let storage = Storage::new("test-storage".to_string()).build();
380
381        // Create a permission profile for management
382        let mut management_profile = PermissionProfile::new();
383        management_profile.0.insert(
384            "*".to_string(),
385            vec![PermissionSetReference::from_name("vault/data-write")],
386        );
387
388        // Test auto management permissions (default)
389        let stack_auto = Stack::new("test-auto-management-stack".to_string())
390            .add(storage.clone(), ResourceLifecycle::Frozen)
391            .management(ManagementPermissions::auto())
392            .build();
393
394        assert!(stack_auto.management().is_auto());
395        assert!(stack_auto.management().profile().is_none());
396
397        // Test extend management permissions
398        let stack_extend = Stack::new("test-extend-management-stack".to_string())
399            .add(storage.clone(), ResourceLifecycle::Frozen)
400            .management(ManagementPermissions::extend(management_profile.clone()))
401            .build();
402
403        assert!(stack_extend.management().is_extend());
404        assert_eq!(
405            stack_extend.management().profile().unwrap(),
406            &management_profile
407        );
408
409        // Test override management permissions
410        let stack_override = Stack::new("test-override-management-stack".to_string())
411            .add(storage.clone(), ResourceLifecycle::Frozen)
412            .management(ManagementPermissions::override_(management_profile.clone()))
413            .build();
414
415        assert!(stack_override.management().is_override());
416        assert_eq!(
417            stack_override.management().profile().unwrap(),
418            &management_profile
419        );
420
421        // Test default management permissions
422        let stack_default = Stack::new("test-default-management-stack".to_string())
423            .add(storage, ResourceLifecycle::Frozen)
424            .build();
425
426        assert!(stack_default.management().is_auto());
427
428        // Test serialization/deserialization with management
429        let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
430        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
431        assert_eq!(stack_extend, deserialized);
432    }
433}