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