1use crate::ownership_policy_for_resource_type;
15
16pub const SECRETS_VAULT_ID: &str = "secrets";
23
24pub const DEPLOYER_SECRETS_VAULT_ID: &str = "deployer-secrets";
26
27const 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
55const NOT_YET_GENERIC_TYPES: &[&str] = &[];
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum GateRefusal {
64 ReservedSecretsVault,
66 DerivedFromStack,
68 NotYetGeneric,
70}
71
72impl GateRefusal {
73 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
95pub 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 if NOT_YET_GENERIC_TYPES.contains(&resource_type) {
111 return Some(GateRefusal::NotYetGeneric);
112 }
113 None
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "camelCase")]
121pub struct TypeGateability {
122 pub frozen: bool,
124 pub live: bool,
126}
127
128pub fn type_gateability(resource_type: &str) -> TypeGateability {
130 let policy = ownership_policy_for_resource_type(resource_type);
131 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
140pub const MANIFEST_TYPES: &[&str] = &[
143 "kv",
144 "storage",
145 "queue",
146 "vault",
147 "postgres",
148 "ai",
149 "worker",
150 "daemon",
151 "container",
152 "email",
153 "sandbox",
154 "experimental/aws-opensearch",
155];
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn stores_are_gateable_in_both_lifecycles() {
163 for store in ["kv", "storage", "queue", "vault", "ai"] {
164 assert_eq!(gate_refusal(store, "analytics"), None, "{store}");
165 let gateability = type_gateability(store);
166 assert!(gateability.frozen && gateability.live, "{store}");
167 }
168 }
169
170 #[test]
171 fn postgres_is_live_gateable_only() {
172 assert_eq!(gate_refusal("postgres", "db"), None);
173 let gateability = type_gateability("postgres");
174 assert!(gateability.live);
178 }
179
180 #[test]
181 fn compute_is_live_gateable() {
182 for compute in ["worker", "daemon", "container"] {
183 assert_eq!(gate_refusal(compute, "api"), None, "{compute}");
184 let gateability = type_gateability(compute);
185 assert!(!gateability.frozen, "{compute} cannot be frozen");
186 assert!(gateability.live, "{compute} gates as a live resource");
187 }
188 }
189
190 #[test]
191 fn stack_derived_types_are_refused() {
192 for framework in STACK_DERIVED_TYPES {
193 assert_eq!(
194 gate_refusal(framework, "x"),
195 Some(GateRefusal::DerivedFromStack),
196 "{framework}"
197 );
198 }
199 }
200
201 #[test]
202 fn the_reserved_secrets_vault_is_refused_by_id() {
203 assert_eq!(
204 gate_refusal("vault", SECRETS_VAULT_ID),
205 Some(GateRefusal::ReservedSecretsVault)
206 );
207 assert_eq!(gate_refusal("vault", "app-tokens"), None);
208 }
209
210 #[test]
211 fn email_and_opensearch_gate_as_frozen_resources() {
212 for setup_owned in ["email", "experimental/aws-opensearch"] {
213 assert_eq!(gate_refusal(setup_owned, "x"), None, "{setup_owned}");
214 let gateability = type_gateability(setup_owned);
215 assert!(gateability.frozen, "{setup_owned} gates at setup");
216 assert!(!gateability.live, "{setup_owned} has no runtime controller");
217 }
218 }
219
220 #[test]
224 fn sandbox_gates_under_either_lifecycle() {
225 assert_eq!(gate_refusal("sandbox", "agents"), None);
226 let gateability = type_gateability("sandbox");
227 assert!(gateability.frozen, "a setup-baked image gates at setup");
228 assert!(
229 gateability.live,
230 "a runtime-built image gates in the runtime strip"
231 );
232 }
233
234 #[test]
238 fn every_manifest_type_is_asserted_somewhere_above() {
239 let asserted = [
240 "kv",
241 "storage",
242 "queue",
243 "vault",
244 "ai",
245 "postgres",
246 "worker",
247 "daemon",
248 "container",
249 "email",
250 "experimental/aws-opensearch",
251 "sandbox",
252 ];
253 for declared in MANIFEST_TYPES {
254 assert!(
255 asserted.contains(declared),
256 "{declared} is in the manifest but no test pins its gateability"
257 );
258 }
259 }
260
261 #[test]
262 fn extension_types_default_to_gateable() {
263 assert_eq!(gate_refusal("acme-widgets", "widgets"), None);
264 let gateability = type_gateability("acme-widgets");
265 assert!(gateability.frozen && gateability.live);
266 }
267}