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