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};
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    /// Resource configuration (can be any type of resource)
13    pub config: Resource,
14    /// Lifecycle management configuration for this resource
15    pub lifecycle: ResourceLifecycle,
16    /// Additional dependencies for this resource beyond those defined in the resource itself.
17    /// The total dependencies are: resource.get_dependencies() + this list
18    pub dependencies: Vec<ResourceRef>,
19    /// Enable remote bindings for this resource (BYOB use case).
20    /// When true, binding params are synced to StackState's `remote_binding_params`.
21    /// Default: false (prevents sensitive data in synced state).
22    #[serde(default)]
23    pub remote_access: bool,
24}
25
26/// A bag of resources, unaware of any cloud.
27#[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    /// Unique identifier for the stack
33    #[builder(start_fn)]
34    pub id: String,
35    /// Map of resource IDs to their configurations and lifecycle settings
36    #[builder(field)]
37    pub resources: IndexMap<String, ResourceEntry>,
38    /// Combined permissions configuration containing both profiles and management
39    #[builder(field)]
40    #[serde(default)]
41    pub permissions: PermissionsConfig,
42    /// Which platforms this stack supports. When None, all platforms are supported.
43    #[builder(field)]
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub supported_platforms: Option<Vec<Platform>>,
46    /// Input definitions required before setup or deployment can proceed.
47    #[builder(field)]
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub inputs: Vec<StackInputDefinition>,
50}
51
52impl Stack {
53    /// Returns a deterministic digest of the complete Frozen resource set.
54    /// Resource and object-key ordering do not affect the digest.
55    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    /// Returns an iterator over the resources in the stack, including their lifecycle state.
74    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
75        self.resources.iter()
76    }
77
78    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
79    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    /// Create a reference to the current stack
88    pub fn current() -> StackRef {
89        StackRef::Current
90    }
91
92    /// Returns the permissions configuration for the stack.
93    pub fn permissions(&self) -> &PermissionsConfig {
94        &self.permissions
95    }
96
97    /// Returns the permission profiles for the stack.
98    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
99        &self.permissions.profiles
100    }
101
102    /// Returns the management permissions configuration for the stack.
103    pub fn management(&self) -> &ManagementPermissions {
104        &self.permissions.management
105    }
106
107    /// Returns the supported platforms, or None if all platforms are supported.
108    pub fn supported_platforms(&self) -> Option<&[Platform]> {
109        self.supported_platforms.as_deref()
110    }
111
112    /// Returns stack input definitions.
113    pub fn inputs(&self) -> &[StackInputDefinition] {
114        &self.inputs
115    }
116
117    /// Returns true if the given platform is supported by this stack.
118    /// When supported_platforms is None, all platforms are supported.
119    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    /// Adds a resource to the stack with its lifecycle state.
148    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
149    /// Use add_with_dependencies() if you need to specify additional dependencies.
150    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    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
159    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
160    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    /// Adds a resource with remote access enabled.
180    /// When remote_access is true, binding params are synced to StackState for external access.
181    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    /// Sets the permissions configuration for the stack.
200    /// This defines access control for compute services in the stack.
201    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
202        self.permissions = permissions;
203        self
204    }
205
206    /// Add a single permission profile to the stack - allows fluent chaining
207    ///
208    /// # Example
209    /// ```rust
210    /// # use alien_core::{Stack, permissions::PermissionProfile};
211    /// Stack::new("my-stack".to_string())
212    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
213    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
214    ///     .build()
215    /// # ;
216    /// ```
217    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    /// Sets the supported platforms for this stack.
223    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
224        self.supported_platforms = Some(platforms);
225        self
226    }
227
228    /// Sets stack input definitions.
229    pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
230        self.inputs = inputs;
231        self
232    }
233
234    /// Sets the management permissions configuration for the stack.
235    /// This defines how management permissions are derived and configured.
236    ///
237    /// # Examples
238    /// ```rust
239    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
240    /// // Auto-derived management permissions (default)
241    /// Stack::new("my-stack".to_string())
242    ///     .management(ManagementPermissions::auto())
243    ///     .build();
244    ///
245    /// // Extend auto-derived permissions
246    /// Stack::new("my-stack".to_string())
247    ///     .management(ManagementPermissions::extend(
248    ///         PermissionProfile::new().global(["vault/data-write"])
249    ///     ))
250    ///     .build();
251    ///
252    /// // Override auto-derived permissions entirely
253    /// Stack::new("my-stack".to_string())
254    ///     .management(ManagementPermissions::override_(
255    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
256    ///     ))
257    ///     .build();
258    /// ```
259    pub fn management(mut self, management: ManagementPermissions) -> Self {
260        self.permissions.management = management;
261        self
262    }
263}
264
265/// Reference to a stack for management permissions
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268#[serde(rename_all = "camelCase")]
269pub enum StackRef {
270    /// Reference to the current stack being built
271    Current,
272    /// Reference to another stack by ID
273    External(String),
274}
275
276impl StackRef {
277    /// Create a StackRef from a stack reference
278    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        // Create permission profiles for the new system
316        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        // Serialize and Deserialize
339        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 equality
345        assert_eq!(
346            stack, deserialized_stack,
347            "Original and deserialized stacks do not match."
348        );
349
350        // Verify snapshot (sort maps to be deterministic across Rust versions)
351        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()) // Empty permissions for existing tests
364            .build();
365
366        // Serialize and Deserialize
367        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 equality
373        assert_eq!(
374            stack, deserialized_stack,
375            "Original and deserialized empty stacks do not match."
376        );
377
378        // Verify snapshot (sort maps to be deterministic across Rust versions)
379        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        // Create a simple stack with permissions
461        let storage = Storage::new("test-storage".to_string()).build();
462
463        // Create a permission profile
464        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        // Verify permissions are accessible
482        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        // Test serialization/deserialization
496        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        // Create a simple stack with management permissions
506        let storage = Storage::new("test-storage".to_string()).build();
507
508        // Create a permission profile for management
509        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        // Test auto management permissions (default)
516        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        // Test extend management permissions
525        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        // Test override management permissions
537        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        // Test default management permissions
549        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        // Test serialization/deserialization with management
556        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}