1use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18 vault_naming, BindingValue, EnvironmentVariable, EnvironmentVariableType, Platform,
19 StackInputDefinition, StackInputEnvironmentVariableType, StackInputKind, StackInputProvider,
20 VaultBinding,
21};
22
23pub const DEPLOYER_SECRET_KEY_PREFIX: &str = "input-";
26
27pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
29
30pub fn deployer_secret_vault_key(input_id: &str) -> String {
36 let mut key = String::from(DEPLOYER_SECRET_KEY_PREFIX);
37 let mut previous_lower_or_digit = false;
38 let mut pending_separator = false;
39 for character in input_id.chars() {
40 if character.is_ascii_alphanumeric() {
41 let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
42 if (pending_separator || starts_word) && !key.ends_with('-') {
43 key.push('-');
44 }
45 key.push(character.to_ascii_lowercase());
46 previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
47 pending_separator = false;
48 } else {
49 pending_separator = true;
50 previous_lower_or_digit = false;
51 }
52 }
53 key.trim_end_matches('-').to_string()
54}
55
56pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
58 input.kind == StackInputKind::Secret
59 && input.provided_by.contains(&StackInputProvider::Deployer)
60}
61
62pub fn deployer_secret_value_refusal(name: &str) -> String {
65 format!(
66 "'{name}' is a deployer secret: its value goes into your own secret store and never \
67 through Alien. Do not pass it here; write it into the deployment's secrets vault \
68 instead (the deployment status shows the secret's name and the command that writes it)."
69 )
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DeployerSecretSlot<'a> {
75 pub input: &'a StackInputDefinition,
77 pub vault_key: String,
79 pub has_stored_value: bool,
82}
83
84pub fn deployer_secret_slots<'a>(
94 inputs: &'a [StackInputDefinition],
95 values: &HashMap<String, serde_json::Value>,
96 stored_secret_input_ids: Option<&[String]>,
97 environment: &[EnvironmentVariable],
98 platform: Platform,
99) -> Vec<DeployerSecretSlot<'a>> {
100 inputs
101 .iter()
102 .filter(|input| is_deployer_secret_input(input))
103 .filter(|input| {
104 input
105 .platforms
106 .as_ref()
107 .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
108 })
109 .filter_map(|input| {
110 let has_stored_value = stored_secret_input_ids
111 .is_some_and(|ids| ids.contains(&input.id))
112 || values.get(&input.id).is_some_and(|value| !value.is_null());
113 let developer_value = input.provided_by.contains(&StackInputProvider::Developer)
114 && (has_stored_value
115 || (stored_secret_input_ids.is_none()
116 && legacy_secret_delivery_complete(input, environment)));
117 (!developer_value).then(|| DeployerSecretSlot {
118 input,
119 vault_key: deployer_secret_vault_key(&input.id),
120 has_stored_value,
121 })
122 })
123 .collect()
124}
125
126fn legacy_secret_delivery_complete(
129 input: &StackInputDefinition,
130 environment: &[EnvironmentVariable],
131) -> bool {
132 !input.env.is_empty()
133 && input.env.iter().all(|mapping| {
134 let mut matches = environment
135 .iter()
136 .filter(|variable| variable.name == mapping.name);
137 let Some(variable) = matches.next() else {
138 return false;
139 };
140 if matches.next().is_some() {
142 return false;
143 }
144 !mapping.name.is_empty()
145 && mapping
146 .target_resources
147 .as_ref()
148 .is_none_or(|targets| !targets.is_empty())
149 && !matches!(
150 mapping.var_type,
151 Some(StackInputEnvironmentVariableType::Plain)
152 )
153 && variable.var_type == EnvironmentVariableType::Secret
154 && match (&mapping.target_resources, &variable.target_resources) {
155 (_, None) => true,
156 (None, Some(delivered)) => delivered.iter().any(|pattern| pattern == "*"),
157 (Some(required), Some(delivered)) => {
158 !required.is_empty()
159 && required.iter().all(|target| {
160 delivered.iter().any(|pattern| {
161 pattern == target
162 || pattern
163 .strip_suffix('*')
164 .is_some_and(|prefix| target.starts_with(prefix))
165 })
166 })
167 }
168 }
169 })
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175#[serde(rename_all = "kebab-case")]
176pub enum DeployerSecretStore {
177 AwsParameterStore,
179 GcpSecretManager,
181 AzureKeyVault,
183 KubernetesSecret,
185 LocalVault,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "camelCase")]
193pub struct DeployerSecretLocation {
194 pub store: DeployerSecretStore,
196 pub name: String,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub vault_name: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub console_url: Option<String>,
205 pub cli_command: String,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub delete_command: Option<String>,
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Eq)]
215pub struct DeployerSecretLocationContext {
216 pub aws_region: Option<String>,
218 pub gcp_project_id: Option<String>,
220 pub azure_subscription_id: Option<String>,
222 pub azure_resource_group: Option<String>,
224 pub deployment_name: Option<String>,
226}
227
228pub fn deployer_secret_location(
231 binding: &VaultBinding,
232 vault_key: &str,
233 context: &DeployerSecretLocationContext,
234) -> crate::Result<DeployerSecretLocation> {
235 let placeholder = DEPLOYER_SECRET_VALUE_PLACEHOLDER;
236 let location = match binding {
237 VaultBinding::ParameterStore(binding) => {
238 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
239 let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
240 let region = context.aws_region.as_deref();
241 let region_flag = region
242 .map(|region| format!(" --region {region}"))
243 .unwrap_or_default();
244 DeployerSecretLocation {
245 store: DeployerSecretStore::AwsParameterStore,
246 vault_name: None,
247 console_url: region.map(|region| {
248 format!(
249 "https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
250 )
251 }),
252 cli_command: format!(
253 "aws ssm put-parameter{region_flag} --name '{name}' --type SecureString --overwrite --value '{placeholder}'"
254 ),
255 delete_command: Some(format!(
256 "aws ssm delete-parameter{region_flag} --name '{name}'"
257 )),
258 name,
259 }
260 }
261 VaultBinding::SecretManager(binding) => {
262 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
263 let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
264 let project = context.gcp_project_id.as_deref();
265 let project_flag = project
266 .map(|project| format!(" --project {project}"))
267 .unwrap_or_default();
268 DeployerSecretLocation {
269 store: DeployerSecretStore::GcpSecretManager,
270 vault_name: None,
271 console_url: project.map(|project| {
272 format!(
273 "https://console.cloud.google.com/security/secret-manager/create?project={project}"
274 )
275 }),
276 cli_command: format!(
279 "(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=-"
280 ),
281 delete_command: Some(format!(
282 "gcloud secrets delete {name}{project_flag} --quiet"
283 )),
284 name,
285 }
286 }
287 VaultBinding::KeyVault(binding) => {
288 let vault_name = concrete(&binding.vault_name, "vaultName")?;
289 let name = vault_naming::key_vault_secret_name(vault_key);
290 DeployerSecretLocation {
291 store: DeployerSecretStore::AzureKeyVault,
292 vault_name: Some(vault_name.clone()),
293 console_url: match (
294 context.azure_subscription_id.as_deref(),
295 context.azure_resource_group.as_deref(),
296 ) {
297 (Some(subscription), Some(resource_group)) => Some(format!(
298 "https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
299 )),
300 _ => None,
301 },
302 cli_command: format!(
303 "az keyvault secret set --vault-name {vault_name} --name {name} --value '{placeholder}'"
304 ),
305 delete_command: Some(format!(
306 "az keyvault secret delete --vault-name {vault_name} --name {name}"
307 )),
308 name,
309 }
310 }
311 VaultBinding::KubernetesSecret(binding) => {
312 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
313 let namespace = concrete(&binding.namespace, "namespace")?;
314 let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
315 DeployerSecretLocation {
316 store: DeployerSecretStore::KubernetesSecret,
317 vault_name: None,
318 console_url: None,
319 cli_command: format!(
320 "kubectl create secret generic {name} --namespace {namespace} --from-literal={}='{placeholder}'",
321 vault_naming::KUBERNETES_SECRET_VALUE_KEY
322 ),
323 delete_command: Some(format!(
324 "kubectl delete secret {name} --namespace {namespace}"
325 )),
326 name,
327 }
328 }
329 VaultBinding::Local(binding) => {
330 let deployment_flag = context
331 .deployment_name
332 .as_deref()
333 .map(|name| format!(" --deployment {name}"))
334 .unwrap_or_default();
335 DeployerSecretLocation {
336 store: DeployerSecretStore::LocalVault,
337 vault_name: None,
338 console_url: None,
339 cli_command: format!(
340 "alien dev vault{deployment_flag} set {} {vault_key} '{placeholder}'",
341 binding.vault_name
342 ),
343 delete_command: Some(format!(
344 "alien dev vault{deployment_flag} delete {} {vault_key}",
345 binding.vault_name
346 )),
347 name: vault_key.to_string(),
348 }
349 }
350 };
351 Ok(location)
352}
353
354fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
355 value.clone().into_value(crate::SECRETS_VAULT_ID, field)
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
362#[serde(rename_all = "kebab-case")]
363pub enum DeployerSecretStatus {
364 Present,
366 Missing,
368 Invalid,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
376#[serde(rename_all = "camelCase")]
377pub struct DeployerSecretReport {
378 pub input_id: String,
380 pub label: String,
382 pub required: bool,
384 pub status: DeployerSecretStatus,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub message: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub version: Option<String>,
393 pub location: DeployerSecretLocation,
395}
396
397impl DeployerSecretReport {
398 pub fn blocks_start(&self) -> bool {
400 self.required && self.status != DeployerSecretStatus::Present
401 }
402
403 pub fn summary(&self) -> String {
406 match self.status {
407 DeployerSecretStatus::Present => format!("present: {}", self.label),
408 DeployerSecretStatus::Missing => format!("missing: {}", self.label),
409 DeployerSecretStatus::Invalid => match &self.message {
410 Some(message) => format!("invalid: {} ({message})", self.label),
411 None => format!("invalid: {}", self.label),
412 },
413 }
414 }
415}
416
417pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct DeployerSecretEnv {
428 pub name: String,
430 pub vault_key: String,
432 pub secret_name: String,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub vault_name: Option<String>,
438 pub label: String,
440 pub required: bool,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub version: Option<String>,
447}
448
449pub fn deployer_secret_environment(
458 inputs: &[StackInputDefinition],
459 values: &HashMap<String, serde_json::Value>,
460 stored_secret_input_ids: Option<&[String]>,
461 environment: &[EnvironmentVariable],
462 platform: Platform,
463 reports: &[DeployerSecretReport],
464) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
465 deployer_secret_slots(
466 inputs,
467 values,
468 stored_secret_input_ids,
469 environment,
470 platform,
471 )
472 .into_iter()
473 .filter_map(|slot| {
474 let report = reports
475 .iter()
476 .find(|report| report.input_id == slot.input.id)?;
477 (!values
480 .get(&slot.input.id)
481 .is_some_and(|value| !value.is_null())
482 && (!slot.has_stored_value || report.status == DeployerSecretStatus::Present))
483 .then_some((slot, report))
484 })
485 .flat_map(|(slot, report)| {
486 slot.input.env.iter().map(move |mapping| {
487 (
488 DeployerSecretEnv {
489 name: mapping.name.clone(),
490 vault_key: slot.vault_key.clone(),
491 secret_name: report.location.name.clone(),
492 vault_name: report.location.vault_name.clone(),
493 label: slot.input.label.clone(),
494 required: slot.input.required,
495 version: report.version.clone(),
496 },
497 mapping.target_resources.clone(),
498 )
499 })
500 })
501 .collect()
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::StackInputEnvironmentMapping;
508
509 fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
510 StackInputDefinition {
511 id: id.to_string(),
512 kind: StackInputKind::Secret,
513 provided_by,
514 required: true,
515 label: "Database password".to_string(),
516 description: String::new(),
517 placeholder: None,
518 default: None,
519 platforms: None,
520 validation: None,
521 generate: None,
522 env: vec![StackInputEnvironmentMapping {
523 name: "DATABASE_PASSWORD".to_string(),
524 target_resources: None,
525 var_type: None,
526 }],
527 }
528 }
529
530 #[test]
531 fn legacy_omitted_presence_uses_only_complete_scoped_secret_delivery() {
532 let base = serde_json::json!({
533 "environmentVariables": {
534 "variables": [{"name": "DATABASE_PASSWORD", "value": "fixture-value", "type": "secret"}],
535 "hash": "fixture", "createdAt": "2026-01-01T00:00:00Z"
536 }
537 });
538 let legacy: crate::DeploymentConfig = serde_json::from_value(base.clone()).unwrap();
539 assert_eq!(legacy.stored_secret_input_ids, None);
540 let input = secret(
541 "databasePassword",
542 vec![StackInputProvider::Developer, StackInputProvider::Deployer],
543 );
544 for case in [
545 "legacy",
546 "null",
547 "empty",
548 "unrelated",
549 "missing",
550 "plain",
551 "wrong-name",
552 "partial",
553 "narrow",
554 "matching-scope",
555 "broader-scope",
556 "deployer-only",
557 "no-mappings",
558 "plain-mapping",
559 "duplicate-plain",
560 "duplicate-scoped-secret",
561 ] {
562 let mut wire = base.clone();
563 let mut input = input.clone();
564 match case {
565 "null" => wire["storedSecretInputIds"] = serde_json::Value::Null,
566 "empty" => wire["storedSecretInputIds"] = serde_json::json!([]),
567 "unrelated" => wire["storedSecretInputIds"] = serde_json::json!(["other"]),
568 "missing" => wire["environmentVariables"]["variables"] = serde_json::json!([]),
569 "plain" => {
570 wire["environmentVariables"]["variables"][0]["type"] =
571 serde_json::json!("plain")
572 }
573 "duplicate-plain" | "duplicate-scoped-secret" => {
574 let mut duplicate = wire["environmentVariables"]["variables"][0].clone();
575 if case == "duplicate-plain" {
576 duplicate["type"] = serde_json::json!("plain");
577 } else {
578 input.env[0].target_resources = Some(vec!["app".to_string()]);
579 wire["environmentVariables"]["variables"][0]["targetResources"] =
580 serde_json::json!(["app"]);
581 duplicate["targetResources"] = serde_json::json!(["other"]);
582 }
583 wire["environmentVariables"]["variables"]
584 .as_array_mut()
585 .unwrap()
586 .push(duplicate);
587 }
588 "wrong-name" => {
589 wire["environmentVariables"]["variables"][0]["name"] =
590 serde_json::json!("OTHER")
591 }
592 "partial" => {
593 let mut mapping = input.env[0].clone();
594 mapping.name = "SECOND".to_string();
595 input.env.push(mapping);
596 }
597 "narrow" => {
598 wire["environmentVariables"]["variables"][0]["targetResources"] =
599 serde_json::json!(["app"])
600 }
601 "matching-scope" => {
602 input.env[0].target_resources = Some(vec!["app*".to_string()]);
603 wire["environmentVariables"]["variables"][0]["targetResources"] =
604 serde_json::json!(["app*"]);
605 }
606 "broader-scope" => input.env[0].target_resources = Some(vec!["app".to_string()]),
607 "deployer-only" => input.provided_by = vec![StackInputProvider::Deployer],
608 "no-mappings" => input.env.clear(),
609 "plain-mapping" => {
610 input.env[0].var_type = Some(StackInputEnvironmentVariableType::Plain)
611 }
612 _ => {}
613 }
614 let config: crate::DeploymentConfig = serde_json::from_value(wire).unwrap();
615 let inputs = [input];
616 let delivered = matches!(case, "legacy" | "null" | "matching-scope" | "broader-scope");
617 let slots = deployer_secret_slots(
618 &inputs,
619 &config.input_values,
620 config.stored_secret_input_ids.as_deref(),
621 &config.environment_variables.variables,
622 Platform::Aws,
623 );
624 assert_eq!(slots.is_empty(), delivered, "{case}");
625 let reports = [report("databasePassword", DeployerSecretStatus::Present)];
626 let pointers = deployer_secret_environment(
627 &inputs,
628 &config.input_values,
629 config.stored_secret_input_ids.as_deref(),
630 &config.environment_variables.variables,
631 Platform::Aws,
632 &reports,
633 );
634 assert_eq!(
635 pointers.len(),
636 if delivered || case == "no-mappings" {
637 0
638 } else {
639 inputs[0].env.len()
640 },
641 "{case}"
642 );
643 assert!(config.input_values.is_empty());
644 assert!(is_deployer_secret_input(&inputs[0]));
645 }
646 }
647
648 #[test]
649 fn vault_keys_are_kebab_case_and_backend_safe() {
650 assert_eq!(
651 deployer_secret_vault_key("databasePassword"),
652 "input-database-password"
653 );
654 assert_eq!(deployer_secret_vault_key("api_key"), "input-api-key");
655 assert_eq!(
656 deployer_secret_vault_key("OAuth2Token"),
657 "input-oauth2-token"
658 );
659 assert_eq!(deployer_secret_vault_key("a--b__c"), "input-a-b-c");
660 }
661
662 #[test]
663 fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
664 let inputs = vec![secret(
665 "databasePassword",
666 vec![StackInputProvider::Deployer],
667 )];
668 let stored = HashMap::from([(
669 "databasePassword".to_string(),
670 serde_json::json!("from-before"),
671 )]);
672
673 let slots = deployer_secret_slots(&inputs, &stored, Some(&[]), &[], Platform::Aws);
674 assert_eq!(slots.len(), 1);
675 assert_eq!(slots[0].vault_key, "input-database-password");
676 assert!(slots[0].has_stored_value);
677 }
678
679 #[test]
680 fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
681 let inputs = vec![secret(
682 "databasePassword",
683 vec![StackInputProvider::Developer, StackInputProvider::Deployer],
684 )];
685 let with_developer_value =
686 HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
687
688 assert!(deployer_secret_slots(
689 &inputs,
690 &with_developer_value,
691 Some(&[]),
692 &[],
693 Platform::Aws
694 )
695 .is_empty());
696 assert_eq!(
697 deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Aws).len(),
698 1
699 );
700 }
701
702 #[test]
703 fn developer_secrets_and_other_platforms_are_not_slots() {
704 let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
705 aws_only.platforms = Some(vec![Platform::Aws]);
706 let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
707
708 assert!(
709 deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Gcp)
710 .is_empty()
711 );
712 }
713
714 #[test]
715 fn locations_name_the_same_secret_the_vault_reads() {
716 let context = DeployerSecretLocationContext {
717 aws_region: Some("us-east-1".to_string()),
718 gcp_project_id: Some("acme-prod".to_string()),
719 deployment_name: Some("default".to_string()),
720 ..Default::default()
721 };
722 let key = "input-database-password";
723
724 let aws = deployer_secret_location(
725 &VaultBinding::parameter_store("stack-secrets"),
726 key,
727 &context,
728 )
729 .unwrap();
730 assert_eq!(aws.name, "stack-secrets-input-database-password");
731 assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
732 assert!(aws.cli_command.contains("--type SecureString"));
733 assert!(aws.cli_command.contains("'<VALUE>'"));
734 assert_eq!(
735 aws.delete_command.as_deref(),
736 Some("aws ssm delete-parameter --region us-east-1 --name 'stack-secrets-input-database-password'")
737 );
738
739 let gcp = deployer_secret_location(
740 &VaultBinding::secret_manager("stack-secrets"),
741 key,
742 &context,
743 )
744 .unwrap();
745 assert_eq!(gcp.name, "stack-secrets-input-database-password");
746 assert_eq!(gcp.vault_name, None);
747 assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
748 assert_eq!(
749 gcp.delete_command.as_deref(),
750 Some("gcloud secrets delete stack-secrets-input-database-password --project acme-prod --quiet")
751 );
752
753 let azure =
756 deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
757 .unwrap();
758 assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
759 assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
760 assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
761 assert_eq!(
762 azure.delete_command,
763 Some(format!(
764 "az keyvault secret delete --vault-name stacksecrets7f3a --name {}",
765 azure.name
766 ))
767 );
768
769 let kubernetes = deployer_secret_location(
770 &VaultBinding::kubernetes_secret("apps", "Stack-Secrets"),
771 key,
772 &context,
773 )
774 .unwrap();
775 assert_eq!(kubernetes.name, "stack-secrets-input-database-password");
776 assert!(kubernetes.cli_command.contains("--namespace apps"));
777 assert_eq!(
778 kubernetes.delete_command.as_deref(),
779 Some("kubectl delete secret stack-secrets-input-database-password --namespace apps")
780 );
781
782 let local =
783 deployer_secret_location(&VaultBinding::local("secrets", "/tmp/x"), key, &context)
784 .unwrap();
785 assert_eq!(
786 local.cli_command,
787 "alien dev vault --deployment default set secrets input-database-password '<VALUE>'"
788 );
789 assert_eq!(
790 local.delete_command.as_deref(),
791 Some("alien dev vault --deployment default delete secrets input-database-password")
792 );
793 }
794
795 #[test]
796 fn a_template_expression_has_no_location() {
797 let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
798 vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
799 });
800 assert!(deployer_secret_location(
801 &binding,
802 "input-x",
803 &DeployerSecretLocationContext::default()
804 )
805 .is_err());
806 }
807
808 #[test]
809 fn only_required_unfilled_slots_block_start() {
810 let location = deployer_secret_location(
811 &VaultBinding::local("secrets", "/tmp/x"),
812 "input-x",
813 &DeployerSecretLocationContext::default(),
814 )
815 .unwrap();
816 let mut report = DeployerSecretReport {
817 input_id: "x".to_string(),
818 label: "Database password".to_string(),
819 required: true,
820 status: DeployerSecretStatus::Missing,
821 message: None,
822 version: None,
823 location,
824 };
825 assert!(report.blocks_start());
826 assert_eq!(report.summary(), "missing: Database password");
827
828 report.required = false;
829 assert!(!report.blocks_start());
830
831 report.required = true;
832 report.status = DeployerSecretStatus::Present;
833 assert!(!report.blocks_start());
834 }
835
836 fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
837 DeployerSecretReport {
838 input_id: input_id.to_string(),
839 label: "Database password".to_string(),
840 required: true,
841 status,
842 message: None,
843 version: None,
844 location: DeployerSecretLocation {
845 store: DeployerSecretStore::AwsParameterStore,
846 name: "stack-secrets-input-database-password".to_string(),
847 vault_name: None,
848 console_url: None,
849 cli_command: String::new(),
850 delete_command: None,
851 },
852 }
853 }
854
855 #[test]
856 fn a_slot_is_read_from_the_vault_once_it_is_reported() {
857 let inputs = vec![secret(
858 "databasePassword",
859 vec![StackInputProvider::Deployer],
860 )];
861 let no_values = HashMap::new();
862
863 assert!(deployer_secret_environment(
864 &inputs,
865 &no_values,
866 Some(&[]),
867 &[],
868 Platform::Aws,
869 &[]
870 )
871 .is_empty());
872
873 let env = deployer_secret_environment(
874 &inputs,
875 &no_values,
876 Some(&[]),
877 &[],
878 Platform::Aws,
879 &[report("databasePassword", DeployerSecretStatus::Missing)],
880 );
881 assert_eq!(env.len(), 1);
882 let (variable, targets) = &env[0];
883 assert_eq!(variable.name, "DATABASE_PASSWORD");
884 assert_eq!(variable.vault_key, "input-database-password");
885 assert_eq!(
886 variable.secret_name,
887 "stack-secrets-input-database-password"
888 );
889 assert!(variable.required);
890 assert_eq!(variable.vault_name, None);
891 assert_eq!(targets, &None);
892 }
893
894 #[test]
895 fn a_key_vault_slot_names_its_vault_for_the_workload() {
896 let inputs = vec![secret(
897 "databasePassword",
898 vec![StackInputProvider::Deployer],
899 )];
900 let location = deployer_secret_location(
901 &VaultBinding::key_vault("stacksecrets7f3a"),
902 "input-database-password",
903 &DeployerSecretLocationContext::default(),
904 )
905 .unwrap();
906 let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
907 azure_report.location = location;
908
909 let env = deployer_secret_environment(
910 &inputs,
911 &HashMap::new(),
912 Some(&[]),
913 &[],
914 Platform::Azure,
915 &[azure_report],
916 );
917
918 assert_eq!(env.len(), 1);
919 assert_eq!(env[0].0.secret_name, "input-database-password");
920 assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
921 }
922
923 #[test]
924 fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
925 let inputs = vec![secret(
926 "databasePassword",
927 vec![StackInputProvider::Deployer],
928 )];
929 let stored = HashMap::from([(
930 "databasePassword".to_string(),
931 serde_json::json!("from-before"),
932 )]);
933
934 assert!(deployer_secret_environment(
935 &inputs,
936 &stored,
937 Some(&[]),
938 &[],
939 Platform::Aws,
940 &[report("databasePassword", DeployerSecretStatus::Missing)],
941 )
942 .is_empty());
943 assert!(
944 deployer_secret_environment(
945 &inputs,
946 &stored,
947 Some(&[]),
948 &[],
949 Platform::Aws,
950 &[report("databasePassword", DeployerSecretStatus::Present)],
951 )
952 .is_empty(),
953 "a filled slot is not read while the stored value has no vault read grant"
954 );
955 assert_eq!(
956 deployer_secret_environment(
957 &inputs,
958 &HashMap::new(),
959 Some(&[]),
960 &[],
961 Platform::Aws,
962 &[report("databasePassword", DeployerSecretStatus::Present)],
963 )
964 .len(),
965 1
966 );
967 }
968 #[test]
969 fn stored_secret_ids_classify_presence_without_input_values() {
970 for required in [true, false] {
971 let mut input = secret(
972 "apiKey",
973 vec![StackInputProvider::Developer, StackInputProvider::Deployer],
974 );
975 input.required = required;
976 let inputs = vec![input];
977 let values = HashMap::new();
978 let present = vec!["apiKey".to_string()];
979 assert!(
980 deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws)
981 .is_empty()
982 );
983 for ids in [vec![], vec!["removedInput".to_string()]] {
984 let slots = deployer_secret_slots(&inputs, &values, Some(&ids), &[], Platform::Aws);
985 assert_eq!(slots.len(), 1);
986 assert!(!slots[0].has_stored_value);
987 assert_eq!(slots[0].input.required, required);
988 }
989 let null_value = HashMap::from([("apiKey".to_string(), serde_json::Value::Null)]);
990 assert!(deployer_secret_slots(
991 &inputs,
992 &null_value,
993 Some(&present),
994 &[],
995 Platform::Aws
996 )
997 .is_empty());
998 assert_eq!(
999 deployer_secret_slots(&inputs, &null_value, Some(&[]), &[], Platform::Aws).len(),
1000 1
1001 );
1002 for status in [DeployerSecretStatus::Missing, DeployerSecretStatus::Present] {
1003 assert!(deployer_secret_environment(
1004 &inputs,
1005 &values,
1006 Some(&present),
1007 &[],
1008 Platform::Aws,
1009 &[report("apiKey", status)]
1010 )
1011 .is_empty());
1012 }
1013 assert!(
1014 values.is_empty(),
1015 "presence must not manufacture an input value"
1016 );
1017 }
1018 }
1019
1020 #[test]
1021 fn stored_secret_ids_keep_deployer_only_slots_and_legacy_fallback() {
1022 let inputs = vec![secret("apiKey", vec![StackInputProvider::Deployer])];
1023 let values = HashMap::new();
1024 let present = vec!["apiKey".to_string()];
1025 let slots = deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws);
1026 assert_eq!(slots.len(), 1);
1027 assert!(slots[0].has_stored_value);
1028 assert!(deployer_secret_environment(
1029 &inputs,
1030 &values,
1031 Some(&present),
1032 &[],
1033 Platform::Aws,
1034 &[report("apiKey", DeployerSecretStatus::Missing)]
1035 )
1036 .is_empty());
1037 let environment = deployer_secret_environment(
1038 &inputs,
1039 &values,
1040 Some(&present),
1041 &[],
1042 Platform::Aws,
1043 &[report("apiKey", DeployerSecretStatus::Present)],
1044 );
1045 assert_eq!(environment.len(), 1);
1046 assert_eq!(environment[0].0.vault_key, "input-api-key");
1047 }
1048
1049 #[test]
1050 fn stored_secret_ids_do_not_change_platform_or_kind_classification() {
1051 let mut input = secret("apiKey", vec![StackInputProvider::Deployer]);
1052 input.platforms = Some(vec![Platform::Aws]);
1053 let inputs = vec![input.clone()];
1054 let present = vec!["apiKey".to_string()];
1055 assert!(deployer_secret_slots(
1056 &inputs,
1057 &HashMap::new(),
1058 Some(&present),
1059 &[],
1060 Platform::Gcp
1061 )
1062 .is_empty());
1063 input.kind = StackInputKind::String;
1064 assert!(deployer_secret_slots(
1065 &[input],
1066 &HashMap::new(),
1067 Some(&present),
1068 &[],
1069 Platform::Aws
1070 )
1071 .is_empty());
1072 }
1073}