use crate::{
ownership_policy_for_resource_type, GateAnswers, Platform, ResourceEntry, Stack,
StackInputDefaultValue, StackInputDefinition,
};
use std::collections::{HashMap, HashSet};
pub fn gate_value_as_bool(value: &serde_json::Value) -> Option<bool> {
match value {
serde_json::Value::Bool(answer) => Some(*answer),
serde_json::Value::String(text) => match text.as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
pub fn frozen_gate_of(entry: &ResourceEntry) -> Option<&str> {
gate_of(entry, true)
}
pub fn live_gate_of(entry: &ResourceEntry) -> Option<&str> {
gate_of(entry, false)
}
fn gate_of(entry: &ResourceEntry, setup_created: bool) -> Option<&str> {
let input_id = entry.enabled_when.as_deref()?;
let emitted_in_setup =
ownership_policy_for_resource_type(entry.config.resource_type().as_ref())
.should_emit_in_setup(entry.lifecycle);
(emitted_in_setup == setup_created).then_some(input_id)
}
pub fn frozen_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
stack
.resources()
.filter_map(|(resource_id, entry)| Some((resource_id, frozen_gate_of(entry)?)))
}
pub fn live_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
stack
.resources()
.filter_map(|(resource_id, entry)| Some((resource_id, live_gate_of(entry)?)))
}
pub fn gate_is_off_platform(
inputs: &[StackInputDefinition],
input_id: &str,
platform: Platform,
) -> bool {
inputs
.iter()
.find(|input| input.id == input_id)
.is_some_and(|input| !input.applies_to(platform))
}
pub fn remove_off_platform_gated_resources(
stack: &mut Stack,
platform: Platform,
compute: Option<&mut crate::ComputeSettings>,
) {
let declined = stack
.resources()
.filter_map(|(id, entry)| {
let input_id = entry.enabled_when.as_deref()?;
gate_is_off_platform(&stack.inputs, input_id, platform).then(|| id.clone())
})
.collect::<Vec<_>>();
if let Some(compute) = compute {
for id in &declined {
if stack
.resources
.get(id)
.is_some_and(|entry| entry.config.downcast_ref::<crate::Container>().is_some())
{
compute.containers.remove(id);
}
}
}
remove_declined_resources(stack, &declined);
}
pub fn gate_resolves_true(
inputs: &[StackInputDefinition],
input_id: &str,
input_values: &HashMap<String, serde_json::Value>,
resource_id: &str,
platform: Platform,
) -> Result<bool, String> {
if gate_is_off_platform(inputs, input_id, platform) {
return Ok(false);
}
if let Some(value) = input_values.get(input_id) {
return gate_value_as_bool(value).ok_or_else(|| {
format!(
"Input '{input_id}' enables resource '{resource_id}' but its value is not \
a boolean: {value}"
)
});
}
match inputs
.iter()
.find(|input| input.id == input_id)
.and_then(|input| input.default.as_ref())
{
Some(StackInputDefaultValue::Boolean(answer)) => Ok(*answer),
_ => Err(format!(
"Input '{input_id}' enables resource '{resource_id}' but no value was provided \
and the input declares no boolean default"
)),
}
}
pub fn live_gate_resolves_true(
inputs: &[StackInputDefinition],
input_id: &str,
input_values: &HashMap<String, serde_json::Value>,
persisted_gate_answers: &GateAnswers,
still_frozen_gating: &HashSet<String>,
resource_id: &str,
platform: Platform,
) -> Result<(bool, &'static str), String> {
if gate_is_off_platform(inputs, input_id, platform) {
return Ok((false, "platform"));
}
if input_values.contains_key(input_id) {
return Ok((
gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
"provided",
));
}
if still_frozen_gating.contains(input_id) {
if let Some(answer) = persisted_gate_answers.get(input_id) {
return Ok((*answer, "persisted"));
}
}
Ok((
gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
"default",
))
}
pub fn declined_live_resources(
stack: &Stack,
input_values: &HashMap<String, serde_json::Value>,
persisted_gate_answers: &GateAnswers,
still_frozen_gating: &HashSet<String>,
platform: Platform,
) -> Result<Vec<String>, String> {
let mut declined = Vec::new();
for (resource_id, input_id) in live_gated(stack) {
let (accepted, _source) = live_gate_resolves_true(
&stack.inputs,
input_id,
input_values,
persisted_gate_answers,
still_frozen_gating,
resource_id,
platform,
)?;
if !accepted {
declined.push(resource_id.clone());
}
}
Ok(declined)
}
pub fn surviving_frozen_gate_answers(stack: &Stack) -> (GateAnswers, HashSet<String>) {
let still_frozen_gating: HashSet<String> = frozen_gated(stack)
.map(|(_, input_id)| input_id.to_string())
.collect();
let answers = still_frozen_gating
.iter()
.map(|input_id| (input_id.clone(), true))
.collect();
(answers, still_frozen_gating)
}
pub fn remove_declined_resources(stack: &mut Stack, declined: &[String]) {
if declined.is_empty() {
return;
}
for entry in stack.resources.values_mut() {
if let Some(profile) = entry
.config
.downcast_mut::<crate::ComputeCluster>()
.and_then(|cluster| cluster.node_permissions.as_mut())
{
scrub_declined_profile(profile, declined);
}
}
remove_declined_live_resources(stack, declined);
}
pub fn remove_declined_live_resources(stack: &mut Stack, declined: &[String]) {
if declined.is_empty() {
return;
}
for resource_id in declined {
tracing::info!(
resource_id = %resource_id,
"The deployer declined this gated resource; it leaves the desired stack"
);
stack.resources.shift_remove(resource_id);
}
for (resource_id, entry) in stack.resources.iter_mut() {
let dropped = match crate::resource_links_mut(&mut entry.config) {
Some(owner) => {
let before = owner.links().len();
owner
.links_mut()
.retain(|link| !declined.contains(&link.id));
before - owner.links().len()
}
None => 0,
};
if dropped > 0 {
tracing::info!(
resource_id = %resource_id,
dropped,
declined = ?declined,
"Dropped links to declined resources; this resource keeps its own lifecycle"
);
}
let ordering_before = entry.dependencies.len();
entry
.dependencies
.retain(|dependency| !declined.contains(&dependency.id));
if ordering_before > entry.dependencies.len() {
tracing::info!(
resource_id = %resource_id,
dropped = ordering_before - entry.dependencies.len(),
"Dropped ordering edges to declined resources"
);
}
}
scrub_declined_grants(stack, declined);
}
fn scrub_declined_grants(stack: &mut Stack, declined: &[String]) {
for profile in stack.permissions.profiles.values_mut() {
scrub_declined_profile(profile, declined);
}
match &mut stack.permissions.management {
crate::permissions::ManagementPermissions::Extend(profile)
| crate::permissions::ManagementPermissions::Override(profile) => {
scrub_declined_profile(profile, declined);
}
crate::permissions::ManagementPermissions::Auto => {}
}
}
fn scrub_declined_profile(
profile: &mut crate::permissions::PermissionProfile,
declined: &[String],
) {
for resource_id in declined {
if profile.0.shift_remove(resource_id).is_some() {
tracing::info!(resource_id = %resource_id, "Dropped the grant for a declined resource");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declining_a_node_target_preserves_empty_profiles_and_unrelated_grants() {
let profile = crate::PermissionProfile::new()
.resource("data", ["storage/data-read"])
.resource("missing", ["storage/data-write"]);
let mut stack = Stack::new("declined-grants".into())
.add(
crate::Storage::new("data".into()).build(),
crate::ResourceLifecycle::Frozen,
)
.add(
crate::ComputeCluster::new("compute".into())
.node_permissions(profile.clone())
.build(),
crate::ResourceLifecycle::Frozen,
)
.permission("reader", profile.clone())
.build();
stack.permissions.management = crate::ManagementPermissions::Extend(profile);
let cluster = stack
.resources
.get_mut("compute")
.unwrap()
.config
.downcast_mut::<crate::ComputeCluster>()
.unwrap();
cluster.node_permissions_platforms = Some(vec![Platform::Aws]);
remove_declined_resources(&mut stack, &["data".into()]);
let cluster = stack.resources["compute"]
.config
.downcast_ref::<crate::ComputeCluster>()
.unwrap();
let remaining = crate::PermissionProfile::new().resource("missing", ["storage/data-write"]);
assert_eq!(cluster.node_permissions.as_ref(), Some(&remaining));
assert_eq!(
cluster.node_permissions_platforms,
Some(vec![Platform::Aws])
);
assert_eq!(stack.permissions.profiles["reader"], remaining);
assert_eq!(
stack.permissions.management,
crate::ManagementPermissions::Extend(remaining)
);
remove_declined_resources(&mut stack, &["missing".into()]);
let cluster = stack.resources["compute"]
.config
.downcast_ref::<crate::ComputeCluster>()
.unwrap();
assert_eq!(
cluster.node_permissions,
Some(crate::PermissionProfile::new())
);
assert_eq!(
cluster.node_permissions_platforms,
Some(vec![Platform::Aws])
);
}
#[test]
fn off_platform_gate_needs_neither_a_valid_value_nor_a_default() {
let mut input = StackInputDefinition::deployer_boolean(
"managedBucket",
"Managed bucket",
"Create a bucket",
None,
);
input.platforms = Some(vec![Platform::Aws]);
let values = HashMap::from([(input.id.clone(), serde_json::json!("not a boolean"))]);
assert!(!gate_resolves_true(
std::slice::from_ref(&input),
&input.id,
&values,
"data",
Platform::Machines
)
.unwrap());
assert!(gate_resolves_true(
std::slice::from_ref(&input),
&input.id,
&values,
"data",
Platform::Aws
)
.is_err());
}
#[test]
fn platform_scope_overrides_defaults_provided_and_recorded_answers() {
for platforms in [None, Some(vec![Platform::Aws]), Some(vec![])] {
for default in [false, true] {
let mut input = StackInputDefinition::deployer_boolean(
"managedBucket",
"Managed bucket",
"Create a bucket",
Some(default),
);
input.platforms = platforms.clone();
for platform in [
Platform::Aws,
Platform::Machines,
Platform::Local,
Platform::Kubernetes,
] {
for provided in [None, Some(false), Some(true)] {
let values = provided
.map(|value| (input.id.clone(), serde_json::json!(value)))
.into_iter()
.collect();
let excluded = platforms
.as_ref()
.is_some_and(|scope| !scope.is_empty() && !scope.contains(&platform));
let expected = !excluded && provided.unwrap_or(default);
assert_eq!(
gate_resolves_true(
std::slice::from_ref(&input),
&input.id,
&values,
"data",
platform
)
.unwrap(),
expected
);
for recorded in [false, true] {
let answers = GateAnswers::from([(input.id.clone(), recorded)]);
for frozen in [HashSet::new(), HashSet::from([input.id.clone()])] {
let expected = !excluded
&& provided.unwrap_or(if frozen.is_empty() {
default
} else {
recorded
});
let (actual, source) = live_gate_resolves_true(
std::slice::from_ref(&input),
&input.id,
&values,
&answers,
&frozen,
"data",
platform,
)
.unwrap();
assert_eq!(actual, expected);
if excluded {
assert_eq!(source, "platform");
}
}
}
}
}
}
}
}
}