1use crate::{
5 ownership_policy_for_resource_type, GateAnswers, Platform, ResourceEntry, Stack,
6 StackInputDefaultValue, StackInputDefinition,
7};
8use std::collections::{HashMap, HashSet};
9
10pub fn gate_value_as_bool(value: &serde_json::Value) -> Option<bool> {
15 match value {
16 serde_json::Value::Bool(answer) => Some(*answer),
17 serde_json::Value::String(text) => match text.as_str() {
18 "true" => Some(true),
19 "false" => Some(false),
20 _ => None,
21 },
22 _ => None,
23 }
24}
25
26pub fn frozen_gate_of(entry: &ResourceEntry) -> Option<&str> {
28 gate_of(entry, true)
29}
30
31pub fn live_gate_of(entry: &ResourceEntry) -> Option<&str> {
35 gate_of(entry, false)
36}
37
38fn gate_of(entry: &ResourceEntry, setup_created: bool) -> Option<&str> {
39 let input_id = entry.enabled_when.as_deref()?;
40 let emitted_in_setup =
41 ownership_policy_for_resource_type(entry.config.resource_type().as_ref())
42 .should_emit_in_setup(entry.lifecycle);
43 (emitted_in_setup == setup_created).then_some(input_id)
44}
45
46pub fn frozen_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
48 stack
49 .resources()
50 .filter_map(|(resource_id, entry)| Some((resource_id, frozen_gate_of(entry)?)))
51}
52
53pub fn live_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
55 stack
56 .resources()
57 .filter_map(|(resource_id, entry)| Some((resource_id, live_gate_of(entry)?)))
58}
59
60pub fn gate_is_off_platform(
62 inputs: &[StackInputDefinition],
63 input_id: &str,
64 platform: Platform,
65) -> bool {
66 inputs
67 .iter()
68 .find(|input| input.id == input_id)
69 .is_some_and(|input| !input.applies_to(platform))
70}
71
72pub fn remove_off_platform_gated_resources(
76 stack: &mut Stack,
77 platform: Platform,
78 compute: Option<&mut crate::ComputeSettings>,
79) {
80 let declined = stack
81 .resources()
82 .filter_map(|(id, entry)| {
83 let input_id = entry.enabled_when.as_deref()?;
84 gate_is_off_platform(&stack.inputs, input_id, platform).then(|| id.clone())
85 })
86 .collect::<Vec<_>>();
87 if let Some(compute) = compute {
88 for id in &declined {
89 if stack
90 .resources
91 .get(id)
92 .is_some_and(|entry| entry.config.downcast_ref::<crate::Container>().is_some())
93 {
94 compute.containers.remove(id);
95 }
96 }
97 }
98 remove_declined_resources(stack, &declined);
99}
100
101pub fn gate_resolves_true(
105 inputs: &[StackInputDefinition],
106 input_id: &str,
107 input_values: &HashMap<String, serde_json::Value>,
108 resource_id: &str,
109 platform: Platform,
110) -> Result<bool, String> {
111 if gate_is_off_platform(inputs, input_id, platform) {
112 return Ok(false);
113 }
114 if let Some(value) = input_values.get(input_id) {
115 return gate_value_as_bool(value).ok_or_else(|| {
116 format!(
117 "Input '{input_id}' enables resource '{resource_id}' but its value is not \
118 a boolean: {value}"
119 )
120 });
121 }
122
123 match inputs
124 .iter()
125 .find(|input| input.id == input_id)
126 .and_then(|input| input.default.as_ref())
127 {
128 Some(StackInputDefaultValue::Boolean(answer)) => Ok(*answer),
129 _ => Err(format!(
130 "Input '{input_id}' enables resource '{resource_id}' but no value was provided \
131 and the input declares no boolean default"
132 )),
133 }
134}
135
136pub fn live_gate_resolves_true(
141 inputs: &[StackInputDefinition],
142 input_id: &str,
143 input_values: &HashMap<String, serde_json::Value>,
144 persisted_gate_answers: &GateAnswers,
145 still_frozen_gating: &HashSet<String>,
146 resource_id: &str,
147 platform: Platform,
148) -> Result<(bool, &'static str), String> {
149 if gate_is_off_platform(inputs, input_id, platform) {
150 return Ok((false, "platform"));
151 }
152 if input_values.contains_key(input_id) {
160 return Ok((
161 gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
162 "provided",
163 ));
164 }
165 if still_frozen_gating.contains(input_id) {
166 if let Some(answer) = persisted_gate_answers.get(input_id) {
167 return Ok((*answer, "persisted"));
168 }
169 }
170 Ok((
171 gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
172 "default",
173 ))
174}
175
176pub fn declined_live_resources(
178 stack: &Stack,
179 input_values: &HashMap<String, serde_json::Value>,
180 persisted_gate_answers: &GateAnswers,
181 still_frozen_gating: &HashSet<String>,
182 platform: Platform,
183) -> Result<Vec<String>, String> {
184 let mut declined = Vec::new();
185 for (resource_id, input_id) in live_gated(stack) {
186 let (accepted, _source) = live_gate_resolves_true(
187 &stack.inputs,
188 input_id,
189 input_values,
190 persisted_gate_answers,
191 still_frozen_gating,
192 resource_id,
193 platform,
194 )?;
195 if !accepted {
196 declined.push(resource_id.clone());
197 }
198 }
199 Ok(declined)
200}
201
202pub fn surviving_frozen_gate_answers(stack: &Stack) -> (GateAnswers, HashSet<String>) {
206 let still_frozen_gating: HashSet<String> = frozen_gated(stack)
207 .map(|(_, input_id)| input_id.to_string())
208 .collect();
209 let answers = still_frozen_gating
210 .iter()
211 .map(|input_id| (input_id.clone(), true))
212 .collect();
213 (answers, still_frozen_gating)
214}
215
216pub fn remove_declined_resources(stack: &mut Stack, declined: &[String]) {
219 if declined.is_empty() {
220 return;
221 }
222 for entry in stack.resources.values_mut() {
223 if let Some(profile) = entry
224 .config
225 .downcast_mut::<crate::ComputeCluster>()
226 .and_then(|cluster| cluster.node_permissions.as_mut())
227 {
228 scrub_declined_profile(profile, declined);
229 }
230 }
231 remove_declined_live_resources(stack, declined);
232}
233
234pub fn remove_declined_live_resources(stack: &mut Stack, declined: &[String]) {
238 if declined.is_empty() {
239 return;
240 }
241
242 for resource_id in declined {
243 tracing::info!(
244 resource_id = %resource_id,
245 "The deployer declined this gated resource; it leaves the desired stack"
246 );
247 stack.resources.shift_remove(resource_id);
248 }
249
250 for (resource_id, entry) in stack.resources.iter_mut() {
254 let dropped = match crate::resource_links_mut(&mut entry.config) {
255 Some(owner) => {
256 let before = owner.links().len();
257 owner
258 .links_mut()
259 .retain(|link| !declined.contains(&link.id));
260 before - owner.links().len()
261 }
262 None => 0,
263 };
264 if dropped > 0 {
265 tracing::info!(
266 resource_id = %resource_id,
267 dropped,
268 declined = ?declined,
269 "Dropped links to declined resources; this resource keeps its own lifecycle"
270 );
271 }
272
273 let ordering_before = entry.dependencies.len();
274 entry
275 .dependencies
276 .retain(|dependency| !declined.contains(&dependency.id));
277 if ordering_before > entry.dependencies.len() {
280 tracing::info!(
281 resource_id = %resource_id,
282 dropped = ordering_before - entry.dependencies.len(),
283 "Dropped ordering edges to declined resources"
284 );
285 }
286 }
287
288 scrub_declined_grants(stack, declined);
289}
290
291fn scrub_declined_grants(stack: &mut Stack, declined: &[String]) {
296 for profile in stack.permissions.profiles.values_mut() {
297 scrub_declined_profile(profile, declined);
298 }
299 match &mut stack.permissions.management {
300 crate::permissions::ManagementPermissions::Extend(profile)
301 | crate::permissions::ManagementPermissions::Override(profile) => {
302 scrub_declined_profile(profile, declined);
303 }
304 crate::permissions::ManagementPermissions::Auto => {}
305 }
306}
307
308fn scrub_declined_profile(
309 profile: &mut crate::permissions::PermissionProfile,
310 declined: &[String],
311) {
312 for resource_id in declined {
313 if profile.0.shift_remove(resource_id).is_some() {
314 tracing::info!(resource_id = %resource_id, "Dropped the grant for a declined resource");
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn declining_a_node_target_preserves_empty_profiles_and_unrelated_grants() {
325 let profile = crate::PermissionProfile::new()
326 .resource("data", ["storage/data-read"])
327 .resource("missing", ["storage/data-write"]);
328 let mut stack = Stack::new("declined-grants".into())
329 .add(
330 crate::Storage::new("data".into()).build(),
331 crate::ResourceLifecycle::Frozen,
332 )
333 .add(
334 crate::ComputeCluster::new("compute".into())
335 .node_permissions(profile.clone())
336 .build(),
337 crate::ResourceLifecycle::Frozen,
338 )
339 .permission("reader", profile.clone())
340 .build();
341 stack.permissions.management = crate::ManagementPermissions::Extend(profile);
342 let cluster = stack
343 .resources
344 .get_mut("compute")
345 .unwrap()
346 .config
347 .downcast_mut::<crate::ComputeCluster>()
348 .unwrap();
349 cluster.node_permissions_platforms = Some(vec![Platform::Aws]);
350 remove_declined_resources(&mut stack, &["data".into()]);
351 let cluster = stack.resources["compute"]
352 .config
353 .downcast_ref::<crate::ComputeCluster>()
354 .unwrap();
355 let remaining = crate::PermissionProfile::new().resource("missing", ["storage/data-write"]);
356 assert_eq!(cluster.node_permissions.as_ref(), Some(&remaining));
357 assert_eq!(
358 cluster.node_permissions_platforms,
359 Some(vec![Platform::Aws])
360 );
361 assert_eq!(stack.permissions.profiles["reader"], remaining);
362 assert_eq!(
363 stack.permissions.management,
364 crate::ManagementPermissions::Extend(remaining)
365 );
366 remove_declined_resources(&mut stack, &["missing".into()]);
367 let cluster = stack.resources["compute"]
368 .config
369 .downcast_ref::<crate::ComputeCluster>()
370 .unwrap();
371 assert_eq!(
372 cluster.node_permissions,
373 Some(crate::PermissionProfile::new())
374 );
375 assert_eq!(
376 cluster.node_permissions_platforms,
377 Some(vec![Platform::Aws])
378 );
379 }
380
381 #[test]
382 fn off_platform_gate_needs_neither_a_valid_value_nor_a_default() {
383 let mut input = StackInputDefinition::deployer_boolean(
384 "managedBucket",
385 "Managed bucket",
386 "Create a bucket",
387 None,
388 );
389 input.platforms = Some(vec![Platform::Aws]);
390 let values = HashMap::from([(input.id.clone(), serde_json::json!("not a boolean"))]);
391 assert!(!gate_resolves_true(
392 std::slice::from_ref(&input),
393 &input.id,
394 &values,
395 "data",
396 Platform::Machines
397 )
398 .unwrap());
399 assert!(gate_resolves_true(
400 std::slice::from_ref(&input),
401 &input.id,
402 &values,
403 "data",
404 Platform::Aws
405 )
406 .is_err());
407 }
408
409 #[test]
410 fn platform_scope_overrides_defaults_provided_and_recorded_answers() {
411 for platforms in [None, Some(vec![Platform::Aws]), Some(vec![])] {
412 for default in [false, true] {
413 let mut input = StackInputDefinition::deployer_boolean(
414 "managedBucket",
415 "Managed bucket",
416 "Create a bucket",
417 Some(default),
418 );
419 input.platforms = platforms.clone();
420 for platform in [
421 Platform::Aws,
422 Platform::Machines,
423 Platform::Local,
424 Platform::Kubernetes,
425 ] {
426 for provided in [None, Some(false), Some(true)] {
427 let values = provided
428 .map(|value| (input.id.clone(), serde_json::json!(value)))
429 .into_iter()
430 .collect();
431 let excluded = platforms
432 .as_ref()
433 .is_some_and(|scope| !scope.is_empty() && !scope.contains(&platform));
434 let expected = !excluded && provided.unwrap_or(default);
435 assert_eq!(
436 gate_resolves_true(
437 std::slice::from_ref(&input),
438 &input.id,
439 &values,
440 "data",
441 platform
442 )
443 .unwrap(),
444 expected
445 );
446 for recorded in [false, true] {
447 let answers = GateAnswers::from([(input.id.clone(), recorded)]);
448 for frozen in [HashSet::new(), HashSet::from([input.id.clone()])] {
449 let expected = !excluded
450 && provided.unwrap_or(if frozen.is_empty() {
451 default
452 } else {
453 recorded
454 });
455 let (actual, source) = live_gate_resolves_true(
456 std::slice::from_ref(&input),
457 &input.id,
458 &values,
459 &answers,
460 &frozen,
461 "data",
462 platform,
463 )
464 .unwrap();
465 assert_eq!(actual, expected);
466 if excluded {
467 assert_eq!(source, "platform");
468 }
469 }
470 }
471 }
472 }
473 }
474 }
475 }
476}