Skip to main content

alien_core/
gate_resolution.rs

1//! How a stack's `.enabled()` gates resolve to a yes or no, shared by the deployment steps that
2//! strip declined resources and the preflight runner that hashes the stack they will keep.
3
4use crate::{
5    ownership_policy_for_resource_type, GateAnswers, Platform, ResourceEntry, Stack,
6    StackInputDefaultValue, StackInputDefinition,
7};
8use std::collections::{HashMap, HashSet};
9
10/// A gate value from the wire: JSON booleans stay booleans, and the
11/// CloudFormation parameter strings "true"/"false" coerce — CloudFormation
12/// has no boolean parameter type, so its registration payloads deliver gate
13/// answers as strings. Anything else is `None`, refused loudly by callers.
14pub 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
26/// The gate input of a setup-created gated resource, `None` for anything else.
27pub fn frozen_gate_of(entry: &ResourceEntry) -> Option<&str> {
28    gate_of(entry, true)
29}
30
31/// The gate input of a runtime-created gated resource, `None` for anything
32/// else. The mirror of [`frozen_gate_of`] — which side of the setup boundary a
33/// gated resource falls on is decided in exactly these two places.
34pub 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
46/// Every gated resource setup creates, as `(resource_id, input_id)`.
47pub 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
53/// Every gated resource the runtime creates, as `(resource_id, input_id)`.
54pub 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
60/// Whether a gate's input is excluded from the deployment platform.
61pub 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
72/// Remove resources whose gate input is unavailable on this platform, including their links
73/// and grants, plus any supplied container allocations. Unlike a mutable live decline,
74/// these resources can never be enabled here.
75pub 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
101/// Inputs scoped away from the platform always resolve false, even with a provided value.
102/// The deployer's answer for a live gate: the provided value, else the
103/// input's declared boolean default. The error is the reason, for the caller to wrap.
104pub 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
136/// A live gate's answer: the provided value, else the answer recorded when
137/// the deployment was created (frozen dominance — a live resource sharing a
138/// frozen-gating input follows the fixed answer, not the declared default),
139/// else the declared default.
140pub 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    // The recorded answer outranks the default only while the input actually
153    // gates a frozen resource — that is what dominance means. Once a release
154    // frees the input, its recorded answer is history, and a live gate
155    // resolves the way any other live gate does.
156    //
157    // The source travels with the answer so the audit log cannot describe a
158    // precedence this function did not apply.
159    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
176/// The ids of the gated live resources whose input resolves false.
177pub 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
202/// Frozen-gate answers read off a stack whose Frozen declines were already stripped: a surviving
203/// gated setup-created resource reads as yes, even when its gate was never answered. So a strip
204/// built on it never drops what the real strip keeps: a digest can miss a match, never fake one.
205pub 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
216/// Remove declined resources before mutations, including node grants that must name concrete
217/// resources when the ComputeCluster mutation derives setup-owned node permissions.
218pub 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
234/// Remove mutable Live declines after mutations, retaining setup-owned node grants just as
235/// derived service-account grants are retained. Later updates compare those Frozen configs
236/// with the accepted render before applying Live declines. The setup digest uses this strip too.
237pub 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    // Removing the resource without its inbound links would leave a survivor pointing at
251    // something that was never created, which the executor and binding resolution both
252    // reject. Scrubbing here is what lets an ungated resource link a gated one.
253    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        // The release-time preflight refuses authored ordering edges onto gated resources,
278        // so one reaching here predates the rule; dropping it keeps the stack coherent.
279        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
291/// Drop grants naming a declined resource from workload and management profiles.
292///
293/// Not inert: GCP applies every non-`"*"` entry without consulting the desired resources.
294/// Nothing is lost, because the mutations re-derive them whenever the gate is accepted.
295fn 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}