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