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