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_KEY_PREFIX: &str = "input-";
pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
pub fn deployer_secret_vault_key(input_id: &str) -> String {
let mut key = String::from(DEPLOYER_SECRET_KEY_PREFIX);
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.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 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 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 delete_command: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeployerSecretLocationContext {
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 placeholder = DEPLOYER_SECRET_VALUE_PLACEHOLDER;
let 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 {region}"))
.unwrap_or_default();
DeployerSecretLocation {
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 '{name}' --type SecureString --overwrite --value '{placeholder}'"
),
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 {project}"))
.unwrap_or_default();
DeployerSecretLocation {
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 describe {name}{project_flag} >/dev/null 2>&1 || gcloud secrets create {name}{project_flag} --replication-policy=automatic) && printf '%s' '{placeholder}' | gcloud secrets versions add {name}{project_flag} --data-file=-"
),
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 {
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 {vault_name} --name {name} --value '{placeholder}'"
),
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 {
store: DeployerSecretStore::KubernetesSecret,
vault_name: None,
console_url: None,
cli_command: format!(
"kubectl create secret generic {name} --namespace {namespace} --from-literal={}='{placeholder}'",
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 {name}"))
.unwrap_or_default();
DeployerSecretLocation {
store: DeployerSecretStore::LocalVault,
vault_name: None,
console_url: None,
cli_command: format!(
"alien dev vault{deployment_flag} set {} {vault_key} '{placeholder}'",
binding.vault_name
),
delete_command: Some(format!(
"alien dev vault{deployment_flag} delete {} {vault_key}",
binding.vault_name
)),
name: vault_key.to_string(),
}
}
};
Ok(location)
}
fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
value.clone().into_value(crate::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_map(|slot| {
let report = reports
.iter()
.find(|report| report.input_id == slot.input.id)?;
(!values
.get(&slot.input.id)
.is_some_and(|value| !value.is_null())
&& (!slot.has_stored_value || report.status == DeployerSecretStatus::Present))
.then_some((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"),
"input-database-password"
);
assert_eq!(deployer_secret_vault_key("api_key"), "input-api-key");
assert_eq!(
deployer_secret_vault_key("OAuth2Token"),
"input-oauth2-token"
);
assert_eq!(deployer_secret_vault_key("a--b__c"), "input-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, "input-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 = "input-database-password";
let aws = deployer_secret_location(
&VaultBinding::parameter_store("stack-secrets"),
key,
&context,
)
.unwrap();
assert_eq!(aws.name, "stack-secrets-input-database-password");
assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
assert!(aws.cli_command.contains("--type SecureString"));
assert!(aws.cli_command.contains("'<VALUE>'"));
assert_eq!(
aws.delete_command.as_deref(),
Some("aws ssm delete-parameter --region us-east-1 --name 'stack-secrets-input-database-password'")
);
let gcp = deployer_secret_location(
&VaultBinding::secret_manager("stack-secrets"),
key,
&context,
)
.unwrap();
assert_eq!(gcp.name, "stack-secrets-input-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-secrets-input-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-Secrets"),
key,
&context,
)
.unwrap();
assert_eq!(kubernetes.name, "stack-secrets-input-database-password");
assert!(kubernetes.cli_command.contains("--namespace apps"));
assert_eq!(
kubernetes.delete_command.as_deref(),
Some("kubectl delete secret stack-secrets-input-database-password --namespace apps")
);
let local =
deployer_secret_location(&VaultBinding::local("secrets", "/tmp/x"), key, &context)
.unwrap();
assert_eq!(
local.cli_command,
"alien dev vault --deployment default set secrets input-database-password '<VALUE>'"
);
assert_eq!(
local.delete_command.as_deref(),
Some("alien dev vault --deployment default delete secrets input-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,
"input-x",
&DeployerSecretLocationContext::default()
)
.is_err());
}
#[test]
fn only_required_unfilled_slots_block_start() {
let location = deployer_secret_location(
&VaultBinding::local("secrets", "/tmp/x"),
"input-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 {
store: DeployerSecretStore::AwsParameterStore,
name: "stack-secrets-input-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, "input-database-password");
assert_eq!(
variable.secret_name,
"stack-secrets-input-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"),
"input-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, "input-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_and_legacy_fallback() {
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_eq!(environment.len(), 1);
assert_eq!(environment[0].0.vault_key, "input-api-key");
}
#[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());
}
}