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