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    /// Id of the boolean stack input that decides whether this resource is
25    /// created at all. `None` means always create it.
26    ///
27    /// Set by `.enabled(input)` in the SDK. Setup emitters render the resource
28    /// conditionally on the matching template variable, so a deployer who says no
29    /// never gets the resource, its outputs, or anything derived from it.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub enabled_when: Option<String>,
32}
33
34/// A bag of resources, unaware of any cloud.
35#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(rename_all = "camelCase")]
38#[builder(start_fn = new)]
39pub struct Stack {
40    /// Unique identifier for the stack
41    #[builder(start_fn)]
42    pub id: String,
43    /// Map of resource IDs to their configurations and lifecycle settings
44    #[builder(field)]
45    pub resources: IndexMap<String, ResourceEntry>,
46    /// Combined permissions configuration containing both profiles and management
47    #[builder(field)]
48    #[serde(default)]
49    pub permissions: PermissionsConfig,
50    /// Which platforms this stack supports. When None, all platforms are supported.
51    #[builder(field)]
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub supported_platforms: Option<Vec<Platform>>,
54    /// Input definitions required before setup or deployment can proceed.
55    #[builder(field)]
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub inputs: Vec<StackInputDefinition>,
58}
59
60impl Stack {
61    /// Returns a deterministic digest of the complete Frozen resource set.
62    /// Resource and object-key ordering do not affect the digest.
63    pub fn frozen_resources_digest(&self) -> String {
64        let mut resources = self
65            .resources
66            .iter()
67            .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
68            .map(|(id, entry)| {
69                let mut value =
70                    serde_json::to_value(entry).expect("resource entries always serialize to JSON");
71                canonicalize_json(&mut value);
72                (id, value)
73            })
74            .collect::<Vec<_>>();
75        resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
76
77        let encoded = serde_json::to_vec(&resources)
78            .expect("canonical Frozen resource projection always serializes");
79        format!("{:x}", Sha256::digest(encoded))
80    }
81    /// Returns an iterator over the resources in the stack, including their lifecycle state.
82    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
83        self.resources.iter()
84    }
85
86    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
87    pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
88        self.resources.iter_mut()
89    }
90
91    pub fn id(&self) -> &str {
92        &self.id
93    }
94
95    /// Create a reference to the current stack
96    pub fn current() -> StackRef {
97        StackRef::Current
98    }
99
100    /// Returns the permissions configuration for the stack.
101    pub fn permissions(&self) -> &PermissionsConfig {
102        &self.permissions
103    }
104
105    /// Returns the permission profiles for the stack.
106    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
107        &self.permissions.profiles
108    }
109
110    /// Returns the management permissions configuration for the stack.
111    pub fn management(&self) -> &ManagementPermissions {
112        &self.permissions.management
113    }
114
115    /// Returns the supported platforms, or None if all platforms are supported.
116    pub fn supported_platforms(&self) -> Option<&[Platform]> {
117        self.supported_platforms.as_deref()
118    }
119
120    /// Returns stack input definitions.
121    pub fn inputs(&self) -> &[StackInputDefinition] {
122        &self.inputs
123    }
124
125    /// Returns true if the given platform is supported by this stack.
126    /// When supported_platforms is None, all platforms are supported.
127    pub fn supports_platform(&self, platform: &Platform) -> bool {
128        match &self.supported_platforms {
129            Some(platforms) => platforms.contains(platform),
130            None => true,
131        }
132    }
133}
134
135fn canonicalize_json(value: &mut serde_json::Value) {
136    match value {
137        serde_json::Value::Array(values) => {
138            for value in values {
139                canonicalize_json(value);
140            }
141        }
142        serde_json::Value::Object(object) => {
143            let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
144            entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
145            for (key, mut value) in entries {
146                canonicalize_json(&mut value);
147                object.insert(key, value);
148            }
149        }
150        _ => {}
151    }
152}
153
154impl StackBuilder {
155    /// Adds a resource to the stack with its lifecycle state.
156    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
157    /// Use add_with_dependencies() if you need to specify additional dependencies.
158    pub fn add<T: crate::ResourceDefinition>(
159        self,
160        resource: T,
161        lifecycle: ResourceLifecycle,
162    ) -> Self {
163        self.add_with_dependencies(resource, lifecycle, vec![])
164    }
165
166    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
167    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
168    pub fn add_with_dependencies<T: crate::ResourceDefinition>(
169        self,
170        resource: T,
171        lifecycle: ResourceLifecycle,
172        additional_dependencies: Vec<ResourceRef>,
173    ) -> Self {
174        let mut entry = Self::entry(resource, lifecycle);
175        entry.dependencies = additional_dependencies;
176        self.insert(entry)
177    }
178
179    /// Adds a resource whose creation follows a boolean stack input.
180    /// The deployer's answer decides whether it is provisioned at all.
181    ///
182    /// Stacks are authored through the TypeScript SDK's `.enabled(input)`, which
183    /// sets the field on the resource; this is the Rust-side seam the generator
184    /// and preflight tests build gated stacks with.
185    #[doc(hidden)]
186    pub fn add_enabled_when<T: crate::ResourceDefinition>(
187        self,
188        resource: T,
189        lifecycle: ResourceLifecycle,
190        input_id: impl Into<String>,
191    ) -> Self {
192        let mut entry = Self::entry(resource, lifecycle);
193        entry.enabled_when = Some(input_id.into());
194        self.insert(entry)
195    }
196
197    /// Adds a resource with remote access enabled.
198    /// When remote_access is true, binding params are synced to StackState for external access.
199    pub fn add_with_remote_access<T: crate::ResourceDefinition>(
200        self,
201        resource: T,
202        lifecycle: ResourceLifecycle,
203    ) -> Self {
204        let mut entry = Self::entry(resource, lifecycle);
205        entry.remote_access = true;
206        self.insert(entry)
207    }
208
209    /// The only place a `ResourceEntry` is spelled out. Each public `add_*`
210    /// varies one field of it, so a new per-entry field costs one edit here
211    /// instead of one per method.
212    fn entry<T: crate::ResourceDefinition>(
213        resource: T,
214        lifecycle: ResourceLifecycle,
215    ) -> ResourceEntry {
216        ResourceEntry {
217            config: Resource::new(resource),
218            lifecycle,
219            dependencies: Vec::new(),
220            remote_access: false,
221            enabled_when: None,
222        }
223    }
224
225    fn insert(mut self, entry: ResourceEntry) -> Self {
226        self.resources.insert(entry.config.id().to_string(), entry);
227        self
228    }
229
230    /// Sets the permissions configuration for the stack.
231    /// This defines access control for compute services in the stack.
232    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
233        self.permissions = permissions;
234        self
235    }
236
237    /// Add a single permission profile to the stack - allows fluent chaining
238    ///
239    /// # Example
240    /// ```rust
241    /// # use alien_core::{Stack, permissions::PermissionProfile};
242    /// Stack::new("my-stack".to_string())
243    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
244    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
245    ///     .build()
246    /// # ;
247    /// ```
248    pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
249        self.permissions.profiles.insert(name.into(), profile);
250        self
251    }
252
253    /// Sets the supported platforms for this stack.
254    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
255        self.supported_platforms = Some(platforms);
256        self
257    }
258
259    /// Sets stack input definitions.
260    pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
261        self.inputs = inputs;
262        self
263    }
264
265    /// Sets the management permissions configuration for the stack.
266    /// This defines how management permissions are derived and configured.
267    ///
268    /// # Examples
269    /// ```rust
270    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
271    /// // Auto-derived management permissions (default)
272    /// Stack::new("my-stack".to_string())
273    ///     .management(ManagementPermissions::auto())
274    ///     .build();
275    ///
276    /// // Extend auto-derived permissions
277    /// Stack::new("my-stack".to_string())
278    ///     .management(ManagementPermissions::extend(
279    ///         PermissionProfile::new().global(["vault/data-write"])
280    ///     ))
281    ///     .build();
282    ///
283    /// // Override auto-derived permissions entirely
284    /// Stack::new("my-stack".to_string())
285    ///     .management(ManagementPermissions::override_(
286    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
287    ///     ))
288    ///     .build();
289    /// ```
290    pub fn management(mut self, management: ManagementPermissions) -> Self {
291        self.permissions.management = management;
292        self
293    }
294}
295
296/// Reference to a stack for management permissions
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
299#[serde(rename_all = "camelCase")]
300pub enum StackRef {
301    /// Reference to the current stack being built
302    Current,
303    /// Reference to another stack by ID
304    External(String),
305}
306
307impl StackRef {
308    /// Create a StackRef from a stack reference
309    pub fn from_stack(stack: &Stack) -> Self {
310        StackRef::External(stack.id().to_string())
311    }
312}
313
314impl From<&Stack> for StackRef {
315    fn from(stack: &Stack) -> Self {
316        StackRef::External(stack.id().to_string())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::resource::ResourceLifecycle;
324    use crate::{
325        Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
326        Storage, Worker, WorkerCode,
327    };
328    use insta::assert_json_snapshot;
329
330    #[test]
331    fn test_stack_serialization() {
332        use crate::WorkerCode;
333
334        let storage = Storage::new("my-bucket".to_string())
335            .public_read(true)
336            .build();
337
338        let worker = Worker::new("my-worker".to_string())
339            .code(WorkerCode::Image {
340                image: "rust:latest".to_string(),
341            })
342            .permissions("execution".to_string())
343            .link(&storage)
344            .build();
345
346        // Create permission profiles for the new system
347        let mut permissions = IndexMap::new();
348        let mut execution_profile = PermissionProfile::new();
349        execution_profile.0.insert(
350            "*".to_string(),
351            vec![
352                PermissionSetReference::from_name("storage/data-read"),
353                PermissionSetReference::from_name("storage/data-write"),
354            ],
355        );
356        permissions.insert("execution".to_string(), execution_profile);
357
358        let stack_builder = Stack::new("test-stack".to_string())
359            .add(storage, ResourceLifecycle::Frozen)
360            .add(worker.clone(), ResourceLifecycle::Live);
361
362        let stack = stack_builder
363            .permissions(PermissionsConfig {
364                profiles: permissions,
365                management: ManagementPermissions::Auto,
366            })
367            .build();
368
369        // Serialize and Deserialize
370        let serialized_stack =
371            serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
372        let deserialized_stack: Stack =
373            serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
374
375        // Assert equality
376        assert_eq!(
377            stack, deserialized_stack,
378            "Original and deserialized stacks do not match."
379        );
380
381        // Verify snapshot (sort maps to be deterministic across Rust versions)
382        let mut settings = insta::Settings::clone_current();
383        settings.set_sort_maps(true);
384        settings.bind(|| {
385            assert_json_snapshot!("stack_serialization_account_managed", stack);
386        });
387    }
388
389    #[test]
390    fn test_empty_stack_serialization() {
391        let stack_builder = Stack::new("empty-test-stack".to_string());
392
393        let stack = stack_builder
394            .permissions(PermissionsConfig::new()) // Empty permissions for existing tests
395            .build();
396
397        // Serialize and Deserialize
398        let serialized_stack =
399            serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
400        let deserialized_stack: Stack =
401            serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
402
403        // Assert equality
404        assert_eq!(
405            stack, deserialized_stack,
406            "Original and deserialized empty stacks do not match."
407        );
408
409        // Verify snapshot (sort maps to be deterministic across Rust versions)
410        let mut settings = insta::Settings::clone_current();
411        settings.set_sort_maps(true);
412        settings.bind(|| {
413            assert_json_snapshot!("empty_stack_serialization_account", stack);
414        });
415    }
416
417    #[test]
418    fn stack_deserializes_resources_without_public_endpoints() {
419        let container = Container::new("api".to_string())
420            .code(ContainerCode::Image {
421                image: "example.com/api:latest".to_string(),
422            })
423            .cpu(ResourceSpec {
424                min: "0.5".to_string(),
425                desired: "1".to_string(),
426            })
427            .memory(ResourceSpec {
428                min: "512Mi".to_string(),
429                desired: "1Gi".to_string(),
430            })
431            .port(8080)
432            .permissions("container-execution".to_string())
433            .build();
434        let daemon = Daemon::new("agent".to_string())
435            .code(DaemonCode::Image {
436                image: "example.com/agent:latest".to_string(),
437            })
438            .permissions("daemon-execution".to_string())
439            .build();
440        let worker = Worker::new("worker".to_string())
441            .code(WorkerCode::Image {
442                image: "example.com/worker:latest".to_string(),
443            })
444            .permissions("worker-execution".to_string())
445            .build();
446        let stack = Stack::new("legacy-stack".to_string())
447            .add(container, ResourceLifecycle::Live)
448            .add(daemon, ResourceLifecycle::Live)
449            .add(worker, ResourceLifecycle::Live)
450            .build();
451
452        let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
453        for resource_id in ["api", "agent", "worker"] {
454            legacy_json
455                .pointer_mut(&format!("/resources/{resource_id}/config"))
456                .and_then(serde_json::Value::as_object_mut)
457                .expect("resource config should be an object")
458                .remove("publicEndpoints");
459        }
460
461        let stack: Stack =
462            serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
463
464        let container = stack
465            .resources
466            .get("api")
467            .and_then(|entry| entry.config.downcast_ref::<Container>())
468            .expect("api should be a container");
469        assert!(container.public_endpoints.is_empty());
470
471        let daemon = stack
472            .resources
473            .get("agent")
474            .and_then(|entry| entry.config.downcast_ref::<Daemon>())
475            .expect("agent should be a daemon");
476        assert!(daemon.public_endpoints.is_empty());
477
478        let worker = stack
479            .resources
480            .get("worker")
481            .and_then(|entry| entry.config.downcast_ref::<Worker>())
482            .expect("worker should be a worker");
483        assert!(worker.public_endpoints.is_empty());
484    }
485
486    #[test]
487    fn test_stack_with_permissions() {
488        use crate::permissions::PermissionProfile;
489        use indexmap::IndexMap;
490
491        // Create a simple stack with permissions
492        let storage = Storage::new("test-storage".to_string()).build();
493
494        // Create a permission profile
495        let mut permission_profile = PermissionProfile::new();
496        permission_profile.0.insert(
497            "*".to_string(),
498            vec![PermissionSetReference::from_name("storage/data-read")],
499        );
500
501        let mut permissions = IndexMap::new();
502        permissions.insert("reader".to_string(), permission_profile);
503
504        let stack = Stack::new("test-permissions-stack".to_string())
505            .add(storage, ResourceLifecycle::Frozen)
506            .permissions(PermissionsConfig {
507                profiles: permissions,
508                management: ManagementPermissions::Auto,
509            })
510            .build();
511
512        // Verify permissions are accessible
513        assert_eq!(stack.permission_profiles().len(), 1);
514        assert!(stack.permission_profiles().contains_key("reader"));
515
516        let reader_profile = stack.permission_profiles().get("reader").unwrap();
517        assert_eq!(reader_profile.0.len(), 1);
518        assert!(reader_profile.0.contains_key("*"));
519
520        let global_permissions = reader_profile.0.get("*").unwrap();
521        assert_eq!(
522            global_permissions,
523            &vec![PermissionSetReference::from_name("storage/data-read")]
524        );
525
526        // Test serialization/deserialization
527        let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
528        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
529        assert_eq!(stack, deserialized);
530    }
531
532    #[test]
533    fn test_stack_with_management_permissions() {
534        use crate::permissions::{ManagementPermissions, PermissionProfile};
535
536        // Create a simple stack with management permissions
537        let storage = Storage::new("test-storage".to_string()).build();
538
539        // Create a permission profile for management
540        let mut management_profile = PermissionProfile::new();
541        management_profile.0.insert(
542            "*".to_string(),
543            vec![PermissionSetReference::from_name("vault/data-write")],
544        );
545
546        // Test auto management permissions (default)
547        let stack_auto = Stack::new("test-auto-management-stack".to_string())
548            .add(storage.clone(), ResourceLifecycle::Frozen)
549            .management(ManagementPermissions::auto())
550            .build();
551
552        assert!(stack_auto.management().is_auto());
553        assert!(stack_auto.management().profile().is_none());
554
555        // Test extend management permissions
556        let stack_extend = Stack::new("test-extend-management-stack".to_string())
557            .add(storage.clone(), ResourceLifecycle::Frozen)
558            .management(ManagementPermissions::extend(management_profile.clone()))
559            .build();
560
561        assert!(stack_extend.management().is_extend());
562        assert_eq!(
563            stack_extend.management().profile().unwrap(),
564            &management_profile
565        );
566
567        // Test override management permissions
568        let stack_override = Stack::new("test-override-management-stack".to_string())
569            .add(storage.clone(), ResourceLifecycle::Frozen)
570            .management(ManagementPermissions::override_(management_profile.clone()))
571            .build();
572
573        assert!(stack_override.management().is_override());
574        assert_eq!(
575            stack_override.management().profile().unwrap(),
576            &management_profile
577        );
578
579        // Test default management permissions
580        let stack_default = Stack::new("test-default-management-stack".to_string())
581            .add(storage, ResourceLifecycle::Frozen)
582            .build();
583
584        assert!(stack_default.management().is_auto());
585
586        // Test serialization/deserialization with management
587        let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
588        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
589        assert_eq!(stack_extend, deserialized);
590    }
591
592    #[test]
593    fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
594        let first = Stack::new("first".to_string())
595            .add(
596                Storage::new("alpha".to_string()).build(),
597                ResourceLifecycle::Frozen,
598            )
599            .add(
600                Storage::new("beta".to_string()).build(),
601                ResourceLifecycle::Frozen,
602            )
603            .add(
604                Storage::new("live-one".to_string()).build(),
605                ResourceLifecycle::Live,
606            )
607            .build();
608        let second = Stack::new("second".to_string())
609            .add(
610                Storage::new("beta".to_string()).build(),
611                ResourceLifecycle::Frozen,
612            )
613            .add(
614                Storage::new("alpha".to_string()).build(),
615                ResourceLifecycle::Frozen,
616            )
617            .add(
618                Storage::new("live-two".to_string()).build(),
619                ResourceLifecycle::Live,
620            )
621            .build();
622
623        assert_eq!(
624            first.frozen_resources_digest(),
625            second.frozen_resources_digest()
626        );
627    }
628}