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        // A setup-created Memorystore instance has no readable auth token in its Terraform
146        // output, so Alien copies it into Secret Manager after setup and needs `redis/management`
147        // even while the resource is Frozen.
148        "redis" => user_choice_with_management(),
149        "storage" | "queue" | "kv" | "vault" | "postgres" | "ai" => user_choice(),
150        _ => user_choice(),
151    }
152}
153
154const fn frozen_only() -> ResourceOwnershipPolicy {
155    ResourceOwnershipPolicy::new(
156        ResourceLifecycle::Frozen,
157        true,
158        false,
159        SetupEmission::WhenFrozen,
160        false,
161        false,
162    )
163}
164
165const fn frozen_with_management() -> ResourceOwnershipPolicy {
166    ResourceOwnershipPolicy::new(
167        ResourceLifecycle::Frozen,
168        true,
169        false,
170        SetupEmission::WhenFrozen,
171        true,
172        false,
173    )
174}
175
176const fn frozen_with_runtime_cleanup() -> ResourceOwnershipPolicy {
177    ResourceOwnershipPolicy::new(
178        ResourceLifecycle::Frozen,
179        true,
180        false,
181        SetupEmission::WhenFrozen,
182        true,
183        true,
184    )
185}
186
187/// A sandbox may be baked by the setup stack or provisioned by a runtime controller.
188///
189/// Live is what lets the base image come from Alien's private registry: the cross-account read is
190/// granted by a repository policy naming the customer's account, which isn't known until the
191/// deployment registers. Frozen stays the default so an already-installed stack keeps its image.
192const fn sandbox_lifecycle() -> ResourceOwnershipPolicy {
193    ResourceOwnershipPolicy::new(
194        ResourceLifecycle::Frozen,
195        true,
196        true,
197        SetupEmission::Always,
198        true,
199        true,
200    )
201}
202
203const fn live_only() -> ResourceOwnershipPolicy {
204    ResourceOwnershipPolicy::new(
205        ResourceLifecycle::Live,
206        false,
207        true,
208        SetupEmission::Never,
209        false,
210        false,
211    )
212}
213
214const fn removed_resource_type() -> ResourceOwnershipPolicy {
215    ResourceOwnershipPolicy::new(
216        ResourceLifecycle::Live,
217        false,
218        false,
219        SetupEmission::Never,
220        false,
221        false,
222    )
223}
224
225const fn user_choice_with_management() -> ResourceOwnershipPolicy {
226    ResourceOwnershipPolicy::new(
227        ResourceLifecycle::Frozen,
228        true,
229        true,
230        SetupEmission::WhenFrozen,
231        true,
232        false,
233    )
234}
235
236const fn user_choice() -> ResourceOwnershipPolicy {
237    ResourceOwnershipPolicy::new(
238        ResourceLifecycle::Frozen,
239        true,
240        true,
241        SetupEmission::WhenFrozen,
242        false,
243        false,
244    )
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn workload_resources_are_live_only() {
253        for resource_type in ["worker", "daemon", "container"] {
254            let policy = ownership_policy_for_resource_type(resource_type);
255            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Live);
256            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
257            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
258            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
259        }
260    }
261
262    #[test]
263    fn compute_cluster_is_frozen_with_runtime_cleanup() {
264        let policy = ownership_policy_for_resource_type("compute-cluster");
265        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
266        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
267        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
268        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
269        assert!(policy.requires_management_permissions());
270        assert!(policy.has_runtime_cleanup_before_teardown());
271    }
272
273    #[test]
274    fn sandbox_defaults_to_frozen_but_may_be_live() {
275        let policy = ownership_policy_for_resource_type("sandbox");
276        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
277        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
278        assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
279        assert!(policy.requires_management_permissions());
280        assert!(policy.has_runtime_cleanup_before_teardown());
281    }
282
283    /// The sandbox is the only type whose two answers differ, and each half is load-bearing:
284    /// setup must still install the build role its controller may only pass, while the image
285    /// belongs to that controller and so must reach the runtime strip a decline runs through.
286    #[test]
287    fn a_live_sandbox_is_scaffolded_by_setup_but_not_owned_by_it() {
288        let policy = ownership_policy_for_resource_type("sandbox");
289
290        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
291        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Frozen));
292
293        assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
294        assert!(policy.emits_setup_scaffolding(ResourceLifecycle::Live));
295    }
296
297    /// Every type but the sandbox must answer `should_emit_in_setup` and
298    /// `emits_setup_scaffolding` identically; a diverging type has silently changed ownership
299    /// behaviour.
300    #[test]
301    fn only_the_sandbox_separates_ownership_from_scaffolding() {
302        let types = crate::gateability::MANIFEST_TYPES.iter().copied().chain([
303            "function",
304            "container-cluster",
305            "compute-cluster",
306            "artifact-registry",
307            "key",
308            "build",
309            "network",
310            "remote-stack-management",
311            "resource-access",
312            "service-account",
313            "service_activation",
314            "service-activation",
315            "azure_resource_group",
316            "azure-resource-group",
317            "azure_storage_account",
318            "azure-storage-account",
319            "azure_container_apps_environment",
320            "azure-container-apps-environment",
321            "azure_service_bus_namespace",
322            "azure-service-bus-namespace",
323            "an-unregistered-extension-type",
324        ]);
325
326        for resource_type in types {
327            if resource_type == "sandbox" {
328                continue;
329            }
330            let policy = ownership_policy_for_resource_type(resource_type);
331            for lifecycle in [ResourceLifecycle::Frozen, ResourceLifecycle::Live] {
332                assert_eq!(
333                    policy.should_emit_in_setup(lifecycle),
334                    policy.emits_setup_scaffolding(lifecycle),
335                    "'{resource_type}' answers the two questions differently under {lifecycle:?}"
336                );
337            }
338        }
339    }
340
341    #[test]
342    fn artifact_registry_is_frozen_with_management() {
343        let policy = ownership_policy_for_resource_type("artifact-registry");
344        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
345        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
346        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
347        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
348        assert!(policy.requires_management_permissions());
349        assert!(!policy.has_runtime_cleanup_before_teardown());
350    }
351
352    #[test]
353    fn removed_resource_type_tags_are_not_normal_policy_entries() {
354        for resource_type in ["function", "container-cluster"] {
355            let policy = ownership_policy_for_resource_type(resource_type);
356            assert!(!policy.allows_lifecycle(ResourceLifecycle::Frozen));
357            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
358            assert!(!policy.requires_management_permissions());
359            assert!(!policy.has_runtime_cleanup_before_teardown());
360        }
361    }
362
363    #[test]
364    fn data_resources_can_be_frozen_or_live() {
365        for resource_type in ["storage", "queue", "kv", "vault", "postgres", "redis", "ai"] {
366            let policy = ownership_policy_for_resource_type(resource_type);
367            assert_eq!(
368                policy.requires_management_permissions(),
369                resource_type == "redis"
370            );
371            assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
372            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
373            assert!(policy.allows_lifecycle(ResourceLifecycle::Live));
374            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
375            assert!(!policy.should_emit_in_setup(ResourceLifecycle::Live));
376        }
377    }
378
379    #[test]
380    fn experimental_aws_opensearch_is_frozen_only() {
381        let policy = ownership_policy_for_resource_type("experimental/aws-opensearch");
382        assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen);
383        assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
384        assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
385        assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
386        assert!(!policy.requires_management_permissions());
387    }
388
389    #[test]
390    fn setup_resources_are_frozen_only() {
391        for resource_type in [
392            "build",
393            "network",
394            "remote-stack-management",
395            "resource-access",
396            "service-account",
397            "service_activation",
398            "azure_resource_group",
399            "azure_storage_account",
400            "azure_container_apps_environment",
401            "azure_service_bus_namespace",
402            "email",
403        ] {
404            let policy = ownership_policy_for_resource_type(resource_type);
405            assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen));
406            assert!(!policy.allows_lifecycle(ResourceLifecycle::Live));
407            assert!(policy.should_emit_in_setup(ResourceLifecycle::Frozen));
408        }
409    }
410}