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 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 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 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,
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();
let applies = |input: &&StackInputDefinition| match &input.platforms {
Some(platforms) if !platforms.is_empty() => platforms.contains(&platform),
_ => true,
};
for input in inputs
.iter()
.filter(|input| !input.env.is_empty())
.filter(applies)
{
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,
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 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 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");
}
}