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                bundled_from: None,
590            })
591            .egress(crate::SandboxEgress::Allow)
592            .lifecycle(crate::SandboxLifecyclePolicy {
593                max_lifetime_seconds: None,
594                idle_pause_seconds: None,
595            })
596            .build();
597        assert!(resource_entry(sandbox, ResourceLifecycle::Live, true).has_remote_bindings());
598        assert!(!resource_entry(
599            Worker::new("worker".to_string())
600                .code(WorkerCode::Image {
601                    image: "example.com/worker:latest".to_string(),
602                })
603                .permissions("worker-execution".to_string())
604                .build(),
605            ResourceLifecycle::Frozen,
606            true,
607        )
608        .has_remote_bindings());
609    }
610
611    #[test]
612    fn test_stack_serialization() {
613        use crate::WorkerCode;
614
615        let storage = Storage::new("my-bucket".to_string())
616            .public_read(true)
617            .build();
618
619        let worker = Worker::new("my-worker".to_string())
620            .code(WorkerCode::Image {
621                image: "rust:latest".to_string(),
622            })
623            .permissions("execution".to_string())
624            .link(&storage)
625            .build();
626
627        // Create permission profiles for the new system
628        let mut permissions = IndexMap::new();
629        let mut execution_profile = PermissionProfile::new();
630        execution_profile.0.insert(
631            "*".to_string(),
632            vec![
633                PermissionSetReference::from_name("storage/data-read"),
634                PermissionSetReference::from_name("storage/data-write"),
635            ],
636        );
637        permissions.insert("execution".to_string(), execution_profile);
638
639        let stack_builder = Stack::new("test-stack".to_string())
640            .add(storage, ResourceLifecycle::Frozen)
641            .add(worker.clone(), ResourceLifecycle::Live);
642
643        let stack = stack_builder
644            .permissions(PermissionsConfig {
645                profiles: permissions,
646                management: ManagementPermissions::Auto,
647            })
648            .build();
649
650        // Serialize and Deserialize
651        let serialized_stack =
652            serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
653        let deserialized_stack: Stack =
654            serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
655
656        // Assert equality
657        assert_eq!(
658            stack, deserialized_stack,
659            "Original and deserialized stacks do not match."
660        );
661
662        // Verify snapshot (sort maps to be deterministic across Rust versions)
663        let mut settings = insta::Settings::clone_current();
664        settings.set_sort_maps(true);
665        settings.bind(|| {
666            assert_json_snapshot!("stack_serialization_account_managed", stack);
667        });
668    }
669
670    #[test]
671    fn test_empty_stack_serialization() {
672        let stack_builder = Stack::new("empty-test-stack".to_string());
673
674        let stack = stack_builder
675            .permissions(PermissionsConfig::new()) // Empty permissions for existing tests
676            .build();
677
678        // Serialize and Deserialize
679        let serialized_stack =
680            serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
681        let deserialized_stack: Stack =
682            serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
683
684        // Assert equality
685        assert_eq!(
686            stack, deserialized_stack,
687            "Original and deserialized empty stacks do not match."
688        );
689
690        // Verify snapshot (sort maps to be deterministic across Rust versions)
691        let mut settings = insta::Settings::clone_current();
692        settings.set_sort_maps(true);
693        settings.bind(|| {
694            assert_json_snapshot!("empty_stack_serialization_account", stack);
695        });
696    }
697
698    #[test]
699    fn stack_deserializes_resources_without_public_endpoints() {
700        let container = Container::new("api".to_string())
701            .code(ContainerCode::Image {
702                image: "example.com/api:latest".to_string(),
703            })
704            .cpu(ResourceSpec {
705                min: "0.5".to_string(),
706                desired: "1".to_string(),
707            })
708            .memory(ResourceSpec {
709                min: "512Mi".to_string(),
710                desired: "1Gi".to_string(),
711            })
712            .port(8080)
713            .permissions("container-execution".to_string())
714            .build();
715        let daemon = Daemon::new("agent".to_string())
716            .code(DaemonCode::Image {
717                image: "example.com/agent:latest".to_string(),
718            })
719            .permissions("daemon-execution".to_string())
720            .build();
721        let worker = Worker::new("worker".to_string())
722            .code(WorkerCode::Image {
723                image: "example.com/worker:latest".to_string(),
724            })
725            .permissions("worker-execution".to_string())
726            .build();
727        let stack = Stack::new("legacy-stack".to_string())
728            .add(container, ResourceLifecycle::Live)
729            .add(daemon, ResourceLifecycle::Live)
730            .add(worker, ResourceLifecycle::Live)
731            .build();
732
733        let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
734        for resource_id in ["api", "agent", "worker"] {
735            legacy_json
736                .pointer_mut(&format!("/resources/{resource_id}/config"))
737                .and_then(serde_json::Value::as_object_mut)
738                .expect("resource config should be an object")
739                .remove("publicEndpoints");
740        }
741
742        let stack: Stack =
743            serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
744
745        let container = stack
746            .resources
747            .get("api")
748            .and_then(|entry| entry.config.downcast_ref::<Container>())
749            .expect("api should be a container");
750        assert!(container.public_endpoints.is_empty());
751
752        let daemon = stack
753            .resources
754            .get("agent")
755            .and_then(|entry| entry.config.downcast_ref::<Daemon>())
756            .expect("agent should be a daemon");
757        assert!(daemon.public_endpoints.is_empty());
758
759        let worker = stack
760            .resources
761            .get("worker")
762            .and_then(|entry| entry.config.downcast_ref::<Worker>())
763            .expect("worker should be a worker");
764        assert!(worker.public_endpoints.is_empty());
765    }
766
767    #[test]
768    fn test_stack_with_permissions() {
769        use crate::permissions::PermissionProfile;
770        use indexmap::IndexMap;
771
772        // Create a simple stack with permissions
773        let storage = Storage::new("test-storage".to_string()).build();
774
775        // Create a permission profile
776        let mut permission_profile = PermissionProfile::new();
777        permission_profile.0.insert(
778            "*".to_string(),
779            vec![PermissionSetReference::from_name("storage/data-read")],
780        );
781
782        let mut permissions = IndexMap::new();
783        permissions.insert("reader".to_string(), permission_profile);
784
785        let stack = Stack::new("test-permissions-stack".to_string())
786            .add(storage, ResourceLifecycle::Frozen)
787            .permissions(PermissionsConfig {
788                profiles: permissions,
789                management: ManagementPermissions::Auto,
790            })
791            .build();
792
793        // Verify permissions are accessible
794        assert_eq!(stack.permission_profiles().len(), 1);
795        assert!(stack.permission_profiles().contains_key("reader"));
796
797        let reader_profile = stack.permission_profiles().get("reader").unwrap();
798        assert_eq!(reader_profile.0.len(), 1);
799        assert!(reader_profile.0.contains_key("*"));
800
801        let global_permissions = reader_profile.0.get("*").unwrap();
802        assert_eq!(
803            global_permissions,
804            &vec![PermissionSetReference::from_name("storage/data-read")]
805        );
806
807        // Test serialization/deserialization
808        let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
809        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
810        assert_eq!(stack, deserialized);
811    }
812
813    #[test]
814    fn test_stack_with_management_permissions() {
815        use crate::permissions::{ManagementPermissions, PermissionProfile};
816
817        // Create a simple stack with management permissions
818        let storage = Storage::new("test-storage".to_string()).build();
819
820        // Create a permission profile for management
821        let mut management_profile = PermissionProfile::new();
822        management_profile.0.insert(
823            "*".to_string(),
824            vec![PermissionSetReference::from_name("vault/data-write")],
825        );
826
827        // Test auto management permissions (default)
828        let stack_auto = Stack::new("test-auto-management-stack".to_string())
829            .add(storage.clone(), ResourceLifecycle::Frozen)
830            .management(ManagementPermissions::auto())
831            .build();
832
833        assert!(stack_auto.management().is_auto());
834        assert!(stack_auto.management().profile().is_none());
835
836        // Test extend management permissions
837        let stack_extend = Stack::new("test-extend-management-stack".to_string())
838            .add(storage.clone(), ResourceLifecycle::Frozen)
839            .management(ManagementPermissions::extend(management_profile.clone()))
840            .build();
841
842        assert!(stack_extend.management().is_extend());
843        assert_eq!(
844            stack_extend.management().profile().unwrap(),
845            &management_profile
846        );
847
848        // Test override management permissions
849        let stack_override = Stack::new("test-override-management-stack".to_string())
850            .add(storage.clone(), ResourceLifecycle::Frozen)
851            .management(ManagementPermissions::override_(management_profile.clone()))
852            .build();
853
854        assert!(stack_override.management().is_override());
855        assert_eq!(
856            stack_override.management().profile().unwrap(),
857            &management_profile
858        );
859
860        // Test default management permissions
861        let stack_default = Stack::new("test-default-management-stack".to_string())
862            .add(storage, ResourceLifecycle::Frozen)
863            .build();
864
865        assert!(stack_default.management().is_auto());
866
867        // Test serialization/deserialization with management
868        let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
869        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
870        assert_eq!(stack_extend, deserialized);
871    }
872
873    #[test]
874    fn presentation_metadata_preserves_setup_fingerprints_and_old_entries() {
875        let original = Stack::new("metadata".to_string())
876            .add(
877                Storage::new("documents".to_string()).build(),
878                ResourceLifecycle::Frozen,
879            )
880            .build();
881        let mut documented = original.clone();
882        let entry = documented.resources.get_mut("documents").unwrap();
883        entry.display_name = Some("Document storage".to_string());
884        entry.description = Some("Uploaded documents and processed results.".to_string());
885        assert_eq!(
886            original.setup_owned_digest(),
887            documented.setup_owned_digest()
888        );
889        assert_eq!(
890            original.setup_update_digest(),
891            documented.setup_update_digest()
892        );
893        assert_eq!(
894            original.resources["documents"].config,
895            documented.resources["documents"].config
896        );
897        let old_json = serde_json::to_value(&original).unwrap();
898        assert!(old_json["resources"]["documents"]
899            .get("displayName")
900            .is_none());
901        let loaded: Stack = serde_json::from_value(old_json).unwrap();
902        assert_eq!(
903            loaded.resources["documents"].display_name_or_id(),
904            "documents"
905        );
906        assert!(loaded.resources["documents"].description.is_none());
907        let round_trip: Stack =
908            serde_json::from_value(serde_json::to_value(&documented).unwrap()).unwrap();
909        assert_eq!(round_trip, documented);
910    }
911
912    fn digest_sandbox(id: &str, image: &str, private_base_image: Option<&str>) -> crate::Sandbox {
913        crate::Sandbox::new(id.to_string())
914            .code(crate::SandboxCode::Image {
915                image: image.to_string(),
916                bundled_from: None,
917            })
918            .maybe_private_base_image(private_base_image.map(str::to_string))
919            .egress(crate::SandboxEgress::None)
920            .lifecycle(crate::SandboxLifecyclePolicy {
921                max_lifetime_seconds: None,
922                idle_pause_seconds: None,
923            })
924            .build()
925    }
926
927    /// Stored setup authorizations carry this digest, so its bytes must not move for any stack
928    /// without a Live sandbox.
929    #[test]
930    fn setup_owned_digest_is_stable_without_a_live_sandbox() {
931        let stack = Stack::new("golden".to_string())
932            .add(
933                Storage::new("ledger".to_string()).build(),
934                ResourceLifecycle::Frozen,
935            )
936            .add(
937                digest_sandbox(
938                    "frozen-agents",
939                    "s3://bucket/frozen.zip",
940                    Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base:1"),
941                ),
942                ResourceLifecycle::Frozen,
943            )
944            .add(
945                Storage::new("scratch".to_string()).build(),
946                ResourceLifecycle::Live,
947            )
948            .build();
949
950        assert_eq!(
951            stack.setup_owned_digest(),
952            "aacf34583a0bb9e3d48dd08be0bdd1da0f1d1e3666f93b844ecb0e1506ca537e"
953        );
954    }
955
956    struct LiveSandbox {
957        image: &'static str,
958        private_base_image: Option<&'static str>,
959        egress: crate::SandboxEgress,
960        network_id: &'static str,
961        remote_access: bool,
962    }
963
964    const BASE_A: &str = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1";
965
966    impl LiveSandbox {
967        fn installed() -> Self {
968            Self {
969                image: "s3://bucket/sandbox-bundle/v1/bundle.zip",
970                private_base_image: Some(BASE_A),
971                egress: crate::SandboxEgress::Allow,
972                network_id: "net-a",
973                remote_access: false,
974            }
975        }
976
977        fn digest(self) -> String {
978            let mut sandbox = digest_sandbox("agents", self.image, self.private_base_image);
979            sandbox.egress = self.egress;
980            let mut stack = Stack::new("stack".to_string())
981                .add(
982                    crate::Network::new(self.network_id.to_string())
983                        .settings(crate::NetworkSettings::Create {
984                            cidr: Some("10.0.0.0/16".to_string()),
985                            availability_zones: 2,
986                        })
987                        .build(),
988                    ResourceLifecycle::Frozen,
989                )
990                .add(sandbox, ResourceLifecycle::Live)
991                .build();
992            stack
993                .resources
994                .get_mut("agents")
995                .expect("sandbox")
996                .remote_access = self.remote_access;
997            let entry = &stack.resources["agents"];
998            crate::sandbox_setup_inputs::aws_sandbox_setup_inputs(
999                &stack,
1000                entry.config.downcast_ref().expect("sandbox"),
1001                entry.lifecycle,
1002                crate::sandbox_setup_inputs::SETUP_INPUTS_COMPARISON_ACCOUNT,
1003            )
1004            .expect("the inputs resolve, so no case is compared by its whole configuration");
1005            stack.setup_owned_digest()
1006        }
1007    }
1008
1009    #[test]
1010    fn setup_owned_digest_follows_a_live_sandboxs_setup_inputs_but_not_its_image() {
1011        let installed = LiveSandbox::installed().digest();
1012
1013        for (unchanged, why) in [
1014            (
1015                LiveSandbox {
1016                    image: "s3://bucket/sandbox-bundle/v2/bundle.zip",
1017                    ..LiveSandbox::installed()
1018                },
1019                "a new bundle under the same prefix",
1020            ),
1021            (
1022                LiveSandbox {
1023                    private_base_image: Some(
1024                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a:2",
1025                    ),
1026                    ..LiveSandbox::installed()
1027                },
1028                "a new tag",
1029            ),
1030            (
1031                LiveSandbox {
1032                    private_base_image: Some(
1033                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-a@sha256:abc",
1034                    ),
1035                    ..LiveSandbox::installed()
1036                },
1037                "a digest",
1038            ),
1039            (
1040                LiveSandbox {
1041                    private_base_image: Some(
1042                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/base-b:1",
1043                    ),
1044                    ..LiveSandbox::installed()
1045                },
1046                "another repository",
1047            ),
1048            (
1049                LiveSandbox {
1050                    private_base_image: Some(
1051                        "210987654321.dkr.ecr.us-east-1.amazonaws.com/team/base-a:1",
1052                    ),
1053                    ..LiveSandbox::installed()
1054                },
1055                "another account",
1056            ),
1057            (
1058                LiveSandbox {
1059                    private_base_image: Some(
1060                        "123456789012.dkr.ecr.{region}.amazonaws.com/team/base-a:1",
1061                    ),
1062                    ..LiveSandbox::installed()
1063                },
1064                "the deployment's region",
1065            ),
1066            (
1067                LiveSandbox {
1068                    image: "s3://other-bucket/sandbox-bundle/v1/bundle.zip",
1069                    ..LiveSandbox::installed()
1070                },
1071                "another bundle bucket",
1072            ),
1073        ] {
1074            assert_eq!(
1075                installed,
1076                unchanged.digest(),
1077                "{why} is the runtime's to roll: a Live sandbox builds from the store and repository its setup granted"
1078            );
1079        }
1080
1081        for (changed, what) in [
1082            (
1083                LiveSandbox {
1084                    private_base_image: None,
1085                    ..LiveSandbox::installed()
1086                },
1087                "a dropped private base",
1088            ),
1089            (
1090                LiveSandbox {
1091                    egress: crate::SandboxEgress::None,
1092                    ..LiveSandbox::installed()
1093                },
1094                "egress allow to deny",
1095            ),
1096            (
1097                LiveSandbox {
1098                    image: "s3://bucket/other-prefix/v1/bundle.zip",
1099                    ..LiveSandbox::installed()
1100                },
1101                "another bundle prefix",
1102            ),
1103            (
1104                LiveSandbox {
1105                    remote_access: true,
1106                    ..LiveSandbox::installed()
1107                },
1108                "a remote grant",
1109            ),
1110            (
1111                LiveSandbox {
1112                    private_base_image: Some(
1113                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/cache/team/base-a:1",
1114                    ),
1115                    ..LiveSandbox::installed()
1116                },
1117                "a base repository in another namespace",
1118            ),
1119            (
1120                LiveSandbox {
1121                    private_base_image: Some(
1122                        "123456789012.dkr.ecr.us-east-1.amazonaws.com/base-a:1",
1123                    ),
1124                    ..LiveSandbox::installed()
1125                },
1126                "a base repository with no namespace",
1127            ),
1128        ] {
1129            assert_ne!(installed, changed.digest(), "{what} is setup-owned");
1130        }
1131
1132        let deny = |network_id| {
1133            LiveSandbox {
1134                egress: crate::SandboxEgress::None,
1135                network_id,
1136                ..LiveSandbox::installed()
1137            }
1138            .digest()
1139        };
1140        assert_ne!(
1141            deny("net-a"),
1142            deny("net-b"),
1143            "the connector's network is setup-owned"
1144        );
1145    }
1146
1147    #[test]
1148    fn setup_owned_digest_is_order_independent() {
1149        let first = Stack::new("first".to_string())
1150            .add(
1151                Storage::new("alpha".to_string()).build(),
1152                ResourceLifecycle::Frozen,
1153            )
1154            .add(
1155                Storage::new("beta".to_string()).build(),
1156                ResourceLifecycle::Frozen,
1157            )
1158            .add(
1159                Storage::new("live-one".to_string()).build(),
1160                ResourceLifecycle::Live,
1161            )
1162            .build();
1163        let second = Stack::new("second".to_string())
1164            .add(
1165                Storage::new("beta".to_string()).build(),
1166                ResourceLifecycle::Frozen,
1167            )
1168            .add(
1169                Storage::new("alpha".to_string()).build(),
1170                ResourceLifecycle::Frozen,
1171            )
1172            .add(
1173                Storage::new("live-two".to_string()).build(),
1174                ResourceLifecycle::Live,
1175            )
1176            .build();
1177
1178        assert_eq!(first.setup_owned_digest(), second.setup_owned_digest());
1179    }
1180}