Skip to main content

alien_core/
ownership.rs

1use crate::ResourceLifecycle;
2
3/// When a resource type contributes anything to the setup artifact.
4///
5/// Most types answer with the lifecycle alone. A sandbox does not: a Live one still needs the
6/// setup stack to create its build role, since the runtime controller may only *pass* it —
7/// `sandbox/provision` grants `iam:PassRole` and no `iam:CreateRole`. Only the image moves.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SetupEmission {
10    /// Never part of the setup artifact; a runtime controller owns the whole resource.
11    Never,
12    /// Emitted only when the resource is Frozen.
13    WhenFrozen,
14    /// Scaffolding under either lifecycle. Setup still only *owns* the Frozen one — the Live
15    /// resource itself belongs to a runtime controller, which is why the two accessors below
16    /// disagree for exactly these types.
17    Always,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ResourceOwnershipPolicy {
22    default_lifecycle: ResourceLifecycle,
23    allow_frozen: bool,
24    allow_live: bool,
25    emit_in_setup: SetupEmission,
26    requires_management_permissions: bool,
27    runtime_cleanup_before_teardown: bool,
28}
29
30impl ResourceOwnershipPolicy {
31    pub const fn new(
32        default_lifecycle: ResourceLifecycle,
33        allow_frozen: bool,
34        allow_live: bool,
35        emit_in_setup: SetupEmission,
36        requires_management_permissions: bool,
37        runtime_cleanup_before_teardown: bool,
38    ) -> Self {
39        Self {
40            default_lifecycle,
41            allow_frozen,
42            allow_live,
43            emit_in_setup,
44            requires_management_permissions,
45            runtime_cleanup_before_teardown,
46        }
47    }
48
49    pub const fn default_lifecycle(self) -> ResourceLifecycle {
50        self.default_lifecycle
51    }
52
53    pub const fn allows_frozen(self) -> bool {
54        self.allow_frozen
55    }
56
57    pub const fn allows_live(self) -> bool {
58        self.allow_live
59    }
60
61    pub const fn allows_lifecycle(self, lifecycle: ResourceLifecycle) -> bool {
62        match lifecycle {
63            ResourceLifecycle::Frozen => self.allow_frozen,
64            ResourceLifecycle::Live => self.allow_live,
65        }
66    }
67
68    /// Whether setup *owns* this resource: it creates it, and no runtime controller will.
69    ///
70    /// Gating, lifecycle checks and permission compilation ask this. A Live sandbox answers
71    /// `false` here and `true` from [`Self::emits_setup_scaffolding`]; answering the two with
72    /// one predicate classifies it as setup-created, which keeps its gate out of the runtime
73    /// strip and builds the image for a deployer who declined it.
74    pub const fn should_emit_in_setup(self, lifecycle: ResourceLifecycle) -> bool {
75        !matches!(self.emit_in_setup, SetupEmission::Never)
76            && matches!(lifecycle, ResourceLifecycle::Frozen)
77    }
78
79    /// Whether the setup artifact renders anything for this resource, its own scaffolding
80    /// included — a Live sandbox's build role, which its controller may pass but not create.
81    ///
82    /// The generators and registration's expected set ask this, and must agree: registration
83    /// refuses a payload naming a resource setup does not emit, and one missing a resource it
84    /// does, so a disagreement fails every install after the stack has already completed.
85    pub const fn emits_setup_scaffolding(self, lifecycle: ResourceLifecycle) -> bool {
86        match self.emit_in_setup {
87            SetupEmission::Never => false,
88            SetupEmission::WhenFrozen => matches!(lifecycle, ResourceLifecycle::Frozen),
89            SetupEmission::Always => true,
90        }
91    }
92
93    pub const fn requires_management_permissions(self) -> bool {
94        self.requires_management_permissions
95    }
96
97    pub const fn has_runtime_cleanup_before_teardown(self) -> bool {
98        self.runtime_cleanup_before_teardown
99    }
100
101    pub fn allowed_lifecycles(self) -> &'static str {
102        match (self.allow_frozen, self.allow_live) {
103            (true, true) => "Frozen or Live",
104            (true, false) => "Frozen",
105            (false, true) => "Live",
106            (false, false) => "no lifecycle",
107        }
108    }
109}
110
111pub fn ownership_policy_for_resource_type(resource_type: &str) -> ResourceOwnershipPolicy {
112    match resource_type {
113        "function" | "container-cluster" => removed_resource_type(),
114        "worker" | "daemon" | "container" => live_only(),
115        "compute-cluster" => frozen_with_runtime_cleanup(),
116        "sandbox" => sandbox_lifecycle(),
117        "artifact-registry" | "key" => frozen_with_management(),
118        "build"
119        | "network"
120        | "remote-stack-management"
121        | "resource-access"
122        | "service-account"
123        | "service_activation"
124        | "service-activation"
125        | "azure_resource_group"
126        | "azure-resource-group"
127        | "azure_storage_account"
128        | "azure-storage-account"
129        | "azure_container_apps_environment"
130        | "azure-container-apps-environment"
131        | "azure_service_bus_namespace"
132        | "azure-service-bus-namespace"
133        // Email holds durable routing state (domain identities, DKIM
134        // verification, receipt rules) that setup owns end to end.
135        | "email" => frozen_only(),
136        // Durable search state, setup-owned only: there is no runtime
137        // controller that could provision or replace the collection.
138        "experimental/aws-opensearch" => frozen_only(),
139        "storage" | "queue" | "kv" | "vault" | "postgres" | "ai" => user_choice(),
140        _ => user_choice(),
141    }
142}
143
144const fn frozen_only() -> ResourceOwnershipPolicy {
145    ResourceOwnershipPolicy::new(
146        ResourceLifecycle::Frozen,
147        true,
148        false,
149        SetupEmission::WhenFrozen,
150        false,
151        false,
152    )
153}
154
155const fn frozen_with_management() -> ResourceOwnershipPolicy {
156    ResourceOwnershipPolicy::new(
157        ResourceLifecycle::Frozen,
158        true,
159        false,
160        SetupEmission::WhenFrozen,
161        true,
162        false,
163    )
164}
165
166const fn frozen_with_runtime_cleanup() -> ResourceOwnershipPolicy {
167    ResourceOwnershipPolicy::new(
168        ResourceLifecycle::Frozen,
169        true,
170        false,
171        SetupEmission::WhenFrozen,
172        true,
173        true,
174    )
175}
176
177/// A sandbox may be baked by the setup stack or provisioned by a runtime controller.
178///
179/// Live is what lets the base image come from Alien's private registry: the cross-account read is
180/// granted by a repository policy naming the customer's account, which isn't known until the
181/// deployment registers. Frozen stays the default so an already-installed stack keeps its image.
182const fn sandbox_lifecycle() -> ResourceOwnershipPolicy {
183    ResourceOwnershipPolicy::new(
184        ResourceLifecycle::Frozen,
185        true,
186        true,
187        SetupEmission::Always,
188        true,
189        true,
190    )
191}
192
193const fn live_only() -> ResourceOwnershipPolicy {
194    ResourceOwnershipPolicy::new(
195        ResourceLifecycle::Live,
196        false,
197        true,
198        SetupEmission::Never,
199        false,
200        false,
201    )
202}
203
204const fn removed_resource_type() -> ResourceOwnershipPolicy {
205    ResourceOwnershipPolicy::new(
206        ResourceLifecycle::Live,
207        false,
208        false,
209        SetupEmission::Never,
210        false,
211        false,
212    )
213}
214
215const fn user_choice() -> ResourceOwnershipPolicy {
216    ResourceOwnershipPolicy::new(
217        ResourceLifecycle::Frozen,
218        true,
219        true,
220        SetupEmission::WhenFrozen,
221        false,
222        false,
223    )
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn workload_resources_are_live_only() {
232        for resource_type in ["worker", "daemon", "container"] {
233            let policy = ownership_policy_for_resource_type(resource_type);
234            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Live);
235            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
236            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
237            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
238        }
239    }
240
241    #[test]
242    fn compute_cluster_is_frozen_with_runtime_cleanup() {
243        let policy = ownership_policy_for_resource_type("compute-cluster");
244        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
245        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
246        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
247        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
248        assert!(policy.requires_management_permissions());
249        assert!(policy.has_runtime_cleanup_before_teardown());
250    }
251
252    #[test]
253    fn sandbox_defaults_to_frozen_but_may_be_live() {
254        let policy = ownership_policy_for_resource_type("sandbox");
255        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
256        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
257        assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
258        assert!(policy.requires_management_permissions());
259        assert!(policy.has_runtime_cleanup_before_teardown());
260    }
261
262    /// The sandbox is the only type whose two answers differ, and each half is load-bearing:
263    /// setup must still install the build role its controller may only pass, while the image
264    /// belongs to that controller and so must reach the runtime strip a decline runs through.
265    #[test]
266    fn a_live_sandbox_is_scaffolded_by_setup_but_not_owned_by_it() {
267        let policy = ownership_policy_for_resource_type("sandbox");
268
269        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
270        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Frozen));
271
272        assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
273        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Live));
274    }
275
276    /// Every type but the sandbox must answer `should_emit_in_setup` and
277    /// `emits_setup_scaffolding` identically; a diverging type has silently changed ownership
278    /// behaviour.
279    #[test]
280    fn only_the_sandbox_separates_ownership_from_scaffolding() {
281        let types = crate::gateability::MANIFEST_TYPES.iter().copied().chain([
282            "function",
283            "container-cluster",
284            "compute-cluster",
285            "artifact-registry",
286            "key",
287            "build",
288            "network",
289            "remote-stack-management",
290            "resource-access",
291            "service-account",
292            "service_activation",
293            "service-activation",
294            "azure_resource_group",
295            "azure-resource-group",
296            "azure_storage_account",
297            "azure-storage-account",
298            "azure_container_apps_environment",
299            "azure-container-apps-environment",
300            "azure_service_bus_namespace",
301            "azure-service-bus-namespace",
302            "an-unregistered-extension-type",
303        ]);
304
305        for resource_type in types {
306            if resource_type == "sandbox" {
307                continue;
308            }
309            let policy = ownership_policy_for_resource_type(resource_type);
310            for lifecycle in [ResourceLifecycle::Frozen, ResourceLifecycle::Live] {
311                assert_eq!(
312                    policy.should_emit_in_setup(lifecycle),
313                    policy.emits_setup_scaffolding(lifecycle),
314                    "'{resource_type}' answers the two questions differently under {lifecycle:?}"
315                );
316            }
317        }
318    }
319
320    #[test]
321    fn artifact_registry_is_frozen_with_management() {
322        let policy = ownership_policy_for_resource_type("artifact-registry");
323        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
324        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
325        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
326        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
327        assert!(policy.requires_management_permissions());
328        assert!(!policy.has_runtime_cleanup_before_teardown());
329    }
330
331    #[test]
332    fn removed_resource_type_tags_are_not_normal_policy_entries() {
333        for resource_type in ["function", "container-cluster"] {
334            let policy = ownership_policy_for_resource_type(resource_type);
335            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
336            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
337            assert!(!policy.requires_management_permissions());
338            assert!(!policy.has_runtime_cleanup_before_teardown());
339        }
340    }
341
342    #[test]
343    fn data_resources_can_be_frozen_or_live() {
344        for resource_type in ["storage", "queue", "kv", "vault", "postgres", "ai"] {
345            let policy = ownership_policy_for_resource_type(resource_type);
346            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
347            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
348            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
349            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
350            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
351        }
352    }
353
354    #[test]
355    fn experimental_aws_opensearch_is_frozen_only() {
356        let policy = ownership_policy_for_resource_type("experimental/aws-opensearch");
357        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
358        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
359        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
360        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
361        assert!(!policy.requires_management_permissions());
362    }
363
364    #[test]
365    fn setup_resources_are_frozen_only() {
366        for resource_type in [
367            "build",
368            "network",
369            "remote-stack-management",
370            "resource-access",
371            "service-account",
372            "service_activation",
373            "azure_resource_group",
374            "azure_storage_account",
375            "azure_container_apps_environment",
376            "azure_service_bus_namespace",
377            "email",
378        ] {
379            let policy = ownership_policy_for_resource_type(resource_type);
380            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
381            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
382            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
383        }
384    }
385}