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. Reserved vault locators support secret sync and metadata
53    /// checks, without exposing either vault through the 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| {
60                    matches!(
61                        vault.id.as_str(),
62                        crate::SECRETS_VAULT_ID | crate::DEPLOYER_SECRETS_VAULT_ID
63                    )
64                })
65    }
66}
67
68/// A bag of resources, unaware of any cloud.
69#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71#[serde(rename_all = "camelCase")]
72#[builder(start_fn = new)]
73pub struct Stack {
74    /// Unique identifier for the stack
75    #[builder(start_fn)]
76    pub id: String,
77    /// Map of resource IDs to their configurations and lifecycle settings
78    #[builder(field)]
79    pub resources: IndexMap<String, ResourceEntry>,
80    /// Combined permissions configuration containing both profiles and management
81    #[builder(field)]
82    #[serde(default)]
83    pub permissions: PermissionsConfig,
84    /// Which platforms this stack supports. When None, all platforms are supported.
85    #[builder(field)]
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub supported_platforms: Option<Vec<Platform>>,
88    /// Input definitions required before setup or deployment can proceed.
89    #[builder(field)]
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub inputs: Vec<StackInputDefinition>,
92    /// Exact image repositories approved for containers created after installation.
93    /// The runtime API also requires an immutable SHA-256 digest.
94    #[builder(field)]
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub dynamic_container_repositories: Vec<String>,
97    /// Released Container resources whose image repositories are approved for
98    /// containers created after installation.
99    #[builder(field)]
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub dynamic_container_image_resources: Vec<String>,
102    /// Operations this stack's deployments run: the plugins, their settings, and
103    /// which operations run without approval. Changing them takes a new release.
104    #[builder(field)]
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub operations: Option<crate::OperationsConfig>,
107}
108
109impl Stack {
110    /// Digest of what setup owns: every Frozen entry plus each Live sandbox's setup inputs. Without
111    /// a Live sandbox the bytes equal the Frozen-only projection. Declined gated entries must be
112    /// stripped by the caller.
113    pub fn setup_owned_digest(&self) -> String {
114        let mut resources = self
115            .resources
116            .iter()
117            .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
118            .map(|(id, entry)| {
119                let mut value =
120                    serde_json::to_value(entry).expect("resource entries always serialize to JSON");
121                canonicalize_json(&mut value);
122                (id, value)
123            })
124            .collect::<Vec<_>>();
125        resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
126
127        let mut live_sandbox_inputs = self
128            .resources
129            .iter()
130            .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Live)
131            .filter_map(|(id, entry)| {
132                let sandbox = entry.config.downcast_ref::<crate::Sandbox>()?;
133                let mut inputs = crate::sandbox_setup_inputs::comparable_aws_sandbox_setup_inputs(
134                    self,
135                    sandbox,
136                    entry.lifecycle,
137                );
138                for (_, value) in &mut inputs {
139                    canonicalize_json(value);
140                }
141                Some((id, inputs))
142            })
143            .collect::<Vec<_>>();
144        live_sandbox_inputs.sort_unstable_by_key(|(id, _)| *id);
145
146        let encoded = if live_sandbox_inputs.is_empty() {
147            serde_json::to_vec(&resources)
148        } else {
149            serde_json::to_vec(&(&resources, &live_sandbox_inputs))
150        }
151        .expect("canonical setup-owned projection always serializes");
152        format!("{:x}", Sha256::digest(encoded))
153    }
154
155    /// Binds a setup update to both its resources and installed permissions.
156    /// Runtime code changes remain outside this authorization.
157    pub fn setup_update_digest(&self) -> String {
158        let mut permissions = serde_json::to_value(&self.permissions)
159            .expect("permission profiles always serialize to JSON");
160        canonicalize_json(&mut permissions);
161        let encoded = serde_json::to_vec(&(self.setup_owned_digest(), permissions))
162            .expect("setup update projection always serializes");
163        format!("{:x}", Sha256::digest(encoded))
164    }
165
166    /// Returns an iterator over the resources in the stack, including their lifecycle state.
167    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
168        self.resources.iter()
169    }
170
171    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
172    pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
173        self.resources.iter_mut()
174    }
175
176    pub fn id(&self) -> &str {
177        &self.id
178    }
179
180    /// Create a reference to the current stack
181    pub fn current() -> StackRef {
182        StackRef::Current
183    }
184
185    /// Returns the permissions configuration for the stack.
186    pub fn permissions(&self) -> &PermissionsConfig {
187        &self.permissions
188    }
189
190    /// Returns the permission profiles for the stack.
191    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
192        &self.permissions.profiles
193    }
194
195    /// Returns the management permissions configuration for the stack.
196    pub fn management(&self) -> &ManagementPermissions {
197        &self.permissions.management
198    }
199
200    /// Returns the supported platforms, or None if all platforms are supported.
201    pub fn supported_platforms(&self) -> Option<&[Platform]> {
202        self.supported_platforms.as_deref()
203    }
204
205    /// Returns stack input definitions.
206    pub fn inputs(&self) -> &[StackInputDefinition] {
207        &self.inputs
208    }
209
210    /// Returns true if the given platform is supported by this stack.
211    /// When supported_platforms is None, all platforms are supported.
212    pub fn supports_platform(&self, platform: &Platform) -> bool {
213        match &self.supported_platforms {
214            Some(platforms) => platforms.contains(platform),
215            None => true,
216        }
217    }
218}
219
220fn canonicalize_json(value: &mut serde_json::Value) {
221    match value {
222        serde_json::Value::Array(values) => {
223            for value in values {
224                canonicalize_json(value);
225            }
226        }
227        serde_json::Value::Object(object) => {
228            let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
229            entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
230            for (key, mut value) in entries {
231                canonicalize_json(&mut value);
232                object.insert(key, value);
233            }
234        }
235        _ => {}
236    }
237}
238
239impl StackBuilder {
240    /// Adds a resource to the stack with its lifecycle state.
241    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
242    /// Use add_with_dependencies() if you need to specify additional dependencies.
243    pub fn add<T: crate::ResourceDefinition>(
244        self,
245        resource: T,
246        lifecycle: ResourceLifecycle,
247    ) -> Self {
248        self.add_with_dependencies(resource, lifecycle, vec![])
249    }
250
251    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
252    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
253    pub fn add_with_dependencies<T: crate::ResourceDefinition>(
254        self,
255        resource: T,
256        lifecycle: ResourceLifecycle,
257        additional_dependencies: Vec<ResourceRef>,
258    ) -> Self {
259        let mut entry = Self::entry(resource, lifecycle);
260        entry.dependencies = additional_dependencies;
261        self.insert(entry)
262    }
263
264    /// Adds a resource whose creation follows a boolean stack input.
265    /// The deployer's answer decides whether it is provisioned at all.
266    ///
267    /// Stacks are authored through the TypeScript SDK's `.enabled(input)`, which
268    /// sets the field on the resource; this is the Rust-side seam the generator
269    /// and preflight tests build gated stacks with.
270    #[doc(hidden)]
271    pub fn add_enabled_when<T: crate::ResourceDefinition>(
272        self,
273        resource: T,
274        lifecycle: ResourceLifecycle,
275        input_id: impl Into<String>,
276    ) -> Self {
277        let mut entry = Self::entry(resource, lifecycle);
278        entry.enabled_when = Some(input_id.into());
279        self.insert(entry)
280    }
281
282    /// Adds a resource with remote access enabled.
283    /// When remote_access is true, binding params are synced to StackState for external access.
284    pub fn add_with_remote_access<T: crate::ResourceDefinition>(
285        self,
286        resource: T,
287        lifecycle: ResourceLifecycle,
288    ) -> Self {
289        let mut entry = Self::entry(resource, lifecycle);
290        entry.remote_access = true;
291        self.insert(entry)
292    }
293
294    /// The only place a `ResourceEntry` is spelled out. Each public `add_*`
295    /// varies one field of it, so a new per-entry field costs one edit here
296    /// instead of one per method.
297    fn entry<T: crate::ResourceDefinition>(
298        resource: T,
299        lifecycle: ResourceLifecycle,
300    ) -> ResourceEntry {
301        ResourceEntry {
302            config: Resource::new(resource),
303            lifecycle,
304            dependencies: Vec::new(),
305            remote_access: false,
306            enabled_when: None,
307        }
308    }
309
310    fn insert(mut self, entry: ResourceEntry) -> Self {
311        self.resources.insert(entry.config.id().to_string(), entry);
312        self
313    }
314
315    /// Sets the permissions configuration for the stack.
316    /// This defines access control for compute services in the stack.
317    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
318        self.permissions = permissions;
319        self
320    }
321
322    /// Add a single permission profile to the stack - allows fluent chaining
323    ///
324    /// # Example
325    /// ```rust
326    /// # use alien_core::{Stack, permissions::PermissionProfile};
327    /// Stack::new("my-stack".to_string())
328    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
329    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
330    ///     .build()
331    /// # ;
332    /// ```
333    pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
334        self.permissions.profiles.insert(name.into(), profile);
335        self
336    }
337
338    /// Sets the supported platforms for this stack.
339    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
340        self.supported_platforms = Some(platforms);
341        self
342    }
343
344    /// Sets stack input definitions.
345    pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
346        self.inputs = inputs;
347        self
348    }
349
350    /// Declares the operations this stack's deployments run.
351    pub fn operations(mut self, operations: crate::OperationsConfig) -> Self {
352        self.operations = Some(operations);
353        self
354    }
355
356    /// Sets the management permissions configuration for the stack.
357    /// This defines how management permissions are derived and configured.
358    ///
359    /// # Examples
360    /// ```rust
361    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
362    /// // Auto-derived management permissions (default)
363    /// Stack::new("my-stack".to_string())
364    ///     .management(ManagementPermissions::auto())
365    ///     .build();
366    ///
367    /// // Extend auto-derived permissions
368    /// Stack::new("my-stack".to_string())
369    ///     .management(ManagementPermissions::extend(
370    ///         PermissionProfile::new().global(["vault/data-write"])
371    ///     ))
372    ///     .build();
373    ///
374    /// // Override auto-derived permissions entirely
375    /// Stack::new("my-stack".to_string())
376    ///     .management(ManagementPermissions::override_(
377    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
378    ///     ))
379    ///     .build();
380    /// ```
381    pub fn management(mut self, management: ManagementPermissions) -> Self {
382        self.permissions.management = management;
383        self
384    }
385}
386
387/// Reference to a stack for management permissions
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
390#[serde(rename_all = "camelCase")]
391pub enum StackRef {
392    /// Reference to the current stack being built
393    Current,
394    /// Reference to another stack by ID
395    External(String),
396}
397
398impl StackRef {
399    /// Create a StackRef from a stack reference
400    pub fn from_stack(stack: &Stack) -> Self {
401        StackRef::External(stack.id().to_string())
402    }
403}
404
405impl From<&Stack> for StackRef {
406    fn from(stack: &Stack) -> Self {
407        StackRef::External(stack.id().to_string())
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::resource::ResourceLifecycle;
415    use crate::{
416        Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
417        Storage, Worker, WorkerCode,
418    };
419    use insta::assert_json_snapshot;
420
421    fn resource_entry<T: crate::ResourceDefinition>(
422        resource: T,
423        lifecycle: ResourceLifecycle,
424        remote_access: bool,
425    ) -> ResourceEntry {
426        ResourceEntry {
427            config: Resource::new(resource),
428            lifecycle,
429            dependencies: Vec::new(),
430            remote_access,
431            enabled_when: None,
432        }
433    }
434
435    /// The grant is rendered by setup, so a resource setup renders nothing for — a Live bucket —
436    /// cannot be published, while a Live sandbox can: setup still installs its scaffolding.
437    #[test]
438    fn remote_bindings_require_a_setup_rendered_resource_and_opt_in() {
439        assert!(resource_entry(
440            Storage::new("archive".to_string()).build(),
441            ResourceLifecycle::Frozen,
442            true,
443        )
444        .has_remote_bindings());
445        assert!(!resource_entry(
446            Storage::new("archive".to_string()).build(),
447            ResourceLifecycle::Frozen,
448            false,
449        )
450        .has_remote_bindings());
451        assert!(!resource_entry(
452            Storage::new("archive".to_string()).build(),
453            ResourceLifecycle::Live,
454            true,
455        )
456        .has_remote_bindings());
457        let sandbox = crate::Sandbox::new("agents".to_string())
458            .code(crate::SandboxCode::Image {
459                image: "s3://alien-bundles/sandbox/bundle.zip".to_string(),
460            })
461            .egress(crate::SandboxEgress::Allow)
462            .lifecycle(crate::SandboxLifecyclePolicy {
463                max_lifetime_seconds: None,
464                idle_pause_seconds: None,
465            })
466            .build();
467        assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
468        assert!(!resource_entry(
469            Worker::new("worker".to_string())
470                .code(WorkerCode::Image {
471                    image: "example.com/worker:latest".to_string(),
472                })
473                .permissions("worker-execution".to_string())
474                .build(),
475            ResourceLifecycle::Frozen,
476            true,
477        )
478        .has_remote_bindings());
479    }
480
481    #[test]
482    fn test_stack_serialization() {
483        use crate::WorkerCode;
484
485        let storage = Storage::new("my-bucket".to_string())
486            .public_read(true)
487            .build();
488
489        let worker = Worker::new("my-worker".to_string())
490            .code(WorkerCode::Image {
491                image: "rust:latest".to_string(),
492            })
493            .permissions("execution".to_string())
494            .link(&storage)
495            .build();
496
497        // Create permission profiles for the new system
498        let mut permissions = IndexMap::new();
499        let mut execution_profile = PermissionProfile::new();
500        execution_profile.0.insert(
501            "*".to_string(),
502            vec![
503                PermissionSetReference::from_name("storage/data-read"),
504                PermissionSetReference::from_name("storage/data-write"),
505            ],
506        );
507        permissions.insert("execution".to_string(), execution_profile);
508
509        let stack_builder = Stack::new("test-stack".to_string())
510            .add(storage, ResourceLifecycle::Frozen)
511            .add(worker.clone(), ResourceLifecycle::Live);
512
513        let stack = stack_builder
514            .permissions(PermissionsConfig {
515                profiles: permissions,
516                management: ManagementPermissions::Auto,
517            })
518            .build();
519
520        // Serialize and Deserialize
521        let serialized_stack =
522            serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
523        let deserialized_stack: Stack =
524            serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
525
526        // Assert equality
527        assert_eq!(
528            stack, deserialized_stack,
529            "Original and deserialized stacks do not match."
530        );
531
532        // Verify snapshot (sort maps to be deterministic across Rust versions)
533        let mut settings = insta::Settings::clone_current();
534        settings.set_sort_maps(true);
535        settings.bind(|| {
536            assert_json_snapshot!("stack_serialization_account_managed", stack);
537        });
538    }
539
540    #[test]
541    fn test_empty_stack_serialization() {
542        let stack_builder = Stack::new("empty-test-stack".to_string());
543
544        let stack = stack_builder
545            .permissions(PermissionsConfig::new()) // Empty permissions for existing tests
546            .build();
547
548        // Serialize and Deserialize
549        let serialized_stack =
550            serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
551        let deserialized_stack: Stack =
552            serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
553
554        // Assert equality
555        assert_eq!(
556            stack, deserialized_stack,
557            "Original and deserialized empty stacks do not match."
558        );
559
560        // Verify snapshot (sort maps to be deterministic across Rust versions)
561        let mut settings = insta::Settings::clone_current();
562        settings.set_sort_maps(true);
563        settings.bind(|| {
564            assert_json_snapshot!("empty_stack_serialization_account", stack);
565        });
566    }
567
568    #[test]
569    fn stack_deserializes_resources_without_public_endpoints() {
570        let container = Container::new("api".to_string())
571            .code(ContainerCode::Image {
572                image: "example.com/api:latest".to_string(),
573            })
574            .cpu(ResourceSpec {
575                min: "0.5".to_string(),
576                desired: "1".to_string(),
577            })
578            .memory(ResourceSpec {
579                min: "512Mi".to_string(),
580                desired: "1Gi".to_string(),
581            })
582            .port(8080)
583            .permissions("container-execution".to_string())
584            .build();
585        let daemon = Daemon::new("agent".to_string())
586            .code(DaemonCode::Image {
587                image: "example.com/agent:latest".to_string(),
588            })
589            .permissions("daemon-execution".to_string())
590            .build();
591        let worker = Worker::new("worker".to_string())
592            .code(WorkerCode::Image {
593                image: "example.com/worker:latest".to_string(),
594            })
595            .permissions("worker-execution".to_string())
596            .build();
597        let stack = Stack::new("legacy-stack".to_string())
598            .add(container, ResourceLifecycle::Live)
599            .add(daemon, ResourceLifecycle::Live)
600            .add(worker, ResourceLifecycle::Live)
601            .build();
602
603        let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
604        for resource_id in ["api", "agent", "worker"] {
605            legacy_json
606                .pointer_mut(&format!("/resources/{resource_id}/config"))
607                .and_then(serde_json::Value::as_object_mut)
608                .expect("resource config should be an object")
609                .remove("publicEndpoints");
610        }
611
612        let stack: Stack =
613            serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
614
615        let container = stack
616            .resources
617            .get("api")
618            .and_then(|entry| entry.config.downcast_ref::<Container>())
619            .expect("api should be a container");
620        assert!(container.public_endpoints.is_empty());
621
622        let daemon = stack
623            .resources
624            .get("agent")
625            .and_then(|entry| entry.config.downcast_ref::<Daemon>())
626            .expect("agent should be a daemon");
627        assert!(daemon.public_endpoints.is_empty());
628
629        let worker = stack
630            .resources
631            .get("worker")
632            .and_then(|entry| entry.config.downcast_ref::<Worker>())
633            .expect("worker should be a worker");
634        assert!(worker.public_endpoints.is_empty());
635    }
636
637    #[test]
638    fn test_stack_with_permissions() {
639        use crate::permissions::PermissionProfile;
640        use indexmap::IndexMap;
641
642        // Create a simple stack with permissions
643        let storage = Storage::new("test-storage".to_string()).build();
644
645        // Create a permission profile
646        let mut permission_profile = PermissionProfile::new();
647        permission_profile.0.insert(
648            "*".to_string(),
649            vec![PermissionSetReference::from_name("storage/data-read")],
650        );
651
652        let mut permissions = IndexMap::new();
653        permissions.insert("reader".to_string(), permission_profile);
654
655        let stack = Stack::new("test-permissions-stack".to_string())
656            .add(storage, ResourceLifecycle::Frozen)
657            .permissions(PermissionsConfig {
658                profiles: permissions,
659                management: ManagementPermissions::Auto,
660            })
661            .build();
662
663        // Verify permissions are accessible
664        assert_eq!(stack.permission_profiles().len(), 1);
665        assert!(stack.permission_profiles().contains_key("reader"));
666
667        let reader_profile = stack.permission_profiles().get("reader").unwrap();
668        assert_eq!(reader_profile.0.len(), 1);
669        assert!(reader_profile.0.contains_key("*"));
670
671        let global_permissions = reader_profile.0.get("*").unwrap();
672        assert_eq!(
673            global_permissions,
674            &vec![PermissionSetReference::from_name("storage/data-read")]
675        );
676
677        // Test serialization/deserialization
678        let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
679        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
680        assert_eq!(stack, deserialized);
681    }
682
683    #[test]
684    fn test_stack_with_management_permissions() {
685        use crate::permissions::{ManagementPermissions, PermissionProfile};
686
687        // Create a simple stack with management permissions
688        let storage = Storage::new("test-storage".to_string()).build();
689
690        // Create a permission profile for management
691        let mut management_profile = PermissionProfile::new();
692        management_profile.0.insert(
693            "*".to_string(),
694            vec![PermissionSetReference::from_name("vault/data-write")],
695        );
696
697        // Test auto management permissions (default)
698        let stack_auto = Stack::new("test-auto-management-stack".to_string())
699            .add(storage.clone(), ResourceLifecycle::Frozen)
700            .management(ManagementPermissions::auto())
701            .build();
702
703        assert!(stack_auto.management().is_auto());
704        assert!(stack_auto.management().profile().is_none());
705
706        // Test extend management permissions
707        let stack_extend = Stack::new("test-extend-management-stack".to_string())
708            .add(storage.clone(), ResourceLifecycle::Frozen)
709            .management(ManagementPermissions::extend(management_profile.clone()))
710            .build();
711
712        assert!(stack_extend.management().is_extend());
713        assert_eq!(
714            stack_extend.management().profile().unwrap(),
715            &management_profile
716        );
717
718        // Test override management permissions
719        let stack_override = Stack::new("test-override-management-stack".to_string())
720            .add(storage.clone(), ResourceLifecycle::Frozen)
721            .management(ManagementPermissions::override_(management_profile.clone()))
722            .build();
723
724        assert!(stack_override.management().is_override());
725        assert_eq!(
726            stack_override.management().profile().unwrap(),
727            &management_profile
728        );
729
730        // Test default management permissions
731        let stack_default = Stack::new("test-default-management-stack".to_string())
732            .add(storage, ResourceLifecycle::Frozen)
733            .build();
734
735        assert!(stack_default.management().is_auto());
736
737        // Test serialization/deserialization with management
738        let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
739        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
740        assert_eq!(stack_extend, deserialized);
741    }
742
743    fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
744        crate::Sandbox::new(id.to_string())
745            .code(crate::SandboxCode::Image {
746                image: image.to_string(),
747            })
748            .maybe_private_base_image(private_base_image.map(str::to_string))
749            .egress(crate::SandboxEgress::Deny)
750            .lifecycle(crate::SandboxLifecyclePolicy {
751                max_lifetime_seconds: None,
752                idle_pause_seconds: None,
753            })
754            .build()
755    }
756
757    /// Stored setup authorizations carry this digest, so its bytes must not move for any stack
758    /// without a Live sandbox.
759    #[test]
760    fn setup_owned_digest_is_stable_without_a_live_sandbox() {
761        let stack = Stack::new("golden".to_string())
762            .add(
763                Storage::new("ledger".to_string()).build(),
764                ResourceLifecycle::Frozen,
765            )
766            .add(
767                digest_sandbox(
768                    "frozen-agents",
769                    "s3://bucket/frozen.zip",
770                    Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
771                ),
772                ResourceLifecycle::Frozen,
773            )
774            .add(
775                Storage::new("scratch".to_string()).build(),
776                ResourceLifecycle::Live,
777            )
778            .build();
779
780        assert_eq!(
781            stack.setup_owned_digest(),
782            "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
783        );
784    }
785
786    struct LiveSandbox {
787        image: &'static str,
788        private_base_image: Option<&'static str>,
789        egress: crate::SandboxEgress,
790        network_id: &'static str,
791        remote_access: bool,
792    }
793
794    const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
795
796    impl LiveSandbox {
797        fn installed() -> Self {
798            Self {
799                image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
800                private_base_image: Some(BASE_A),
801                egress: crate::SandboxEgress::Allow,
802                network_id: "net-a",
803                remote_access: false,
804            }
805        }
806
807        fn digest(self) -> String {
808            let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
809            sandbox.egress = self.egress;
810            let mut stack = Stack::new("stack".to_string())
811                .add(
812                    crate::Network::new(self.network_id.to_string())
813                        .settings(crate::NetworkSettings::Create {
814                            cidr: Some("10.0.0.0/16".to_string()),
815                            availability_zones: 2,
816                        })
817                        .build(),
818                    ResourceLifecycle::Frozen,
819                )
820                .add(sandbox, ResourceLifecycle::Live)
821                .build();
822            stack
823                .resources
824                .get_mut("agents")
825                .expect("sandbox")
826                .remote_access = self.remote_access;
827            let entry = &stack.resources["agents"];
828            crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
829                &stack,
830                entry.config.downcast_ref().expect("sandbox"),
831                entry.lifecycle,
832                crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
833            )
834            .expect("the inputs resolve, so no case is compared by its whole configuration");
835            stack.setup_owned_digest()
836        }
837    }
838
839    #[test]
840    fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
841        let installed = LiveSandbox::installed().digest();
842
843        for (unchanged, why) in [
844            (
845                LiveSandbox {
846                    image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
847                    ..LiveSandbox::installed()
848                },
849                "a new bundle under the same prefix",
850            ),
851            (
852                LiveSandbox {
853                    private_base_image: Some(
854                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
855                    ),
856                    ..LiveSandbox::installed()
857                },
858                "a new tag",
859            ),
860            (
861                LiveSandbox {
862                    private_base_image: Some(
863                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
864                    ),
865                    ..LiveSandbox::installed()
866                },
867                "a digest",
868            ),
869        ] {
870            assert_eq!(
871                installed,
872                unchanged.digest(),
873                "{why} is the runtime's to roll"
874            );
875        }
876
877        for (changed, what) in [
878            (
879                LiveSandbox {
880                    private_base_image: None,
881                    ..LiveSandbox::installed()
882                },
883                "a dropped private base",
884            ),
885            (
886                LiveSandbox {
887                    private_base_image: Some(
888                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
889                    ),
890                    ..LiveSandbox::installed()
891                },
892                "another repository",
893            ),
894            (
895                LiveSandbox {
896                    private_base_image: Some(
897                        "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
898                    ),
899                    ..LiveSandbox::installed()
900                },
901                "another account",
902            ),
903            (
904                LiveSandbox {
905                    private_base_image: Some(
906                        "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
907                    ),
908                    ..LiveSandbox::installed()
909                },
910                "the deployment's region",
911            ),
912            (
913                LiveSandbox {
914                    egress: crate::SandboxEgress::Deny,
915                    ..LiveSandbox::installed()
916                },
917                "egress allow to deny",
918            ),
919            (
920                LiveSandbox {
921                    image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
922                    ..LiveSandbox::installed()
923                },
924                "another bundle bucket",
925            ),
926            (
927                LiveSandbox {
928                    image: "s3://bucket/other-prefix/v1/bundle.zip",
929                    ..LiveSandbox::installed()
930                },
931                "another bundle prefix",
932            ),
933            (
934                LiveSandbox {
935                    remote_access: true,
936                    ..LiveSandbox::installed()
937                },
938                "a remote grant",
939            ),
940        ] {
941            assert_ne!(installed, changed.digest(), "{what} is setup-owned");
942        }
943
944        let deny = |network_id| {
945            LiveSandbox {
946                egress: crate::SandboxEgress::Deny,
947                network_id,
948                ..LiveSandbox::installed()
949            }
950            .digest()
951        };
952        assert_ne!(
953            deny("net-a"),
954            deny("net-b"),
955            "the connector's network is setup-owned"
956        );
957    }
958
959    #[test]
960    fn setup_owned_digest_is_order_independent() {
961        let first = Stack::new("first".to_string())
962            .add(
963                Storage::new("alpha".to_string()).build(),
964                ResourceLifecycle::Frozen,
965            )
966            .add(
967                Storage::new("beta".to_string()).build(),
968                ResourceLifecycle::Frozen,
969            )
970            .add(
971                Storage::new("live-one".to_string()).build(),
972                ResourceLifecycle::Live,
973            )
974            .build();
975        let second = Stack::new("second".to_string())
976            .add(
977                Storage::new("beta".to_string()).build(),
978                ResourceLifecycle::Frozen,
979            )
980            .add(
981                Storage::new("alpha".to_string()).build(),
982                ResourceLifecycle::Frozen,
983            )
984            .add(
985                Storage::new("live-two".to_string()).build(),
986                ResourceLifecycle::Live,
987            )
988            .build();
989
990        assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
991    }
992}