Skip to main content

alien_core/
stack.rs

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