Skip to main content

alien_core/
gateability.rs

1//! The single authority on which resources may carry an `.enabled()` gate.
2//!
3//! Both the compile-time check (`ResourceEnabledValidCheck`) and the setup
4//! generators consult this module, so a caller that renders without running
5//! preflights hits the same refusals. The rules live here rather than in the
6//! preflight crate because the generators must not depend on preflights, and
7//! duplicating the rules would let them drift.
8//!
9//! Extension resource types registered outside this crate are gateable by
10//! default (their ownership policy is `user_choice`), matching how the emitter
11//! registries treat them: the generic gating post-pass needs nothing from the
12//! emitter, so there is nothing for an extension to opt into.
13
14use crate::ownership_policy_for_resource_type;
15
16/// Reserved id of the deployment secrets vault.
17///
18/// `SecretsVaultMutation` links this vault to Live Workers and compute
19/// clusters after compile-time checks run, so its presence can never be
20/// optional. Owned here so the gating rules and the mutation agree on one
21/// constant.
22pub const SECRETS_VAULT_ID: &str = "secrets";
23
24/// Reserved vault for secrets written by the deployer with their own credentials.
25pub const DEPLOYER_SECRETS_VAULT_ID: &str = "deployer-secrets";
26
27/// Framework and auxiliary infrastructure Alien derives from the stack, the
28/// platform, or the deployment settings. A gate here is never a customer
29/// choice: `ServiceAccountMutation` inserts profile-derived "{profile}-sa"
30/// entries unconditionally, the Azure `default-*` resources are
31/// preflight-injected hosts other resources build on, and network presence is
32/// a StackSettings decision, not a stack-resource one. Both naming variants
33/// are listed where the ownership table accepts both.
34const STACK_DERIVED_TYPES: &[&str] = &[
35    "build",
36    "artifact-registry",
37    "service-account",
38    "compute-cluster",
39    "kubernetes-cluster",
40    "network",
41    "remote-stack-management",
42    "resource-access",
43    "service_activation",
44    "service-activation",
45    "azure_resource_group",
46    "azure-resource-group",
47    "azure_storage_account",
48    "azure-storage-account",
49    "azure_container_apps_environment",
50    "azure-container-apps-environment",
51    "azure_service_bus_namespace",
52    "azure-service-bus-namespace",
53];
54
55/// Types whose setup emitters have not been proven under the generic gating
56/// post-pass yet. Emptied as each type's gated render is validated; the list
57/// exists so switching the mechanism cannot silently open gating for a type
58/// nobody has rendered gated before.
59const NOT_YET_GENERIC_TYPES: &[&str] = &[];
60
61/// Why a resource cannot carry an `.enabled()` gate.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum GateRefusal {
64    /// The reserved deployment secrets vault (`SECRETS_VAULT_ID`).
65    ReservedSecretsVault,
66    /// Framework infrastructure derived from the stack itself.
67    DerivedFromStack,
68    /// The type's gated setup render has not been validated yet.
69    NotYetGeneric,
70}
71
72impl GateRefusal {
73    /// The reason clause, phrased to follow "Resource 'x' is enabled by input
74    /// 'y', but ...". Callers compose the full message so the preflight and
75    /// the generators report identically.
76    pub fn reason(self) -> &'static str {
77        match self {
78            GateRefusal::ReservedSecretsVault => {
79                "it is the deployment secrets vault. Workers and compute clusters are wired to \
80                 it automatically after compile-time checks run, so a deployer who says no would \
81                 leave them resolving a binding for a vault that was never created. Its presence \
82                 cannot be optional. Give a vault you want to gate a different id"
83            }
84            GateRefusal::DerivedFromStack => {
85                "Alien derives this resource from the stack itself, so it cannot be optional"
86            }
87            GateRefusal::NotYetGeneric => {
88                "this resource type's conditional setup render has not been validated yet, so \
89                 the resource would be created regardless of the deployer's answer"
90            }
91        }
92    }
93}
94
95/// Whether a resource may carry an `.enabled()` gate at all. `None` means
96/// gateable. Lifecycle legality is not decided here — a lifecycle the type
97/// does not allow is refused by the lifecycle rules regardless of gating.
98pub fn gate_refusal(resource_type: &str, resource_id: &str) -> Option<GateRefusal> {
99    if matches!(resource_id, SECRETS_VAULT_ID | DEPLOYER_SECRETS_VAULT_ID) {
100        return Some(GateRefusal::ReservedSecretsVault);
101    }
102    if STACK_DERIVED_TYPES.contains(&resource_type) {
103        return Some(GateRefusal::DerivedFromStack);
104    }
105    // Compute (worker, daemon, container) is deliberately gateable: declining
106    // a live workload rides the same removal path as deleting it from a
107    // release, and its provisioning baseline persists so acceptance can
108    // return. Pausing the sole consumer of an ungated queue is allowed by
109    // design — the queue's retention policy governs the backlog.
110    if NOT_YET_GENERIC_TYPES.contains(&resource_type) {
111        return Some(GateRefusal::NotYetGeneric);
112    }
113    None
114}
115
116/// Per-lifecycle gateability of one resource type, derived from the ownership
117/// table and the gate refusals. Serialized into the generated manifest the
118/// TypeScript SDK's surface test consumes.
119#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "camelCase")]
121pub struct TypeGateability {
122    /// The gate may appear on a Frozen entry of this type.
123    pub frozen: bool,
124    /// The gate may appear on a Live entry of this type.
125    pub live: bool,
126}
127
128/// Gateability of one built-in user resource type, keyed for the manifest.
129pub fn type_gateability(resource_type: &str) -> TypeGateability {
130    let policy = ownership_policy_for_resource_type(resource_type);
131    // The id-based rule cannot be evaluated per type; the manifest describes
132    // types, and the reserved vault id is refused per entry.
133    let gateable = gate_refusal(resource_type, "").is_none();
134    TypeGateability {
135        frozen: gateable && policy.allows_frozen(),
136        live: gateable && policy.allows_live(),
137    }
138}
139
140/// The built-in user resource types listed in the generated manifest. The SDK
141/// builder surface is asserted against exactly this set.
142pub const MANIFEST_TYPES: &[&str] = &[
143    "kv",
144    "storage",
145    "queue",
146    "vault",
147    "postgres",
148    "redis",
149    "ai",
150    "worker",
151    "daemon",
152    "container",
153    "email",
154    "sandbox",
155    "experimental/aws-opensearch",
156];
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn stores_are_gateable_in_both_lifecycles() {
164        for store in ["kv", "storage", "queue", "vault", "ai"] {
165            assert_eq!(gate_refusal(store, "analytics"), None, "{store}");
166            let gateability = type_gateability(store);
167            assert!(gateability.frozen && gateability.live, "{store}");
168        }
169    }
170
171    #[test]
172    fn postgres_is_live_gateable_only() {
173        assert_eq!(gate_refusal("postgres", "db"), None);
174        let gateability = type_gateability("postgres");
175        // Frozen postgres has no setup emitter today; the gate rule allows it
176        // and the missing emitter refuses the render, exactly as for an
177        // ungated frozen postgres.
178        assert!(gateability.live);
179    }
180
181    #[test]
182    fn redis_gates_like_postgres() {
183        assert_eq!(gate_refusal("redis", "cache"), None);
184        assert!(type_gateability("redis").live);
185    }
186
187    #[test]
188    fn compute_is_live_gateable() {
189        for compute in ["worker", "daemon", "container"] {
190            assert_eq!(gate_refusal(compute, "api"), None, "{compute}");
191            let gateability = type_gateability(compute);
192            assert!(!gateability.frozen, "{compute} cannot be frozen");
193            assert!(gateability.live, "{compute} gates as a live resource");
194        }
195    }
196
197    #[test]
198    fn stack_derived_types_are_refused() {
199        for framework in STACK_DERIVED_TYPES {
200            assert_eq!(
201                gate_refusal(framework, "x"),
202                Some(GateRefusal::DerivedFromStack),
203                "{framework}"
204            );
205        }
206    }
207
208    #[test]
209    fn the_reserved_secrets_vault_is_refused_by_id() {
210        assert_eq!(
211            gate_refusal("vault", SECRETS_VAULT_ID),
212            Some(GateRefusal::ReservedSecretsVault)
213        );
214        assert_eq!(gate_refusal("vault", "app-tokens"), None);
215    }
216
217    #[test]
218    fn email_and_opensearch_gate_as_frozen_resources() {
219        for setup_owned in ["email", "experimental/aws-opensearch"] {
220            assert_eq!(gate_refusal(setup_owned, "x"), None, "{setup_owned}");
221            let gateability = type_gateability(setup_owned);
222            assert!(gateability.frozen, "{setup_owned} gates at setup");
223            assert!(!gateability.live, "{setup_owned} has no runtime controller");
224        }
225    }
226
227    /// A sandbox declaration is the image and its pool, never a sandbox — sandboxes are created at
228    /// runtime and cleaned up with the parent, so neither gate sits on one. Both lifecycles gate
229    /// because both are real: Frozen bakes the image at stack creation, Live builds it at runtime.
230    #[test]
231    fn sandbox_gates_under_either_lifecycle() {
232        assert_eq!(gate_refusal("sandbox", "agents"), None);
233        let gateability = type_gateability("sandbox");
234        assert!(gateability.frozen, "a setup-baked image gates at setup");
235        assert!(
236            gateability.live,
237            "a runtime-built image gates in the runtime strip"
238        );
239    }
240
241    /// Every manifest type must state its gateability, because the SDK builder surface is
242    /// generated from it — a type nobody asserted gets whatever the ownership table happens to
243    /// say, which is how a new resource acquires an unintended gate.
244    #[test]
245    fn every_manifest_type_is_asserted_somewhere_above() {
246        let asserted = [
247            "kv",
248            "storage",
249            "queue",
250            "vault",
251            "ai",
252            "postgres",
253            "redis",
254            "worker",
255            "daemon",
256            "container",
257            "email",
258            "experimental/aws-opensearch",
259            "sandbox",
260        ];
261        for declared in MANIFEST_TYPES {
262            assert!(
263                asserted.contains(declared),
264                "{declared} is in the manifest but no test pins its gateability"
265            );
266        }
267    }
268
269    #[test]
270    fn extension_types_default_to_gateable() {
271        assert_eq!(gate_refusal("acme-widgets", "widgets"), None);
272        let gateability = type_gateability("acme-widgets");
273        assert!(gateability.frozen && gateability.live);
274    }
275}