Skip to main content

alien_core/
deployer_secrets.rs

1//! Deployer secrets that live only in the customer's own secret store.
2//!
3//! A secret stack input the deployer provides is *vault-native*: the deployer
4//! writes the value into their cloud's secret store, in the stack's `secrets`
5//! vault, under a key Alien derives from the input id. Alien tells them where
6//! (a console link and a CLI command with a placeholder), checks whether the
7//! slot is filled using metadata-only calls, and the workload reads the value
8//! at start. Setup paths never carry the value.
9//!
10//! A secret input that the developer may also provide keeps today's path when
11//! the developer gave a value; otherwise it is vault-native too.
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    vault_naming, BindingValue, EnvironmentVariablesSnapshot, Platform, StackInputDefinition,
19    StackInputKind, StackInputProvider, VaultBinding,
20};
21
22/// Prefix of every vault key that holds a deployer secret, so these keys
23/// never collide with the env-var-named keys Alien syncs itself.
24pub const DEPLOYER_SECRET_KEY_PREFIX: &str = "input-";
25
26/// Placeholder for the secret value in the CLI commands Alien shows.
27pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
28
29/// The `secrets` vault key for a deployer secret input: `input-` plus the input
30/// id in lowercase kebab case (`databasePassword` → `input-database-password`).
31///
32/// Only `[a-z0-9-]` survive, so the key is valid unchanged in every backend
33/// (SSM, Secret Manager, Key Vault and Kubernetes object names).
34pub fn deployer_secret_vault_key(input_id: &str) -> String {
35    let mut key = String::from(DEPLOYER_SECRET_KEY_PREFIX);
36    let mut previous_lower_or_digit = false;
37    let mut pending_separator = false;
38    for character in input_id.chars() {
39        if character.is_ascii_alphanumeric() {
40            let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
41            if (pending_separator || starts_word) && !key.ends_with('-') {
42                key.push('-');
43            }
44            key.push(character.to_ascii_lowercase());
45            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
46            pending_separator = false;
47        } else {
48            pending_separator = true;
49            previous_lower_or_digit = false;
50        }
51    }
52    key.trim_end_matches('-').to_string()
53}
54
55/// Whether the deployer may provide this secret input.
56pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
57    input.kind == StackInputKind::Secret
58        && input.provided_by.contains(&StackInputProvider::Deployer)
59}
60
61/// Why a setup path refuses a value for the deployer secret `name` (its label
62/// or id), with what to do instead.
63pub fn deployer_secret_value_refusal(name: &str) -> String {
64    format!(
65        "'{name}' is a deployer secret: its value goes into your own secret store and never \
66         through Alien. Do not pass it here; write it into the deployment's secrets vault \
67         instead (the deployment status shows the secret's name and the command that writes it)."
68    )
69}
70
71/// A deployer secret input whose value lives in the customer's secret store.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DeployerSecretSlot<'a> {
74    /// The stack input.
75    pub input: &'a StackInputDefinition,
76    /// Key of the value in the `secrets` vault.
77    pub vault_key: String,
78    /// The deployment still stores a value for this input from before slots
79    /// were vault-native. It is used until the control plane drops it.
80    pub has_stored_value: bool,
81}
82
83/// The deployment's input values as the deployer secret helpers must see them.
84///
85/// A control plane never puts a stored secret's value in `input_values`: it
86/// delivers it through the environment variables the input maps. Such an
87/// input counts as stored when every variable it maps is in `environment`, so
88/// a developer value is not mistaken for an empty deployer slot. Input and
89/// user variable names are unique, so a mapped name can only come from the
90/// input. The added entries mark presence only and carry no value.
91pub fn stored_input_values(
92    inputs: &[StackInputDefinition],
93    values: &HashMap<String, serde_json::Value>,
94    environment: &EnvironmentVariablesSnapshot,
95) -> HashMap<String, serde_json::Value> {
96    let mut stored = values.clone();
97    for input in inputs {
98        if input.kind != StackInputKind::Secret
99            || input.env.is_empty()
100            || stored.get(&input.id).is_some_and(|value| !value.is_null())
101        {
102            continue;
103        }
104        let delivered = input.env.iter().all(|mapping| {
105            environment
106                .variables
107                .iter()
108                .any(|variable| variable.name == mapping.name)
109        });
110        if delivered {
111            stored.insert(input.id.clone(), serde_json::Value::Bool(true));
112        }
113    }
114    stored
115}
116
117/// The vault-native deployer secret slots of a deployment on `platform`.
118///
119/// `values` are the deployment's stored input values. A secret input the
120/// developer may also provide is a slot only when it has no stored value: a
121/// developer value keeps today's path.
122pub fn deployer_secret_slots<'a>(
123    inputs: &'a [StackInputDefinition],
124    values: &HashMap<String, serde_json::Value>,
125    platform: Platform,
126) -> Vec<DeployerSecretSlot<'a>> {
127    inputs
128        .iter()
129        .filter(|input| is_deployer_secret_input(input))
130        .filter(|input| {
131            input
132                .platforms
133                .as_ref()
134                .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
135        })
136        .filter_map(|input| {
137            let has_stored_value = values.get(&input.id).is_some_and(|value| !value.is_null());
138            let developer_value =
139                has_stored_value && input.provided_by.contains(&StackInputProvider::Developer);
140            (!developer_value).then(|| DeployerSecretSlot {
141                input,
142                vault_key: deployer_secret_vault_key(&input.id),
143                has_stored_value,
144            })
145        })
146        .collect()
147}
148
149/// The secret store a deployer writes a vault-native secret into.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
152#[serde(rename_all = "kebab-case")]
153pub enum DeployerSecretStore {
154    /// AWS Systems Manager Parameter Store, as a `SecureString` parameter.
155    AwsParameterStore,
156    /// GCP Secret Manager.
157    GcpSecretManager,
158    /// Azure Key Vault.
159    AzureKeyVault,
160    /// A Kubernetes Secret with the value under the `value` key.
161    KubernetesSecret,
162    /// The local development vault (`alien dev vault set`).
163    LocalVault,
164}
165
166/// Where a deployer writes a vault-native secret.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[serde(rename_all = "camelCase")]
170pub struct DeployerSecretLocation {
171    /// The secret store.
172    pub store: DeployerSecretStore,
173    /// Full name of the secret in that store.
174    pub name: String,
175    /// The Azure Key Vault that holds the secret. Other stores resolve `name`
176    /// in the stack's own account or project.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub vault_name: Option<String>,
179    /// Cloud console page where the secret is created, when the store has one.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub console_url: Option<String>,
182    /// Command that writes the value, with `<VALUE>` in place of the secret.
183    pub cli_command: String,
184}
185
186/// What a location needs beyond the vault binding.
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct DeployerSecretLocationContext {
189    /// AWS region of the stack.
190    pub aws_region: Option<String>,
191    /// GCP project id of the stack.
192    pub gcp_project_id: Option<String>,
193    /// Azure subscription id of the stack.
194    pub azure_subscription_id: Option<String>,
195    /// Azure resource group that holds the Key Vault.
196    pub azure_resource_group: Option<String>,
197    /// Deployment name, which `alien dev vault set` selects with `--deployment`.
198    pub deployment_name: Option<String>,
199}
200
201/// Where the deployer writes the value for `vault_key` of the `secrets` vault
202/// described by `binding`.
203pub fn deployer_secret_location(
204    binding: &VaultBinding,
205    vault_key: &str,
206    context: &DeployerSecretLocationContext,
207) -> crate::Result<DeployerSecretLocation> {
208    let placeholder = DEPLOYER_SECRET_VALUE_PLACEHOLDER;
209    let location = match binding {
210        VaultBinding::ParameterStore(binding) => {
211            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
212            let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
213            let region = context.aws_region.as_deref();
214            let region_flag = region
215                .map(|region| format!(" --region {region}"))
216                .unwrap_or_default();
217            DeployerSecretLocation {
218                store: DeployerSecretStore::AwsParameterStore,
219                vault_name: None,
220                console_url: region.map(|region| {
221                    format!(
222                        "https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
223                    )
224                }),
225                cli_command: format!(
226                    "aws ssm put-parameter{region_flag} --name '{name}' --type SecureString --overwrite --value '{placeholder}'"
227                ),
228                name,
229            }
230        }
231        VaultBinding::SecretManager(binding) => {
232            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
233            let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
234            let project = context.gcp_project_id.as_deref();
235            let project_flag = project
236                .map(|project| format!(" --project {project}"))
237                .unwrap_or_default();
238            DeployerSecretLocation {
239                store: DeployerSecretStore::GcpSecretManager,
240                vault_name: None,
241                console_url: project.map(|project| {
242                    format!(
243                        "https://console.cloud.google.com/security/secret-manager/create?project={project}"
244                    )
245                }),
246                // Creates the secret the first time; every run adds the value
247                // as a new version, so the same command rotates it.
248                cli_command: format!(
249                    "(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=-"
250                ),
251                name,
252            }
253        }
254        VaultBinding::KeyVault(binding) => {
255            let vault_name = concrete(&binding.vault_name, "vaultName")?;
256            let name = vault_naming::key_vault_secret_name(vault_key);
257            DeployerSecretLocation {
258                store: DeployerSecretStore::AzureKeyVault,
259                vault_name: Some(vault_name.clone()),
260                console_url: match (
261                    context.azure_subscription_id.as_deref(),
262                    context.azure_resource_group.as_deref(),
263                ) {
264                    (Some(subscription), Some(resource_group)) => Some(format!(
265                        "https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
266                    )),
267                    _ => None,
268                },
269                cli_command: format!(
270                    "az keyvault secret set --vault-name {vault_name} --name {name} --value '{placeholder}'"
271                ),
272                name,
273            }
274        }
275        VaultBinding::KubernetesSecret(binding) => {
276            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
277            let namespace = concrete(&binding.namespace, "namespace")?;
278            let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
279            DeployerSecretLocation {
280                store: DeployerSecretStore::KubernetesSecret,
281                vault_name: None,
282                console_url: None,
283                cli_command: format!(
284                    "kubectl create secret generic {name} --namespace {namespace} --from-literal={}='{placeholder}'",
285                    vault_naming::KUBERNETES_SECRET_VALUE_KEY
286                ),
287                name,
288            }
289        }
290        VaultBinding::Local(binding) => {
291            let deployment_flag = context
292                .deployment_name
293                .as_deref()
294                .map(|name| format!(" --deployment {name}"))
295                .unwrap_or_default();
296            DeployerSecretLocation {
297                store: DeployerSecretStore::LocalVault,
298                vault_name: None,
299                console_url: None,
300                cli_command: format!(
301                    "alien dev vault{deployment_flag} set {} {vault_key} '{placeholder}'",
302                    binding.vault_name
303                ),
304                name: vault_key.to_string(),
305            }
306        }
307    };
308    Ok(location)
309}
310
311fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
312    value.clone().into_value(crate::SECRETS_VAULT_ID, field)
313}
314
315/// Whether a deployer secret slot holds a usable value. Alien learns this from
316/// metadata only and never reads the value.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
319#[serde(rename_all = "kebab-case")]
320pub enum DeployerSecretStatus {
321    /// The secret exists and can be read by the workload.
322    Present,
323    /// The secret does not exist.
324    Missing,
325    /// The secret exists but cannot be used as is (wrong type, disabled, no
326    /// enabled version); the report's message says why.
327    Invalid,
328}
329
330/// The state of one deployer secret slot, reported with the deployment.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
333#[serde(rename_all = "camelCase")]
334pub struct DeployerSecretReport {
335    /// Stack input id.
336    pub input_id: String,
337    /// The input's label.
338    pub label: String,
339    /// Whether the workload cannot start without it.
340    pub required: bool,
341    /// Whether the slot is filled.
342    pub status: DeployerSecretStatus,
343    /// Why the slot is invalid.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub message: Option<String>,
346    /// Where the deployer writes the value.
347    pub location: DeployerSecretLocation,
348}
349
350impl DeployerSecretReport {
351    /// Whether this slot keeps the workload from starting.
352    pub fn blocks_start(&self) -> bool {
353        self.required && self.status != DeployerSecretStatus::Present
354    }
355
356    /// One line for a person: `missing: Database password` or
357    /// `invalid: Database password (must be a SecureString)`.
358    pub fn summary(&self) -> String {
359        match self.status {
360            DeployerSecretStatus::Present => format!("present: {}", self.label),
361            DeployerSecretStatus::Missing => format!("missing: {}", self.label),
362            DeployerSecretStatus::Invalid => match &self.message {
363                Some(message) => format!("invalid: {} ({message})", self.label),
364                None => format!("invalid: {}", self.label),
365            },
366        }
367    }
368}
369
370/// Env var that carries a natively projected workload's
371/// [`DeployerSecretEnv`] list to its hosting controller, which resolves each
372/// entry before the process starts (a `secretKeyRef` on Kubernetes, a vault
373/// read on the local platform). It holds names only, never values.
374pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
375
376/// An environment variable a workload reads from a vault-native deployer
377/// secret when it starts.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct DeployerSecretEnv {
381    /// Environment variable name.
382    pub name: String,
383    /// Key in the `secrets` vault.
384    pub vault_key: String,
385    /// Full name in the secret store (the Kubernetes Secret on Kubernetes).
386    pub secret_name: String,
387    /// The Azure Key Vault that holds it. Other stores resolve `secret_name`
388    /// in the workload's own account or project.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub vault_name: Option<String>,
391    /// The input's label, for the "missing: <label>" a failed start reports.
392    pub label: String,
393    /// Whether the workload must not start without it.
394    pub required: bool,
395}
396
397/// The environment variables workloads read from vault-native deployer
398/// secrets, each with the resources its mapping targets (`None` = all).
399///
400/// A slot is read from the vault once Alien has a report for it, unless the
401/// deployment still stores a value from before slots were vault-native: that
402/// value keeps today's path until the control plane drops it. The workload's
403/// profile gains the vault read grant at the same point, so a workload never
404/// reads a slot it may not read.
405pub fn deployer_secret_environment(
406    inputs: &[StackInputDefinition],
407    values: &HashMap<String, serde_json::Value>,
408    platform: Platform,
409    reports: &[DeployerSecretReport],
410) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
411    deployer_secret_slots(inputs, values, platform)
412        .into_iter()
413        .filter_map(|slot| {
414            let report = reports
415                .iter()
416                .find(|report| report.input_id == slot.input.id)?;
417            (!slot.has_stored_value).then_some((slot, report))
418        })
419        .flat_map(|(slot, report)| {
420            slot.input.env.iter().map(move |mapping| {
421                (
422                    DeployerSecretEnv {
423                        name: mapping.name.clone(),
424                        vault_key: slot.vault_key.clone(),
425                        secret_name: report.location.name.clone(),
426                        vault_name: report.location.vault_name.clone(),
427                        label: slot.input.label.clone(),
428                        required: slot.input.required,
429                    },
430                    mapping.target_resources.clone(),
431                )
432            })
433        })
434        .collect()
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::StackInputEnvironmentMapping;
441
442    fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
443        StackInputDefinition {
444            id: id.to_string(),
445            kind: StackInputKind::Secret,
446            provided_by,
447            required: true,
448            label: "Database password".to_string(),
449            description: String::new(),
450            placeholder: None,
451            default: None,
452            platforms: None,
453            validation: None,
454            generate: None,
455            env: vec![StackInputEnvironmentMapping {
456                name: "DATABASE_PASSWORD".to_string(),
457                target_resources: None,
458                var_type: None,
459            }],
460        }
461    }
462
463    #[test]
464    fn vault_keys_are_kebab_case_and_backend_safe() {
465        assert_eq!(
466            deployer_secret_vault_key("databasePassword"),
467            "input-database-password"
468        );
469        assert_eq!(deployer_secret_vault_key("api_key"), "input-api-key");
470        assert_eq!(
471            deployer_secret_vault_key("OAuth2Token"),
472            "input-oauth2-token"
473        );
474        assert_eq!(deployer_secret_vault_key("a--b__c"), "input-a-b-c");
475    }
476
477    #[test]
478    fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
479        let inputs = vec![secret(
480            "databasePassword",
481            vec![StackInputProvider::Deployer],
482        )];
483        let stored = HashMap::from([(
484            "databasePassword".to_string(),
485            serde_json::json!("from-before"),
486        )]);
487
488        let slots = deployer_secret_slots(&inputs, &stored, Platform::Aws);
489        assert_eq!(slots.len(), 1);
490        assert_eq!(slots[0].vault_key, "input-database-password");
491        assert!(slots[0].has_stored_value);
492    }
493
494    #[test]
495    fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
496        let inputs = vec![secret(
497            "databasePassword",
498            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
499        )];
500        let with_developer_value =
501            HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
502
503        assert!(deployer_secret_slots(&inputs, &with_developer_value, Platform::Aws).is_empty());
504        assert_eq!(
505            deployer_secret_slots(&inputs, &HashMap::new(), Platform::Aws).len(),
506            1
507        );
508    }
509
510    /// A control plane delivers a stored secret through its mapped variables,
511    /// never in `input_values`: a developer value delivered that way must keep
512    /// a dual-provided secret off the vault path, and only a fully delivered
513    /// input counts.
514    #[test]
515    fn a_secret_delivered_through_its_variables_counts_as_stored() {
516        let inputs = vec![secret(
517            "databasePassword",
518            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
519        )];
520        let snapshot = |names: &[&str]| EnvironmentVariablesSnapshot {
521            variables: names
522                .iter()
523                .map(|name| crate::EnvironmentVariable {
524                    name: name.to_string(),
525                    value: "dev".to_string(),
526                    var_type: crate::EnvironmentVariableType::Secret,
527                    target_resources: None,
528                })
529                .collect(),
530            hash: String::new(),
531            created_at: String::new(),
532        };
533
534        let delivered =
535            stored_input_values(&inputs, &HashMap::new(), &snapshot(&["DATABASE_PASSWORD"]));
536        assert!(deployer_secret_slots(&inputs, &delivered, Platform::Aws).is_empty());
537
538        let not_delivered = stored_input_values(&inputs, &HashMap::new(), &snapshot(&["OTHER"]));
539        assert_eq!(
540            deployer_secret_slots(&inputs, &not_delivered, Platform::Aws).len(),
541            1
542        );
543    }
544
545    #[test]
546    fn developer_secrets_and_other_platforms_are_not_slots() {
547        let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
548        aws_only.platforms = Some(vec![Platform::Aws]);
549        let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
550
551        assert!(deployer_secret_slots(&inputs, &HashMap::new(), Platform::Gcp).is_empty());
552    }
553
554    #[test]
555    fn locations_name_the_same_secret_the_vault_reads() {
556        let context = DeployerSecretLocationContext {
557            aws_region: Some("us-east-1".to_string()),
558            gcp_project_id: Some("acme-prod".to_string()),
559            deployment_name: Some("default".to_string()),
560            ..Default::default()
561        };
562        let key = "input-database-password";
563
564        let aws = deployer_secret_location(
565            &VaultBinding::parameter_store("stack-secrets"),
566            key,
567            &context,
568        )
569        .unwrap();
570        assert_eq!(aws.name, "stack-secrets-input-database-password");
571        assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
572        assert!(aws.cli_command.contains("--type SecureString"));
573        assert!(aws.cli_command.contains("'<VALUE>'"));
574
575        let gcp = deployer_secret_location(
576            &VaultBinding::secret_manager("stack-secrets"),
577            key,
578            &context,
579        )
580        .unwrap();
581        assert_eq!(gcp.name, "stack-secrets-input-database-password");
582        assert_eq!(gcp.vault_name, None);
583        assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
584
585        // A Key Vault secret name alone does not say which vault holds it, so
586        // the location carries the vault for whoever reads the slot.
587        let azure =
588            deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
589                .unwrap();
590        assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
591        assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
592        assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
593
594        let kubernetes = deployer_secret_location(
595            &VaultBinding::kubernetes_secret("apps", "Stack-Secrets"),
596            key,
597            &context,
598        )
599        .unwrap();
600        assert_eq!(kubernetes.name, "stack-secrets-input-database-password");
601        assert!(kubernetes.cli_command.contains("--namespace apps"));
602
603        let local =
604            deployer_secret_location(&VaultBinding::local("secrets", "/tmp/x"), key, &context)
605                .unwrap();
606        assert_eq!(
607            local.cli_command,
608            "alien dev vault --deployment default set secrets input-database-password '<VALUE>'"
609        );
610    }
611
612    #[test]
613    fn a_template_expression_has_no_location() {
614        let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
615            vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
616        });
617        assert!(deployer_secret_location(
618            &binding,
619            "input-x",
620            &DeployerSecretLocationContext::default()
621        )
622        .is_err());
623    }
624
625    #[test]
626    fn only_required_unfilled_slots_block_start() {
627        let location = deployer_secret_location(
628            &VaultBinding::local("secrets", "/tmp/x"),
629            "input-x",
630            &DeployerSecretLocationContext::default(),
631        )
632        .unwrap();
633        let mut report = DeployerSecretReport {
634            input_id: "x".to_string(),
635            label: "Database password".to_string(),
636            required: true,
637            status: DeployerSecretStatus::Missing,
638            message: None,
639            location,
640        };
641        assert!(report.blocks_start());
642        assert_eq!(report.summary(), "missing: Database password");
643
644        report.required = false;
645        assert!(!report.blocks_start());
646
647        report.required = true;
648        report.status = DeployerSecretStatus::Present;
649        assert!(!report.blocks_start());
650    }
651
652    fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
653        DeployerSecretReport {
654            input_id: input_id.to_string(),
655            label: "Database password".to_string(),
656            required: true,
657            status,
658            message: None,
659            location: DeployerSecretLocation {
660                store: DeployerSecretStore::AwsParameterStore,
661                name: "stack-secrets-input-database-password".to_string(),
662                vault_name: None,
663                console_url: None,
664                cli_command: String::new(),
665            },
666        }
667    }
668
669    #[test]
670    fn a_slot_is_read_from_the_vault_once_it_is_reported() {
671        let inputs = vec![secret(
672            "databasePassword",
673            vec![StackInputProvider::Deployer],
674        )];
675        let no_values = HashMap::new();
676
677        assert!(deployer_secret_environment(&inputs, &no_values, Platform::Aws, &[]).is_empty());
678
679        let env = deployer_secret_environment(
680            &inputs,
681            &no_values,
682            Platform::Aws,
683            &[report("databasePassword", DeployerSecretStatus::Missing)],
684        );
685        assert_eq!(env.len(), 1);
686        let (variable, targets) = &env[0];
687        assert_eq!(variable.name, "DATABASE_PASSWORD");
688        assert_eq!(variable.vault_key, "input-database-password");
689        assert_eq!(
690            variable.secret_name,
691            "stack-secrets-input-database-password"
692        );
693        assert!(variable.required);
694        assert_eq!(variable.vault_name, None);
695        assert_eq!(targets, &None);
696    }
697
698    #[test]
699    fn a_key_vault_slot_names_its_vault_for_the_workload() {
700        let inputs = vec![secret(
701            "databasePassword",
702            vec![StackInputProvider::Deployer],
703        )];
704        let location = deployer_secret_location(
705            &VaultBinding::key_vault("stacksecrets7f3a"),
706            "input-database-password",
707            &DeployerSecretLocationContext::default(),
708        )
709        .unwrap();
710        let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
711        azure_report.location = location;
712
713        let env =
714            deployer_secret_environment(&inputs, &HashMap::new(), Platform::Azure, &[azure_report]);
715
716        assert_eq!(env.len(), 1);
717        assert_eq!(env[0].0.secret_name, "input-database-password");
718        assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
719    }
720
721    #[test]
722    fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
723        let inputs = vec![secret(
724            "databasePassword",
725            vec![StackInputProvider::Deployer],
726        )];
727        let stored = HashMap::from([(
728            "databasePassword".to_string(),
729            serde_json::json!("from-before"),
730        )]);
731
732        assert!(deployer_secret_environment(
733            &inputs,
734            &stored,
735            Platform::Aws,
736            &[report("databasePassword", DeployerSecretStatus::Missing)],
737        )
738        .is_empty());
739        assert!(
740            deployer_secret_environment(
741                &inputs,
742                &stored,
743                Platform::Aws,
744                &[report("databasePassword", DeployerSecretStatus::Present)],
745            )
746            .is_empty(),
747            "a filled slot is not read while the stored value has no vault read grant"
748        );
749        assert_eq!(
750            deployer_secret_environment(
751                &inputs,
752                &HashMap::new(),
753                Platform::Aws,
754                &[report("databasePassword", DeployerSecretStatus::Present)],
755            )
756            .len(),
757            1
758        );
759    }
760}