use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
vault_naming, BindingValue, EnvironmentVariable, EnvironmentVariableType, Platform,
StackInputDefinition, StackInputEnvironmentVariableType, StackInputKind, StackInputProvider,
VaultBinding,
};
pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
pub fn deployer_secret_vault_key(input_id: &str) -> String {
let mut key = String::new();
let mut previous_lower_or_digit = false;
let mut pending_separator = false;
for character in input_id.chars() {
if character.is_ascii_alphanumeric() {
let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
if (pending_separator || starts_word) && !key.is_empty() && !key.ends_with('-') {
key.push('-');
}
key.push(character.to_ascii_lowercase());
previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
pending_separator = false;
} else {
pending_separator = true;
previous_lower_or_digit = false;
}
}
key.trim_end_matches('-').to_string()
}
pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
input.kind == StackInputKind::Secret
&& input.provided_by.contains(&StackInputProvider::Deployer)
}
pub fn deployer_secret_value_refusal(name: &str) -> String {
format!(
"'{name}' is a deployer secret: its value goes into your own secret store and never \
through Alien. Do not pass it here; write it into the deployment's deployer-secrets vault \
instead (the deployment status shows the secret's name and the command that writes it)."
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeployerSecretSlot<'a> {
pub input: &'a StackInputDefinition,
pub vault_key: String,
pub has_stored_value: bool,
}
pub fn deployer_secret_slots<'a>(
inputs: &'a [StackInputDefinition],
values: &HashMap<String, serde_json::Value>,
stored_secret_input_ids: Option<&[String]>,
environment: &[EnvironmentVariable],
platform: Platform,
) -> Vec<DeployerSecretSlot<'a>> {
inputs
.iter()
.filter(|input| is_deployer_secret_input(input))
.filter(|input| {
input
.platforms
.as_ref()
.is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
})
.filter_map(|input| {
let has_stored_value = stored_secret_input_ids
.is_some_and(|ids| ids.contains(&input.id))
|| values.get(&input.id).is_some_and(|value| !value.is_null());
let developer_value = input.provided_by.contains(&StackInputProvider::Developer)
&& (has_stored_value
|| (stored_secret_input_ids.is_none()
&& legacy_secret_delivery_complete(input, environment)));
(!developer_value).then(|| DeployerSecretSlot {
input,
vault_key: deployer_secret_vault_key(&input.id),
has_stored_value,
})
})
.collect()
}
fn legacy_secret_delivery_complete(
input: &StackInputDefinition,
environment: &[EnvironmentVariable],
) -> bool {
!input.env.is_empty()
&& input.env.iter().all(|mapping| {
let mut matches = environment
.iter()
.filter(|variable| variable.name == mapping.name);
let Some(variable) = matches.next() else {
return false;
};
if matches.next().is_some() {
return false;
}
!mapping.name.is_empty()
&& mapping
.target_resources
.as_ref()
.is_none_or(|targets| !targets.is_empty())
&& !matches!(
mapping.var_type,
Some(StackInputEnvironmentVariableType::Plain)
)
&& variable.var_type == EnvironmentVariableType::Secret
&& match (&mapping.target_resources, &variable.target_resources) {
(_, None) => true,
(None, Some(delivered)) => delivered.iter().any(|pattern| pattern == "*"),
(Some(required), Some(delivered)) => {
!required.is_empty()
&& required.iter().all(|target| {
delivered.iter().any(|pattern| {
pattern == target
|| pattern
.strip_suffix('*')
.is_some_and(|prefix| target.starts_with(prefix))
})
})
}
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DeployerSecretStore {
AwsParameterStore,
GcpSecretManager,
AzureKeyVault,
KubernetesSecret,
LocalVault,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeployerSecretCommandVariants {
pub inline: String,
pub prompt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeployerSecretCommands {
pub unix: DeployerSecretCommandVariants,
pub windows: DeployerSecretCommandVariants,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeployerSecretLocation {
pub store: DeployerSecretStore,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub console_url: Option<String>,
pub cli_command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands: Option<DeployerSecretCommands>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_command: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeployerSecretLocationContext {
pub secret_label: Option<String>,
pub aws_account_id: Option<String>,
pub aws_region: Option<String>,
pub gcp_project_id: Option<String>,
pub azure_subscription_id: Option<String>,
pub azure_resource_group: Option<String>,
pub deployment_name: Option<String>,
}
pub fn deployer_secret_location(
binding: &VaultBinding,
vault_key: &str,
context: &DeployerSecretLocationContext,
) -> crate::Result<DeployerSecretLocation> {
let label = context.secret_label.as_deref().unwrap_or(vault_key);
let mut prepare = String::new();
let mut location = match binding {
VaultBinding::ParameterStore(binding) => {
let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
let region = context.aws_region.as_deref();
let region_flag = region
.map(|region| format!(" --region {}", shell_quote(region)))
.unwrap_or_default();
DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::AwsParameterStore,
vault_name: None,
console_url: region.map(|region| {
format!(
"https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
)
}),
cli_command: format!(
"aws ssm put-parameter{region_flag} --name {} --type SecureString --overwrite --value file:///dev/stdin", shell_quote(&name)
),
delete_command: Some(format!(
"aws ssm delete-parameter{region_flag} --name '{name}'"
)),
name,
}
}
VaultBinding::SecretManager(binding) => {
let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
let project = context.gcp_project_id.as_deref();
let project_flag = project
.map(|project| format!(" --project {}", shell_quote(project)))
.unwrap_or_default();
prepare = format!(
"(gcloud secrets describe {0}{project_flag} >/dev/null 2>&1 || gcloud secrets create {0}{project_flag} --replication-policy=automatic) &&\n",
shell_quote(&name)
);
DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::GcpSecretManager,
vault_name: None,
console_url: project.map(|project| {
format!(
"https://console.cloud.google.com/security/secret-manager/create?project={project}"
)
}),
cli_command: format!(
"gcloud secrets versions add {}{project_flag} --data-file=-", shell_quote(&name)
),
delete_command: Some(format!(
"gcloud secrets delete {name}{project_flag} --quiet"
)),
name,
}
}
VaultBinding::KeyVault(binding) => {
let vault_name = concrete(&binding.vault_name, "vaultName")?;
let name = vault_naming::key_vault_secret_name(vault_key);
DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::AzureKeyVault,
vault_name: Some(vault_name.clone()),
console_url: match (
context.azure_subscription_id.as_deref(),
context.azure_resource_group.as_deref(),
) {
(Some(subscription), Some(resource_group)) => Some(format!(
"https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
)),
_ => None,
},
cli_command: format!(
"az keyvault secret set --vault-name {} --name {} --file /dev/stdin --encoding utf-8 --output none",
shell_quote(&vault_name),
shell_quote(&name)
),
delete_command: Some(format!(
"az keyvault secret delete --vault-name {vault_name} --name {name}"
)),
name,
}
}
VaultBinding::KubernetesSecret(binding) => {
let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
let namespace = concrete(&binding.namespace, "namespace")?;
let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::KubernetesSecret,
vault_name: None,
console_url: None,
cli_command: format!(
"kubectl create secret generic {} --namespace {} --from-file={}=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -",
shell_quote(&name),
shell_quote(&namespace),
vault_naming::KUBERNETES_SECRET_VALUE_KEY
),
delete_command: Some(format!(
"kubectl delete secret {name} --namespace {namespace}"
)),
name,
}
}
VaultBinding::Local(binding) => {
let deployment_flag = context
.deployment_name
.as_deref()
.map(|name| format!(" --deployment {}", shell_quote(name)))
.unwrap_or_default();
DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::LocalVault,
vault_name: None,
console_url: None,
cli_command: format!(
"alien dev vault{deployment_flag} set {} {} --stdin",
shell_quote(&binding.vault_name),
shell_quote(vault_key)
),
delete_command: Some(format!(
"alien dev vault{deployment_flag} delete {} {vault_key}",
binding.vault_name
)),
name: vault_key.to_string(),
}
}
};
let unix_inline = match location.store {
DeployerSecretStore::AwsParameterStore => location
.cli_command
.replace("file:///dev/stdin", "'<VALUE>'"),
DeployerSecretStore::AzureKeyVault => location
.cli_command
.replace("--file /dev/stdin --encoding utf-8", "--value '<VALUE>'"),
DeployerSecretStore::KubernetesSecret => location.cli_command.replace(
"--from-file=value=/dev/stdin",
"--from-literal=value='<VALUE>'",
),
DeployerSecretStore::LocalVault => format!(
"{} '<VALUE>'",
location.cli_command.trim_end_matches(" --stdin")
),
DeployerSecretStore::GcpSecretManager => format!(
"{}printf '%s' '<VALUE>' | {}",
prepare.replace('\n', " "),
location.cli_command
),
};
let windows = windows_secret_commands(binding, vault_key, context)?;
let prompt = shell_quote(&format!("{label}: "));
location.cli_command = format!(
"(set +x; set -o pipefail; printf '%s' {prompt} >&2; IFS= read -rs secret || [ -n \"$secret\" ]\n[ -n \"$secret\" ] && printf '\\n' >&2 && {prepare}printf '%s' \"$secret\" | {}\n)",
location.cli_command,
prepare = prepare.replace('\n', " "),
);
let (target, check_command) = match binding {
VaultBinding::ParameterStore(_) => (
context.aws_account_id.clone(),
Some("aws sts get-caller-identity --query Account --output text".to_string()),
),
VaultBinding::SecretManager(_) => (
context.gcp_project_id.clone(),
Some("gcloud auth list --filter=status:ACTIVE --format=\"value(account)\"".to_string()),
),
VaultBinding::KeyVault(_) => (
context.azure_subscription_id.clone(),
Some("az account show --query id --output tsv".to_string()),
),
VaultBinding::KubernetesSecret(binding) => (
Some(concrete(&binding.namespace, "namespace")?),
Some("kubectl config current-context".to_string()),
),
VaultBinding::Local(_) => (context.deployment_name.clone(), None),
};
location.commands = Some(DeployerSecretCommands {
unix: DeployerSecretCommandVariants {
inline: unix_inline,
prompt: location.cli_command.clone(),
},
windows,
target,
check_command,
});
Ok(location)
}
fn windows_secret_commands(
binding: &VaultBinding,
vault_key: &str,
context: &DeployerSecretLocationContext,
) -> crate::Result<DeployerSecretCommandVariants> {
let mut prepare = String::new();
let write = match binding {
VaultBinding::ParameterStore(binding) => {
let name = vault_naming::parameter_store_parameter_name(
&concrete(&binding.vault_prefix, "vaultPrefix")?,
vault_key,
);
let region = context
.aws_region
.as_deref()
.map(|value| format!(" --region {}", powershell_quote(value)))
.unwrap_or_default();
format!(
"aws ssm put-parameter{region} --name {} --type SecureString --overwrite --value \"file://$path\"",
powershell_quote(&name)
)
}
VaultBinding::SecretManager(binding) => {
let name = powershell_quote(&vault_naming::secret_manager_secret_id(
&concrete(&binding.vault_prefix, "vaultPrefix")?,
vault_key,
));
let project = context
.gcp_project_id
.as_deref()
.map(|value| format!(" --project {}", powershell_quote(value)))
.unwrap_or_default();
prepare = format!(
"gcloud secrets describe {name}{project} 2>$null | Out-Null; if ($LASTEXITCODE -ne 0) {{ gcloud secrets create {name}{project} --replication-policy=automatic; if ($LASTEXITCODE -ne 0) {{ throw 'Could not create secret' }} }}; "
);
format!("gcloud secrets versions add {name}{project} --data-file=$path")
}
VaultBinding::KeyVault(binding) => format!(
"az keyvault secret set --vault-name {} --name {} --file $path --encoding utf-8 --output none",
powershell_quote(&concrete(&binding.vault_name, "vaultName")?),
powershell_quote(&vault_naming::key_vault_secret_name(vault_key))
),
VaultBinding::KubernetesSecret(binding) => format!(
"kubectl create secret generic {} --namespace {} --from-file=\"value=$path\" --dry-run=client -o yaml | kubectl apply -f -",
powershell_quote(&vault_naming::kubernetes_secret_name(
&concrete(&binding.vault_prefix, "vaultPrefix")?,
vault_key
)),
powershell_quote(&concrete(&binding.namespace, "namespace")?)
),
VaultBinding::Local(binding) => {
let deployment = context
.deployment_name
.as_deref()
.map(|value| format!(" --deployment {}", powershell_quote(value)))
.unwrap_or_default();
format!(
"alien dev vault{deployment} set {} {} --file $path",
powershell_quote(&binding.vault_name),
powershell_quote(vault_key)
)
}
};
let file = "$path = [IO.Path]::GetTempFileName()";
let save = "[IO.File]::WriteAllText($path, $value, (New-Object Text.UTF8Encoding $false))";
let cleanup = "Remove-Item -LiteralPath $path -Force; $value = $null";
let result = "; if ($LASTEXITCODE -ne 0) { throw 'Could not write secret' }";
let inline = format!(
"& {{ $value = '<VALUE>'; {file}; try {{ {save}; {prepare}{write}{result} }} finally {{ {cleanup} }} }}"
);
let inline = match binding {
VaultBinding::ParameterStore(_) => write.replace("\"file://$path\"", "'<VALUE>'"),
VaultBinding::KeyVault(_) => {
write.replace("--file $path --encoding utf-8", "--value '<VALUE>'")
}
VaultBinding::KubernetesSecret(_) => write.replace(
"--from-file=\"value=$path\"",
"--from-literal=value='<VALUE>'",
),
VaultBinding::Local(binding) => {
let deployment = context
.deployment_name
.as_deref()
.map(|value| format!(" --deployment {}", powershell_quote(value)))
.unwrap_or_default();
format!(
"alien dev vault{deployment} set {} {} '<VALUE>'",
powershell_quote(&binding.vault_name),
powershell_quote(vault_key)
)
}
VaultBinding::SecretManager(_) => inline,
};
let label = powershell_quote(context.secret_label.as_deref().unwrap_or(vault_key));
let prompt = format!(
"& {{ $value = [Net.NetworkCredential]::new('', (Read-Host {label} -AsSecureString)).Password\nif (!$value) {{ return }}\n{file}\ntry {{ {save}\n{prepare}{write}{result}\n}} finally {{ {cleanup} }} }}"
);
Ok(DeployerSecretCommandVariants { inline, prompt })
}
fn powershell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn shell_quote(value: &str) -> String {
if !value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_./:@".contains(&byte))
{
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
}
fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
value
.clone()
.into_value(crate::DEPLOYER_SECRETS_VAULT_ID, field)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DeployerSecretStatus {
Present,
Missing,
Invalid,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeployerSecretReport {
pub input_id: String,
pub label: String,
pub required: bool,
pub status: DeployerSecretStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub location: DeployerSecretLocation,
}
impl DeployerSecretReport {
pub fn blocks_start(&self) -> bool {
self.required && self.status != DeployerSecretStatus::Present
}
pub fn summary(&self) -> String {
match self.status {
DeployerSecretStatus::Present => format!("present: {}", self.label),
DeployerSecretStatus::Missing => format!("missing: {}", self.label),
DeployerSecretStatus::Invalid => match &self.message {
Some(message) => format!("invalid: {} ({message})", self.label),
None => format!("invalid: {}", self.label),
},
}
}
}
pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployerSecretEnv {
pub name: String,
pub vault_key: String,
pub secret_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault_name: Option<String>,
pub label: String,
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
pub fn deployer_secret_environment(
inputs: &[StackInputDefinition],
values: &HashMap<String, serde_json::Value>,
stored_secret_input_ids: Option<&[String]>,
environment: &[EnvironmentVariable],
platform: Platform,
reports: &[DeployerSecretReport],
) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
deployer_secret_slots(
inputs,
values,
stored_secret_input_ids,
environment,
platform,
)
.into_iter()
.filter(|slot| !slot.has_stored_value)
.filter_map(|slot| {
reports
.iter()
.find(|report| report.input_id == slot.input.id)
.map(|report| (slot, report))
})
.flat_map(|(slot, report)| {
slot.input.env.iter().map(move |mapping| {
(
DeployerSecretEnv {
name: mapping.name.clone(),
vault_key: slot.vault_key.clone(),
secret_name: report.location.name.clone(),
vault_name: report.location.vault_name.clone(),
label: slot.input.label.clone(),
required: slot.input.required,
version: report.version.clone(),
},
mapping.target_resources.clone(),
)
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StackInputEnvironmentMapping;
fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
StackInputDefinition {
id: id.to_string(),
kind: StackInputKind::Secret,
provided_by,
required: true,
label: "Database password".to_string(),
description: String::new(),
placeholder: None,
default: None,
platforms: None,
validation: None,
generate: None,
env: vec![StackInputEnvironmentMapping {
name: "DATABASE_PASSWORD".to_string(),
target_resources: None,
var_type: None,
}],
}
}
#[test]
fn legacy_omitted_presence_uses_only_complete_scoped_secret_delivery() {
let base = serde_json::json!({
"environmentVariables": {
"variables": [{"name": "DATABASE_PASSWORD", "value": "fixture-value", "type": "secret"}],
"hash": "fixture", "createdAt": "2026-01-01T00:00:00Z"
}
});
let legacy: crate::DeploymentConfig = serde_json::from_value(base.clone()).unwrap();
assert_eq!(legacy.stored_secret_input_ids, None);
let input = secret(
"databasePassword",
vec![StackInputProvider::Developer, StackInputProvider::Deployer],
);
for case in [
"legacy",
"null",
"empty",
"unrelated",
"missing",
"plain",
"wrong-name",
"partial",
"narrow",
"matching-scope",
"broader-scope",
"deployer-only",
"no-mappings",
"plain-mapping",
"duplicate-plain",
"duplicate-scoped-secret",
] {
let mut wire = base.clone();
let mut input = input.clone();
match case {
"null" => wire["storedSecretInputIds"] = serde_json::Value::Null,
"empty" => wire["storedSecretInputIds"] = serde_json::json!([]),
"unrelated" => wire["storedSecretInputIds"] = serde_json::json!(["other"]),
"missing" => wire["environmentVariables"]["variables"] = serde_json::json!([]),
"plain" => {
wire["environmentVariables"]["variables"][0]["type"] =
serde_json::json!("plain")
}
"duplicate-plain" | "duplicate-scoped-secret" => {
let mut duplicate = wire["environmentVariables"]["variables"][0].clone();
if case == "duplicate-plain" {
duplicate["type"] = serde_json::json!("plain");
} else {
input.env[0].target_resources = Some(vec!["app".to_string()]);
wire["environmentVariables"]["variables"][0]["targetResources"] =
serde_json::json!(["app"]);
duplicate["targetResources"] = serde_json::json!(["other"]);
}
wire["environmentVariables"]["variables"]
.as_array_mut()
.unwrap()
.push(duplicate);
}
"wrong-name" => {
wire["environmentVariables"]["variables"][0]["name"] =
serde_json::json!("OTHER")
}
"partial" => {
let mut mapping = input.env[0].clone();
mapping.name = "SECOND".to_string();
input.env.push(mapping);
}
"narrow" => {
wire["environmentVariables"]["variables"][0]["targetResources"] =
serde_json::json!(["app"])
}
"matching-scope" => {
input.env[0].target_resources = Some(vec!["app*".to_string()]);
wire["environmentVariables"]["variables"][0]["targetResources"] =
serde_json::json!(["app*"]);
}
"broader-scope" => input.env[0].target_resources = Some(vec!["app".to_string()]),
"deployer-only" => input.provided_by = vec![StackInputProvider::Deployer],
"no-mappings" => input.env.clear(),
"plain-mapping" => {
input.env[0].var_type = Some(StackInputEnvironmentVariableType::Plain)
}
_ => {}
}
let config: crate::DeploymentConfig = serde_json::from_value(wire).unwrap();
let inputs = [input];
let delivered = matches!(case, "legacy" | "null" | "matching-scope" | "broader-scope");
let slots = deployer_secret_slots(
&inputs,
&config.input_values,
config.stored_secret_input_ids.as_deref(),
&config.environment_variables.variables,
Platform::Aws,
);
assert_eq!(slots.is_empty(), delivered, "{case}");
let reports = [report("databasePassword", DeployerSecretStatus::Present)];
let pointers = deployer_secret_environment(
&inputs,
&config.input_values,
config.stored_secret_input_ids.as_deref(),
&config.environment_variables.variables,
Platform::Aws,
&reports,
);
assert_eq!(
pointers.len(),
if delivered || case == "no-mappings" {
0
} else {
inputs[0].env.len()
},
"{case}"
);
assert!(config.input_values.is_empty());
assert!(is_deployer_secret_input(&inputs[0]));
}
}
#[test]
fn vault_keys_are_kebab_case_and_backend_safe() {
assert_eq!(
deployer_secret_vault_key("databasePassword"),
"database-password"
);
assert_eq!(deployer_secret_vault_key("api_key"), "api-key");
assert_eq!(deployer_secret_vault_key("OAuth2Token"), "oauth2-token");
assert_eq!(deployer_secret_vault_key("a--b__c"), "a-b-c");
}
#[test]
fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
let inputs = vec![secret(
"databasePassword",
vec![StackInputProvider::Deployer],
)];
let stored = HashMap::from([(
"databasePassword".to_string(),
serde_json::json!("from-before"),
)]);
let slots = deployer_secret_slots(&inputs, &stored, Some(&[]), &[], Platform::Aws);
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].vault_key, "database-password");
assert!(slots[0].has_stored_value);
}
#[test]
fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
let inputs = vec![secret(
"databasePassword",
vec![StackInputProvider::Developer, StackInputProvider::Deployer],
)];
let with_developer_value =
HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
assert!(deployer_secret_slots(
&inputs,
&with_developer_value,
Some(&[]),
&[],
Platform::Aws
)
.is_empty());
assert_eq!(
deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Aws).len(),
1
);
}
#[test]
fn developer_secrets_and_other_platforms_are_not_slots() {
let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
aws_only.platforms = Some(vec![Platform::Aws]);
let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
assert!(
deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Gcp)
.is_empty()
);
}
#[test]
fn locations_name_the_same_secret_the_vault_reads() {
let context = DeployerSecretLocationContext {
aws_region: Some("us-east-1".to_string()),
gcp_project_id: Some("acme-prod".to_string()),
deployment_name: Some("default".to_string()),
..Default::default()
};
let key = "database-password";
let aws = deployer_secret_location(
&VaultBinding::parameter_store("stack-deployer-secrets"),
key,
&context,
)
.unwrap();
assert_eq!(aws.name, "stack-deployer-secrets-database-password");
assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
assert!(aws.cli_command.contains("--type SecureString"));
assert_eq!(
aws.delete_command.as_deref(),
Some(
"aws ssm delete-parameter --region us-east-1 --name 'stack-deployer-secrets-database-password'"
)
);
let gcp = deployer_secret_location(
&VaultBinding::secret_manager("stack-deployer-secrets"),
key,
&context,
)
.unwrap();
assert_eq!(gcp.name, "stack-deployer-secrets-database-password");
assert_eq!(gcp.vault_name, None);
assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
assert_eq!(
gcp.delete_command.as_deref(),
Some(
"gcloud secrets delete stack-deployer-secrets-database-password --project acme-prod --quiet"
)
);
let azure =
deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
.unwrap();
assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
assert_eq!(
azure.delete_command,
Some(format!(
"az keyvault secret delete --vault-name stacksecrets7f3a --name {}",
azure.name
))
);
let kubernetes = deployer_secret_location(
&VaultBinding::kubernetes_secret("apps", "Stack-Deployer-Secrets"),
key,
&context,
)
.unwrap();
assert_eq!(kubernetes.name, "stack-deployer-secrets-database-password");
assert!(kubernetes.cli_command.contains("--namespace apps"));
assert_eq!(
kubernetes.delete_command.as_deref(),
Some("kubectl delete secret stack-deployer-secrets-database-password --namespace apps")
);
let local = deployer_secret_location(
&VaultBinding::local("deployer-secrets", "/tmp/x"),
key,
&context,
)
.unwrap();
assert_eq!(
local.delete_command.as_deref(),
Some("alien dev vault --deployment default delete deployer-secrets database-password")
);
}
#[test]
fn a_template_expression_has_no_location() {
let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
});
assert!(
deployer_secret_location(&binding, "x", &DeployerSecretLocationContext::default())
.is_err()
);
}
#[test]
fn only_required_unfilled_slots_block_start() {
let location = deployer_secret_location(
&VaultBinding::local("deployer-secrets", "/tmp/x"),
"x",
&DeployerSecretLocationContext::default(),
)
.unwrap();
let mut report = DeployerSecretReport {
input_id: "x".to_string(),
label: "Database password".to_string(),
required: true,
status: DeployerSecretStatus::Missing,
message: None,
version: None,
location,
};
assert!(report.blocks_start());
assert_eq!(report.summary(), "missing: Database password");
report.required = false;
assert!(!report.blocks_start());
report.required = true;
report.status = DeployerSecretStatus::Present;
assert!(!report.blocks_start());
}
fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
DeployerSecretReport {
input_id: input_id.to_string(),
label: "Database password".to_string(),
required: true,
status,
message: None,
version: None,
location: DeployerSecretLocation {
commands: None,
store: DeployerSecretStore::AwsParameterStore,
name: "stack-deployer-secrets-database-password".to_string(),
vault_name: None,
console_url: None,
cli_command: String::new(),
delete_command: None,
},
}
}
#[test]
fn a_slot_is_read_from_the_vault_once_it_is_reported() {
let inputs = vec![secret(
"databasePassword",
vec![StackInputProvider::Deployer],
)];
let no_values = HashMap::new();
assert!(deployer_secret_environment(
&inputs,
&no_values,
Some(&[]),
&[],
Platform::Aws,
&[]
)
.is_empty());
let env = deployer_secret_environment(
&inputs,
&no_values,
Some(&[]),
&[],
Platform::Aws,
&[report("databasePassword", DeployerSecretStatus::Missing)],
);
assert_eq!(env.len(), 1);
let (variable, targets) = &env[0];
assert_eq!(variable.name, "DATABASE_PASSWORD");
assert_eq!(variable.vault_key, "database-password");
assert_eq!(
variable.secret_name,
"stack-deployer-secrets-database-password"
);
assert!(variable.required);
assert_eq!(variable.vault_name, None);
assert_eq!(targets, &None);
}
#[test]
fn a_key_vault_slot_names_its_vault_for_the_workload() {
let inputs = vec![secret(
"databasePassword",
vec![StackInputProvider::Deployer],
)];
let location = deployer_secret_location(
&VaultBinding::key_vault("stacksecrets7f3a"),
"database-password",
&DeployerSecretLocationContext::default(),
)
.unwrap();
let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
azure_report.location = location;
let env = deployer_secret_environment(
&inputs,
&HashMap::new(),
Some(&[]),
&[],
Platform::Azure,
&[azure_report],
);
assert_eq!(env.len(), 1);
assert_eq!(env[0].0.secret_name, "database-password");
assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
}
#[test]
fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
let inputs = vec![secret(
"databasePassword",
vec![StackInputProvider::Deployer],
)];
let stored = HashMap::from([(
"databasePassword".to_string(),
serde_json::json!("from-before"),
)]);
assert!(deployer_secret_environment(
&inputs,
&stored,
Some(&[]),
&[],
Platform::Aws,
&[report("databasePassword", DeployerSecretStatus::Missing)],
)
.is_empty());
assert!(
deployer_secret_environment(
&inputs,
&stored,
Some(&[]),
&[],
Platform::Aws,
&[report("databasePassword", DeployerSecretStatus::Present)],
)
.is_empty(),
"a filled slot is not read while the stored value has no vault read grant"
);
assert_eq!(
deployer_secret_environment(
&inputs,
&HashMap::new(),
Some(&[]),
&[],
Platform::Aws,
&[report("databasePassword", DeployerSecretStatus::Present)],
)
.len(),
1
);
}
#[test]
fn stored_secret_ids_classify_presence_without_input_values() {
for required in [true, false] {
let mut input = secret(
"apiKey",
vec![StackInputProvider::Developer, StackInputProvider::Deployer],
);
input.required = required;
let inputs = vec![input];
let values = HashMap::new();
let present = vec!["apiKey".to_string()];
assert!(
deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws)
.is_empty()
);
for ids in [vec![], vec!["removedInput".to_string()]] {
let slots = deployer_secret_slots(&inputs, &values, Some(&ids), &[], Platform::Aws);
assert_eq!(slots.len(), 1);
assert!(!slots[0].has_stored_value);
assert_eq!(slots[0].input.required, required);
}
let null_value = HashMap::from([("apiKey".to_string(), serde_json::Value::Null)]);
assert!(deployer_secret_slots(
&inputs,
&null_value,
Some(&present),
&[],
Platform::Aws
)
.is_empty());
assert_eq!(
deployer_secret_slots(&inputs, &null_value, Some(&[]), &[], Platform::Aws).len(),
1
);
for status in [DeployerSecretStatus::Missing, DeployerSecretStatus::Present] {
assert!(deployer_secret_environment(
&inputs,
&values,
Some(&present),
&[],
Platform::Aws,
&[report("apiKey", status)]
)
.is_empty());
}
assert!(
values.is_empty(),
"presence must not manufacture an input value"
);
}
}
#[test]
fn stored_secret_ids_keep_deployer_only_slots_on_stored_delivery() {
let inputs = vec![secret("apiKey", vec![StackInputProvider::Deployer])];
let values = HashMap::new();
let present = vec!["apiKey".to_string()];
let slots = deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws);
assert_eq!(slots.len(), 1);
assert!(slots[0].has_stored_value);
assert!(deployer_secret_environment(
&inputs,
&values,
Some(&present),
&[],
Platform::Aws,
&[report("apiKey", DeployerSecretStatus::Missing)]
)
.is_empty());
let environment = deployer_secret_environment(
&inputs,
&values,
Some(&present),
&[],
Platform::Aws,
&[report("apiKey", DeployerSecretStatus::Present)],
);
assert!(
environment.is_empty(),
"a filled native slot cannot replace stored delivery without its read grant"
);
}
#[test]
fn stored_secret_ids_do_not_change_platform_or_kind_classification() {
let mut input = secret("apiKey", vec![StackInputProvider::Deployer]);
input.platforms = Some(vec![Platform::Aws]);
let inputs = vec![input.clone()];
let present = vec!["apiKey".to_string()];
assert!(deployer_secret_slots(
&inputs,
&HashMap::new(),
Some(&present),
&[],
Platform::Gcp
)
.is_empty());
input.kind = StackInputKind::String;
assert!(deployer_secret_slots(
&[input],
&HashMap::new(),
Some(&present),
&[],
Platform::Aws
)
.is_empty());
}
}