use serde::{Deserialize, Serialize};
use crate::Platform;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum StackInputProvider {
Developer,
Deployer,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum StackInputKind {
String,
Secret,
Number,
Integer,
Boolean,
Enum,
StringList,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackInputEnvironmentMapping {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_resources: Option<Vec<String>>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub var_type: Option<StackInputEnvironmentVariableType>,
}
impl StackInputEnvironmentMapping {
pub fn targets(&self, resource_id: &str) -> bool {
targets_resource(&self.target_resources, resource_id)
}
}
pub fn targets_resource(target_resources: &Option<Vec<String>>, resource_id: &str) -> bool {
match target_resources {
None => true,
Some(patterns) => patterns
.iter()
.any(|pattern| match pattern.strip_suffix('*') {
Some(prefix) => resource_id.starts_with(prefix),
None => resource_id == pattern,
}),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum StackInputEnvironmentVariableType {
Plain,
Secret,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackInputValidation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_length: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_length: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub values: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_items: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type", content = "value")]
pub enum StackInputDefaultValue {
String(String),
Number(String),
Boolean(bool),
StringList(Vec<String>),
}
pub const STACK_INPUT_GENERATE_MIN_LENGTH: u32 = 16;
pub const STACK_INPUT_GENERATE_MAX_LENGTH: u32 = 256;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackInputGenerate {
pub length: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackInputCondition {
pub input: String,
pub equals: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackInputDefinition {
pub id: String,
pub kind: StackInputKind,
pub provided_by: Vec<StackInputProvider>,
pub required: bool,
pub label: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<StackInputDefaultValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<Platform>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<StackInputCondition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validation: Option<StackInputValidation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generate: Option<StackInputGenerate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<StackInputEnvironmentMapping>,
}
impl StackInputDefinition {
pub fn applies_to(&self, platform: Platform) -> bool {
self.platforms
.as_ref()
.is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
}
pub fn applies(
&self,
inputs: &[StackInputDefinition],
values: &std::collections::HashMap<String, serde_json::Value>,
platform: Platform,
) -> bool {
if !self.applies_to(platform) {
return false;
}
let Some(condition) = &self.when else {
return true;
};
let Some(reference) = inputs.iter().find(|input| input.id == condition.input) else {
return false;
};
if !reference.applies_to(platform) {
return !condition.equals;
}
let value = match values
.get(&condition.input)
.filter(|value| !value.is_null())
{
Some(value) => crate::gate_value_as_bool(value),
None => match reference.default {
Some(StackInputDefaultValue::Boolean(value)) => Some(value),
_ => None,
},
};
value == Some(condition.equals)
}
pub fn controls_input(
&self,
inputs: &[StackInputDefinition],
include: impl Fn(&StackInputDefinition) -> bool,
) -> bool {
inputs.iter().any(|dependent| {
include(dependent)
&& dependent
.when
.as_ref()
.is_some_and(|when| when.input == self.id)
})
}
pub fn is_generated(&self) -> bool {
self.generate.is_some()
}
#[doc(hidden)]
pub fn deployer_boolean(
id: &str,
label: &str,
description: &str,
default: Option<bool>,
) -> Self {
Self {
id: id.to_string(),
kind: StackInputKind::Boolean,
when: None,
provided_by: vec![StackInputProvider::Deployer],
required: default.is_none(),
label: label.to_string(),
description: description.to_string(),
placeholder: None,
default: default.map(StackInputDefaultValue::Boolean),
platforms: None,
validation: None,
generate: None,
env: Vec::new(),
}
}
}
pub fn find_boolean_gate_input<'a>(
inputs: &'a [StackInputDefinition],
input_id: &str,
) -> Result<&'a StackInputDefinition, GateInputIssue> {
let input = inputs
.iter()
.find(|input| input.id == input_id)
.ok_or(GateInputIssue::Undeclared)?;
if input.kind != StackInputKind::Boolean {
return Err(GateInputIssue::NotBoolean(input.kind.clone()));
}
Ok(input)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateInputIssue {
Undeclared,
NotBoolean(StackInputKind),
}
pub fn resolve_stack_input_environment_variables(
inputs: &[StackInputDefinition],
values: &std::collections::HashMap<String, serde_json::Value>,
platform: Platform,
) -> crate::Result<Vec<crate::EnvironmentVariable>> {
let mut variables: Vec<crate::EnvironmentVariable> = Vec::new();
for input in inputs
.iter()
.filter(|input| !input.env.is_empty())
.filter(|input| input.applies(inputs, values, platform))
{
let value = match values.get(&input.id) {
Some(serde_json::Value::Null) | None => match &input.default {
Some(default) => default_environment_string(default),
None => continue,
},
Some(value) => environment_string(value),
};
for mapping in &input.env {
if variables
.iter()
.any(|variable| variable.name == mapping.name)
{
return Err(alien_error::AlienError::new(
crate::ErrorData::GenericError {
message: format!(
"Stack inputs map more than one value to environment variable '{}'",
mapping.name
),
},
));
}
let secret = match mapping.var_type {
Some(StackInputEnvironmentVariableType::Secret) => true,
Some(StackInputEnvironmentVariableType::Plain) => false,
None => input.kind == StackInputKind::Secret,
};
variables.push(crate::EnvironmentVariable {
name: mapping.name.clone(),
value: value.clone(),
var_type: if secret {
crate::EnvironmentVariableType::Secret
} else {
crate::EnvironmentVariableType::Plain
},
target_resources: mapping.target_resources.clone(),
});
}
}
Ok(variables)
}
fn environment_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Array(items) => items
.iter()
.map(environment_string)
.collect::<Vec<_>>()
.join(","),
other => other.to_string(),
}
}
fn default_environment_string(default: &StackInputDefaultValue) -> String {
match default {
StackInputDefaultValue::String(value) | StackInputDefaultValue::Number(value) => {
value.clone()
}
StackInputDefaultValue::Boolean(value) => value.to_string(),
StackInputDefaultValue::StringList(values) => values.join(","),
}
}
#[cfg(test)]
mod environment_tests {
use std::collections::HashMap;
use super::*;
use crate::EnvironmentVariableType;
#[test]
fn an_env_mapping_targets_every_resource_or_its_patterns() {
let mapping = |targets: Option<Vec<&str>>| StackInputEnvironmentMapping {
name: "API_KEY".to_string(),
target_resources: targets.map(|t| t.into_iter().map(String::from).collect()),
var_type: None,
};
assert!(mapping(None).targets("api"));
assert!(mapping(Some(vec!["api"])).targets("api"));
assert!(!mapping(Some(vec!["api"])).targets("api-worker"));
assert!(mapping(Some(vec!["api-*"])).targets("api-worker"));
assert!(!mapping(Some(vec!["api-*"])).targets("web"));
assert!(mapping(Some(vec!["api-*", "worker"])).targets("worker"));
assert!(!mapping(Some(vec!["api-*", "worker"])).targets("scheduler"));
assert!(!mapping(Some(vec![])).targets("api"));
}
fn input(id: &str, kind: StackInputKind, env: &str) -> StackInputDefinition {
StackInputDefinition {
id: id.to_string(),
kind,
when: None,
provided_by: vec![StackInputProvider::Developer],
required: false,
label: id.to_string(),
description: id.to_string(),
placeholder: None,
default: None,
generate: None,
platforms: None,
validation: None,
env: vec![StackInputEnvironmentMapping {
name: env.to_string(),
target_resources: Some(vec!["api".to_string()]),
var_type: None,
}],
}
}
#[test]
fn conditional_environment_uses_provided_boolean_before_default() {
let toggle = StackInputDefinition::deployer_boolean(
"enabled",
"Enabled",
"Enable snapshots",
Some(true),
);
let mut bucket = input("bucket", StackInputKind::String, "BUCKET");
bucket.required = true;
bucket.when = Some(StackInputCondition {
input: "enabled".into(),
equals: true,
});
bucket.default = Some(StackInputDefaultValue::String("default-bucket".into()));
let inputs = vec![toggle, bucket];
for (answer, expected) in [
(None, Some("default-bucket")),
(Some(serde_json::json!(true)), Some("default-bucket")),
(Some(serde_json::json!("true")), Some("default-bucket")),
(Some(serde_json::json!(false)), None),
(Some(serde_json::json!("false")), None),
] {
let values = answer
.map(|value| HashMap::from([("enabled".into(), value)]))
.unwrap_or_default();
let env =
resolve_stack_input_environment_variables(&inputs, &values, Platform::Aws).unwrap();
assert_eq!(
env.first().map(|variable| variable.value.as_str()),
expected
);
}
let values = HashMap::from([
("enabled".into(), serde_json::json!(false)),
("bucket".into(), serde_json::json!({"invalid": "ignored"})),
]);
assert!(
resolve_stack_input_environment_variables(&inputs, &values, Platform::Aws)
.unwrap()
.is_empty()
);
}
#[test]
fn conditional_input_requires_an_answer_and_both_platform_scopes() {
let mut toggle =
StackInputDefinition::deployer_boolean("enabled", "Enabled", "Enable snapshots", None);
toggle.platforms = Some(vec![Platform::Aws]);
let mut bucket = input("bucket", StackInputKind::String, "BUCKET");
bucket.when = Some(StackInputCondition {
input: "enabled".into(),
equals: false,
});
let mut inputs = vec![toggle, bucket];
assert!(!inputs[1].applies(&inputs, &HashMap::new(), Platform::Aws));
let values = HashMap::from([("enabled".into(), serde_json::json!(false))]);
assert!(inputs[1].applies(&inputs, &values, Platform::Aws));
assert!(inputs[1].applies(&inputs, &values, Platform::Kubernetes));
inputs[1].when.as_mut().unwrap().equals = true;
assert!(!inputs[1].applies(&inputs, &values, Platform::Kubernetes));
inputs[0].platforms = Some(vec![]);
assert!(inputs[0].applies_to(Platform::Aws));
assert!(!inputs[1].applies(&inputs, &values, Platform::Aws));
inputs[1].when.as_mut().unwrap().equals = false;
assert!(inputs[1].applies(&inputs, &values, Platform::Aws));
inputs[1].platforms = Some(vec![Platform::Kubernetes]);
assert!(!inputs[1].applies(&inputs, &values, Platform::Aws));
}
#[test]
fn delivery_preserves_stored_values_without_revalidation() {
for (kind, value, validation) in [
(
StackInputKind::Number,
"1.50",
serde_json::json!({"min":"10"}),
),
(StackInputKind::Number, "1e3", serde_json::json!({})),
(
StackInputKind::Integer,
"007",
serde_json::json!({"max":"5"}),
),
(
StackInputKind::String,
"not-a-url",
serde_json::json!({"format":"url"}),
),
(
StackInputKind::String,
"",
serde_json::json!({"minLength":3}),
),
] {
let mut stored = input("stored", kind, "STORED");
stored.required = true;
stored.validation = Some(serde_json::from_value(validation).unwrap());
let mut added = input("added", StackInputKind::String, "ADDED");
added.required = true;
let values = HashMap::from([("stored".into(), serde_json::json!(value))]);
let variables =
resolve_stack_input_environment_variables(&[stored, added], &values, Platform::Aws)
.expect("delivery must not revalidate existing deployments");
assert_eq!(variables.len(), 1);
assert_eq!(variables[0].name, "STORED");
assert_eq!(variables[0].value, value);
}
}
#[test]
fn legacy_empty_platform_list_still_maps_environment() {
let mut definition = input("region", StackInputKind::String, "REGION");
definition.platforms = Some(vec![]);
let values = HashMap::from([("region".into(), serde_json::json!("us-east-1"))]);
let env = resolve_stack_input_environment_variables(&[definition], &values, Platform::Aws)
.unwrap();
assert_eq!(env.len(), 1);
assert_eq!(env[0].value, "us-east-1");
}
#[test]
fn maps_values_defaults_and_secrecy() {
let mut region = input("region", StackInputKind::String, "REGION");
region.default = Some(StackInputDefaultValue::String("eu-west-1".to_string()));
let inputs = vec![
input("token", StackInputKind::Secret, "ACCESS_TOKEN"),
input("zones", StackInputKind::StringList, "ZONES"),
region,
input("unset", StackInputKind::String, "UNSET"),
];
let values = HashMap::from([
("token".to_string(), serde_json::json!("s3cr3t")),
("zones".to_string(), serde_json::json!(["a", "b"])),
]);
let variables =
resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
.unwrap();
let by_name: HashMap<_, _> = variables.iter().map(|v| (v.name.as_str(), v)).collect();
assert_eq!(
variables.len(),
3,
"unset inputs without defaults produce nothing"
);
assert_eq!(by_name["ACCESS_TOKEN"].value, "s3cr3t");
assert_eq!(
by_name["ACCESS_TOKEN"].var_type,
EnvironmentVariableType::Secret
);
assert_eq!(
by_name["ACCESS_TOKEN"].target_resources,
Some(vec!["api".to_string()])
);
assert_eq!(by_name["ZONES"].value, "a,b");
assert_eq!(by_name["ZONES"].var_type, EnvironmentVariableType::Plain);
assert_eq!(by_name["REGION"].value, "eu-west-1");
}
#[test]
fn rejects_two_inputs_mapped_to_one_variable() {
let inputs = vec![
input("a", StackInputKind::String, "SHARED"),
input("b", StackInputKind::String, "SHARED"),
];
let values = HashMap::from([
("a".to_string(), serde_json::json!("1")),
("b".to_string(), serde_json::json!("2")),
]);
let error =
resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
.expect_err("duplicate names must be rejected");
assert!(error.message.contains("SHARED"));
}
#[test]
fn legacy_empty_platform_scope_preserves_environment_variables() {
let mut input = input("region", StackInputKind::String, "REGION");
input.platforms = Some(vec![]);
input.default = Some(StackInputDefaultValue::String("us-east-1".into()));
let values = HashMap::from([("region".into(), serde_json::json!("us-west-2"))]);
let variables =
resolve_stack_input_environment_variables(&[input], &values, Platform::Aws).unwrap();
assert_eq!(variables.len(), 1);
assert_eq!(variables[0].name, "REGION");
assert_eq!(variables[0].value, "us-west-2");
}
#[test]
fn inputs_for_other_platforms_produce_nothing() {
let mut aws_only = input("aws-region", StackInputKind::String, "REGION");
aws_only.platforms = Some(vec![Platform::Aws]);
aws_only.default = Some(StackInputDefaultValue::String("us-east-1".to_string()));
let mut kubernetes_only = input("zone", StackInputKind::String, "REGION");
kubernetes_only.platforms = Some(vec![Platform::Kubernetes]);
let values = HashMap::from([("zone".to_string(), serde_json::json!("rack-7"))]);
let variables = resolve_stack_input_environment_variables(
&[aws_only, kubernetes_only],
&values,
Platform::Kubernetes,
)
.unwrap();
assert_eq!(variables.len(), 1);
assert_eq!(variables[0].name, "REGION");
assert_eq!(variables[0].value, "rack-7");
}
}