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