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