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