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    /// Whether setup renders scaffolding for this type under either lifecycle, whether or not it
94    /// also owns the resource.
95    pub const fn always_emits_setup_scaffolding(self) -> bool {
96        matches!(self.emit_in_setup, SetupEmission::Always)
97    }
98
99    pub const fn requires_management_permissions(self) -> bool {
100        self.requires_management_permissions
101    }
102
103    pub const fn has_runtime_cleanup_before_teardown(self) -> bool {
104        self.runtime_cleanup_before_teardown
105    }
106
107    pub fn allowed_lifecycles(self) -> &'static str {
108        match (self.allow_frozen, self.allow_live) {
109            (true, true) => "Frozen or Live",
110            (true, false) => "Frozen",
111            (false, true) => "Live",
112            (false, false) => "no lifecycle",
113        }
114    }
115}
116
117pub fn ownership_policy_for_resource_type(resource_type: &str) -> ResourceOwnershipPolicy {
118    match resource_type {
119        "function" | "container-cluster" => removed_resource_type(),
120        "worker" | "daemon" | "container" => live_only(),
121        "compute-cluster" => frozen_with_runtime_cleanup(),
122        "sandbox" => sandbox_lifecycle(),
123        "artifact-registry" | "key" => frozen_with_management(),
124        "build"
125        | "network"
126        | "remote-stack-management"
127        | "resource-access"
128        | "service-account"
129        | "service_activation"
130        | "service-activation"
131        | "azure_resource_group"
132        | "azure-resource-group"
133        | "azure_storage_account"
134        | "azure-storage-account"
135        | "azure_container_apps_environment"
136        | "azure-container-apps-environment"
137        | "azure_service_bus_namespace"
138        | "azure-service-bus-namespace"
139        // Email holds durable routing state (domain identities, DKIM
140        // verification, receipt rules) that setup owns end to end.
141        | "email" => frozen_only(),
142        // Durable search state, setup-owned only: there is no runtime
143        // controller that could provision or replace the collection.
144        "experimental/aws-opensearch" => frozen_only(),
145        "storage" | "queue" | "kv" | "vault" | "postgres" | "ai" => user_choice(),
146        _ => user_choice(),
147    }
148}
149
150const fn frozen_only() -> ResourceOwnershipPolicy {
151    ResourceOwnershipPolicy::new(
152        ResourceLifecycle::Frozen,
153        true,
154        false,
155        SetupEmission::WhenFrozen,
156        false,
157        false,
158    )
159}
160
161const fn frozen_with_management() -> ResourceOwnershipPolicy {
162    ResourceOwnershipPolicy::new(
163        ResourceLifecycle::Frozen,
164        true,
165        false,
166        SetupEmission::WhenFrozen,
167        true,
168        false,
169    )
170}
171
172const fn frozen_with_runtime_cleanup() -> ResourceOwnershipPolicy {
173    ResourceOwnershipPolicy::new(
174        ResourceLifecycle::Frozen,
175        true,
176        false,
177        SetupEmission::WhenFrozen,
178        true,
179        true,
180    )
181}
182
183/// A sandbox may be baked by the setup stack or provisioned by a runtime controller.
184///
185/// Live is what lets the base image come from Alien's private registry: the cross-account read is
186/// granted by a repository policy naming the customer's account, which isn't known until the
187/// deployment registers. Frozen stays the default so an already-installed stack keeps its image.
188const fn sandbox_lifecycle() -> ResourceOwnershipPolicy {
189    ResourceOwnershipPolicy::new(
190        ResourceLifecycle::Frozen,
191        true,
192        true,
193        SetupEmission::Always,
194        true,
195        true,
196    )
197}
198
199const fn live_only() -> ResourceOwnershipPolicy {
200    ResourceOwnershipPolicy::new(
201        ResourceLifecycle::Live,
202        false,
203        true,
204        SetupEmission::Never,
205        false,
206        false,
207    )
208}
209
210const fn removed_resource_type() -> ResourceOwnershipPolicy {
211    ResourceOwnershipPolicy::new(
212        ResourceLifecycle::Live,
213        false,
214        false,
215        SetupEmission::Never,
216        false,
217        false,
218    )
219}
220
221const fn user_choice() -> ResourceOwnershipPolicy {
222    ResourceOwnershipPolicy::new(
223        ResourceLifecycle::Frozen,
224        true,
225        true,
226        SetupEmission::WhenFrozen,
227        false,
228        false,
229    )
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn workload_resources_are_live_only() {
238        for resource_type in ["worker", "daemon", "container"] {
239            let policy = ownership_policy_for_resource_type(resource_type);
240            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Live);
241            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
242            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
243            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
244        }
245    }
246
247    #[test]
248    fn compute_cluster_is_frozen_with_runtime_cleanup() {
249        let policy = ownership_policy_for_resource_type("compute-cluster");
250        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
251        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
252        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
253        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
254        assert!(policy.requires_management_permissions());
255        assert!(policy.has_runtime_cleanup_before_teardown());
256    }
257
258    #[test]
259    fn sandbox_defaults_to_frozen_but_may_be_live() {
260        let policy = ownership_policy_for_resource_type("sandbox");
261        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
262        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
263        assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
264        assert!(policy.requires_management_permissions());
265        assert!(policy.has_runtime_cleanup_before_teardown());
266    }
267
268    /// The sandbox is the only type whose two answers differ, and each half is load-bearing:
269    /// setup must still install the build role its controller may only pass, while the image
270    /// belongs to that controller and so must reach the runtime strip a decline runs through.
271    #[test]
272    fn a_live_sandbox_is_scaffolded_by_setup_but_not_owned_by_it() {
273        let policy = ownership_policy_for_resource_type("sandbox");
274
275        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
276        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Frozen));
277
278        assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
279        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Live));
280    }
281
282    /// Every type but the sandbox must answer `should_emit_in_setup` and
283    /// `emits_setup_scaffolding` identically; a diverging type has silently changed ownership
284    /// behaviour.
285    #[test]
286    fn only_the_sandbox_separates_ownership_from_scaffolding() {
287        let types = crate::gateability::MANIFEST_TYPES.iter().copied().chain([
288            "function",
289            "container-cluster",
290            "compute-cluster",
291            "artifact-registry",
292            "key",
293            "build",
294            "network",
295            "remote-stack-management",
296            "resource-access",
297            "service-account",
298            "service_activation",
299            "service-activation",
300            "azure_resource_group",
301            "azure-resource-group",
302            "azure_storage_account",
303            "azure-storage-account",
304            "azure_container_apps_environment",
305            "azure-container-apps-environment",
306            "azure_service_bus_namespace",
307            "azure-service-bus-namespace",
308            "an-unregistered-extension-type",
309        ]);
310
311        for resource_type in types {
312            if resource_type == "sandbox" {
313                continue;
314            }
315            let policy = ownership_policy_for_resource_type(resource_type);
316            for lifecycle in [ResourceLifecycle::Frozen, ResourceLifecycle::Live] {
317                assert_eq!(
318                    policy.should_emit_in_setup(lifecycle),
319                    policy.emits_setup_scaffolding(lifecycle),
320                    "'{resource_type}' answers the two questions differently under {lifecycle:?}"
321                );
322            }
323        }
324    }
325
326    #[test]
327    fn artifact_registry_is_frozen_with_management() {
328        let policy = ownership_policy_for_resource_type("artifact-registry");
329        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
330        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
331        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
332        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
333        assert!(policy.requires_management_permissions());
334        assert!(!policy.has_runtime_cleanup_before_teardown());
335    }
336
337    #[test]
338    fn removed_resource_type_tags_are_not_normal_policy_entries() {
339        for resource_type in ["function", "container-cluster"] {
340            let policy = ownership_policy_for_resource_type(resource_type);
341            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
342            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
343            assert!(!policy.requires_management_permissions());
344            assert!(!policy.has_runtime_cleanup_before_teardown());
345        }
346    }
347
348    #[test]
349    fn data_resources_can_be_frozen_or_live() {
350        for resource_type in ["storage", "queue", "kv", "vault", "postgres", "ai"] {
351            let policy = ownership_policy_for_resource_type(resource_type);
352            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
353            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
354            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
355            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
356            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
357        }
358    }
359
360    #[test]
361    fn experimental_aws_opensearch_is_frozen_only() {
362        let policy = ownership_policy_for_resource_type("experimental/aws-opensearch");
363        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
364        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
365        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
366        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
367        assert!(!policy.requires_management_permissions());
368    }
369
370    #[test]
371    fn setup_resources_are_frozen_only() {
372        for resource_type in [
373            "build",
374            "network",
375            "remote-stack-management",
376            "resource-access",
377            "service-account",
378            "service_activation",
379            "azure_resource_group",
380            "azure_storage_account",
381            "azure_container_apps_environment",
382            "azure_service_bus_namespace",
383            "email",
384        ] {
385            let policy = ownership_policy_for_resource_type(resource_type);
386            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
387            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
388            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
389        }
390    }
391}