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, ResourceEntry, Stack, StackInputDefaultValue,
6    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/// The deployer's answer for a live gate: the provided value, else the
61/// input's declared boolean default. The error is the reason, for the caller to wrap.
62pub fn gate_resolves_true(
63    inputs: &[StackInputDefinition],
64    input_id: &str,
65    input_values: &HashMap<String, serde_json::Value>,
66    resource_id: &str,
67) -> Result<bool, String> {
68    if let Some(value) = input_values.get(input_id) {
69        return gate_value_as_bool(value).ok_or_else(|| {
70            format!(
71                "Input '{input_id}' enables resource '{resource_id}' but its value is not \
72                 a boolean: {value}"
73            )
74        });
75    }
76
77    match inputs
78        .iter()
79        .find(|input| input.id == input_id)
80        .and_then(|input| input.default.as_ref())
81    {
82        Some(StackInputDefaultValue::Boolean(answer)) => Ok(*answer),
83        _ => Err(format!(
84            "Input '{input_id}' enables resource '{resource_id}' but no value was provided \
85             and the input declares no boolean default"
86        )),
87    }
88}
89
90/// A live gate's answer: the provided value, else the answer recorded when
91/// the deployment was created (frozen dominance — a live resource sharing a
92/// frozen-gating input follows the fixed answer, not the declared default),
93/// else the declared default.
94pub fn live_gate_resolves_true(
95    inputs: &[StackInputDefinition],
96    input_id: &str,
97    input_values: &HashMap<String, serde_json::Value>,
98    persisted_gate_answers: &GateAnswers,
99    still_frozen_gating: &HashSet<String>,
100    resource_id: &str,
101) -> Result<(bool, &'static str), String> {
102    // The recorded answer outranks the default only while the input actually
103    // gates a frozen resource — that is what dominance means. Once a release
104    // frees the input, its recorded answer is history, and a live gate
105    // resolves the way any other live gate does.
106    //
107    // The source travels with the answer so the audit log cannot describe a
108    // precedence this function did not apply.
109    if input_values.contains_key(input_id) {
110        return Ok((
111            gate_resolves_true(inputs, input_id, input_values, resource_id)?,
112            "provided",
113        ));
114    }
115    if still_frozen_gating.contains(input_id) {
116        if let Some(answer) = persisted_gate_answers.get(input_id) {
117            return Ok((*answer, "persisted"));
118        }
119    }
120    Ok((
121        gate_resolves_true(inputs, input_id, input_values, resource_id)?,
122        "default",
123    ))
124}
125
126/// The ids of the gated live resources whose input resolves false.
127pub fn declined_live_resources(
128    stack: &Stack,
129    input_values: &HashMap<String, serde_json::Value>,
130    persisted_gate_answers: &GateAnswers,
131    still_frozen_gating: &HashSet<String>,
132) -> Result<Vec<String>, String> {
133    let mut declined = Vec::new();
134    for (resource_id, input_id) in live_gated(stack) {
135        let (accepted, _source) = live_gate_resolves_true(
136            &stack.inputs,
137            input_id,
138            input_values,
139            persisted_gate_answers,
140            still_frozen_gating,
141            resource_id,
142        )?;
143        if !accepted {
144            declined.push(resource_id.clone());
145        }
146    }
147    Ok(declined)
148}
149
150/// Frozen-gate answers read off a stack whose Frozen declines were already stripped: a surviving
151/// gated setup-created resource reads as yes, even when its gate was never answered. So a strip
152/// built on it never drops what the real strip keeps: a digest can miss a match, never fake one.
153pub fn surviving_frozen_gate_answers(stack: &Stack) -> (GateAnswers, HashSet<String>) {
154    let still_frozen_gating: HashSet<String> = frozen_gated(stack)
155        .map(|(_, input_id)| input_id.to_string())
156        .collect();
157    let answers = still_frozen_gating
158        .iter()
159        .map(|input_id| (input_id.clone(), true))
160        .collect();
161    (answers, still_frozen_gating)
162}
163
164/// Take declined gated resources out of `stack` with every link, ordering edge and grant naming
165/// them. The deployment strips and the preflight runner's digest projection share it, so the
166/// installed stack and the target they compare are stripped the same way.
167pub fn remove_declined_resources(stack: &mut Stack, declined: &[String]) {
168    if declined.is_empty() {
169        return;
170    }
171
172    for resource_id in declined {
173        tracing::info!(
174            resource_id = %resource_id,
175            "The deployer declined this gated resource; it leaves the desired stack"
176        );
177        stack.resources.shift_remove(resource_id);
178    }
179
180    // Removing the resource without its inbound links would leave a survivor pointing at
181    // something that was never created, which the executor and binding resolution both
182    // reject. Scrubbing here is what lets an ungated resource link a gated one.
183    for (resource_id, entry) in stack.resources.iter_mut() {
184        let dropped = match crate::resource_links_mut(&mut entry.config) {
185            Some(owner) => {
186                let before = owner.links().len();
187                owner
188                    .links_mut()
189                    .retain(|link| !declined.contains(&link.id));
190                before - owner.links().len()
191            }
192            None => 0,
193        };
194        if dropped > 0 {
195            tracing::info!(
196                resource_id = %resource_id,
197                dropped,
198                declined = ?declined,
199                "Dropped links to declined resources; this resource keeps its own lifecycle"
200            );
201        }
202
203        let ordering_before = entry.dependencies.len();
204        entry
205            .dependencies
206            .retain(|dependency| !declined.contains(&dependency.id));
207        // The release-time preflight refuses authored ordering edges onto gated resources,
208        // so one reaching here predates the rule; dropping it keeps the stack coherent.
209        if ordering_before > entry.dependencies.len() {
210            tracing::info!(
211                resource_id = %resource_id,
212                dropped = ordering_before - entry.dependencies.len(),
213                "Dropped ordering edges to declined resources"
214            );
215        }
216    }
217
218    scrub_declined_grants(stack, declined);
219}
220
221/// Drop grants naming a declined resource from every permission profile.
222///
223/// Not inert: GCP applies every non-`"*"` entry without consulting the desired resources.
224/// Nothing is lost, because the mutations re-derive them whenever the gate is accepted.
225fn scrub_declined_grants(stack: &mut Stack, declined: &[String]) {
226    let scrub = |profile: &mut crate::permissions::PermissionProfile| {
227        for resource_id in declined {
228            if profile.0.shift_remove(resource_id).is_some() {
229                tracing::info!(
230                    resource_id = %resource_id,
231                    "Dropped the grant for a declined resource"
232                );
233            }
234        }
235    };
236
237    for profile in stack.permissions.profiles.values_mut() {
238        scrub(profile);
239    }
240    match &mut stack.permissions.management {
241        crate::permissions::ManagementPermissions::Extend(profile)
242        | crate::permissions::ManagementPermissions::Override(profile) => scrub(profile),
243        crate::permissions::ManagementPermissions::Auto => {}
244    }
245}