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 "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 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 #[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 #[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}