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