Skip to main content

alien_core/
stack_input.rs

1use serde::{Deserialize, Serialize};
2
3use crate::Platform;
4
5/// Who can provide a stack input value.
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
7#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
8#[serde(rename_all = "camelCase")]
9pub enum StackInputProvider {
10    /// Value is provided by the developer before a deployment link is created.
11    Developer,
12    /// Value is provided by the deployer during setup.
13    Deployer,
14}
15
16/// Primitive stack input kind.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19#[serde(rename_all = "camelCase")]
20pub enum StackInputKind {
21    /// Plain string input.
22    String,
23    /// Secret string input.
24    Secret,
25    /// Floating point number input.
26    Number,
27    /// Integer input.
28    Integer,
29    /// Boolean input.
30    Boolean,
31    /// String enum input.
32    Enum,
33    /// List of strings.
34    StringList,
35}
36
37/// How a resolved stack input is injected into runtime environment variables.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase")]
41pub struct StackInputEnvironmentMapping {
42    /// Environment variable name.
43    pub name: String,
44    /// Target resource IDs or patterns. None means every env-capable resource.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub target_resources: Option<Vec<String>>,
47    /// Whether this env var is plain or secret. Defaults from the input kind.
48    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
49    pub var_type: Option<StackInputEnvironmentVariableType>,
50}
51
52impl StackInputEnvironmentMapping {
53    /// Whether this mapping reaches `resource_id` (see [`targets_resource`]).
54    pub fn targets(&self, resource_id: &str) -> bool {
55        targets_resource(&self.target_resources, resource_id)
56    }
57}
58
59/// Whether an environment variable with these `target_resources` reaches
60/// `resource_id`: every resource when unset, else an exact id or a `prefix*`
61/// pattern. Env delivery and the permissions that follow from it (such as
62/// reading a deployer secret) share this one rule.
63pub fn targets_resource(target_resources: &Option<Vec<String>>, resource_id: &str) -> bool {
64    match target_resources {
65        None => true,
66        Some(patterns) => patterns
67            .iter()
68            .any(|pattern| match pattern.strip_suffix('*') {
69                Some(prefix) => resource_id.starts_with(prefix),
70                None => resource_id == pattern,
71            }),
72    }
73}
74
75/// Environment variable handling for a stack input mapping.
76#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78#[serde(rename_all = "lowercase")]
79pub enum StackInputEnvironmentVariableType {
80    /// Plain value injected directly.
81    Plain,
82    /// Secret value routed through secret handling.
83    Secret,
84}
85
86/// Portable stack input validation constraints.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase")]
90pub struct StackInputValidation {
91    /// Minimum string length.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub min_length: Option<u32>,
94    /// Maximum string length.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub max_length: Option<u32>,
97    /// Portable whole-value regex pattern.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub pattern: Option<String>,
100    /// Semantic format hint such as url.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub format: Option<String>,
103    /// Minimum number.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub min: Option<String>,
106    /// Maximum number.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub max: Option<String>,
109    /// Allowed string enum values.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub values: Option<Vec<String>>,
112    /// Minimum string-list items.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub min_items: Option<u32>,
115    /// Maximum string-list items.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub max_items: Option<u32>,
118}
119
120/// Stack input default value.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[serde(rename_all = "camelCase", tag = "type", content = "value")]
124pub enum StackInputDefaultValue {
125    /// String default.
126    String(String),
127    /// Number default.
128    Number(String),
129    /// Boolean default.
130    Boolean(bool),
131    /// String list default.
132    StringList(Vec<String>),
133}
134
135/// Shortest value Alien generates for a secret input.
136pub const STACK_INPUT_GENERATE_MIN_LENGTH: u32 = 16;
137/// Longest value Alien generates for a secret input.
138pub const STACK_INPUT_GENERATE_MAX_LENGTH: u32 = 256;
139
140/// Asks Alien to generate a secret input's value.
141///
142/// The value is an alphanumeric string (`A-Z`, `a-z`, `0-9`), so it is safe in
143/// connection strings, command lines and environment variables. It is generated
144/// once, when the deployment's input values are first resolved, and then kept
145/// with the deployment's other input values.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148#[serde(rename_all = "camelCase")]
149pub struct StackInputGenerate {
150    /// Number of characters to generate.
151    pub length: u32,
152}
153
154/// A boolean input condition controlling whether another input applies.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "camelCase")]
158pub struct StackInputCondition {
159    /// ID of an unconditional boolean input in the same stack.
160    pub input: String,
161    /// Boolean value for which this input applies.
162    pub equals: bool,
163}
164
165/// Stack input definition serialized into a release stack.
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168#[serde(rename_all = "camelCase")]
169pub struct StackInputDefinition {
170    /// Stable input ID used by CLI/API calls.
171    pub id: String,
172    /// Input primitive kind.
173    pub kind: StackInputKind,
174    /// Who can provide this value.
175    pub provided_by: Vec<StackInputProvider>,
176    /// Whether a resolved value is required before deployment can proceed.
177    pub required: bool,
178    /// Human-facing field label.
179    pub label: String,
180    /// Human-facing helper text.
181    pub description: String,
182    /// Example placeholder shown in UI.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub placeholder: Option<String>,
185    /// Default value for optional/plain inputs.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub default: Option<StackInputDefaultValue>,
188    /// Platforms where this input applies.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub platforms: Option<Vec<Platform>>,
191    /// Only applies when the referenced boolean resolves to the given value.
192    /// Inactive inputs ignore provided values, validation and environment mappings.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub when: Option<StackInputCondition>,
195    /// Portable validation constraints.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub validation: Option<StackInputValidation>,
198    /// Alien generates the value when none is provided. Secret,
199    /// developer-provided inputs only.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub generate: Option<StackInputGenerate>,
202    /// Runtime env-var mappings for v1 input resolution.
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub env: Vec<StackInputEnvironmentMapping>,
205}
206
207impl StackInputDefinition {
208    /// Whether this input is available on the deployment platform, independently of its base cloud.
209    /// Empty scopes retain their legacy all-platform meaning for existing releases.
210    /// Build-time checks reject empty scopes in new releases.
211    pub fn applies_to(&self, platform: Platform) -> bool {
212        self.platforms
213            .as_ref()
214            .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
215    }
216
217    /// Whether this input applies, using the referenced input's provided value
218    /// or boolean default. An off-platform reference resolves to false; an
219    /// unanswered reference never matches. Definitions must pass release preflights first.
220    pub fn applies(
221        &self,
222        inputs: &[StackInputDefinition],
223        values: &std::collections::HashMap<String, serde_json::Value>,
224        platform: Platform,
225    ) -> bool {
226        if !self.applies_to(platform) {
227            return false;
228        }
229        let Some(condition) = &self.when else {
230            return true;
231        };
232        let Some(reference) = inputs.iter().find(|input| input.id == condition.input) else {
233            return false;
234        };
235        if !reference.applies_to(platform) {
236            return !condition.equals;
237        }
238        let value = match values
239            .get(&condition.input)
240            .filter(|value| !value.is_null())
241        {
242            Some(value) => crate::gate_value_as_bool(value),
243            None => match reference.default {
244                Some(StackInputDefaultValue::Boolean(value)) => Some(value),
245                _ => None,
246            },
247        };
248        value == Some(condition.equals)
249    }
250
251    /// Whether this input controls any input selected by the caller.
252    pub fn controls_input(
253        &self,
254        inputs: &[StackInputDefinition],
255        include: impl Fn(&StackInputDefinition) -> bool,
256    ) -> bool {
257        inputs.iter().any(|dependent| {
258            include(dependent)
259                && dependent
260                    .when
261                    .as_ref()
262                    .is_some_and(|when| when.input == self.id)
263        })
264    }
265
266    /// Whether Alien generates this input's value when none is provided. A
267    /// generated input never needs a value from the developer or deployer.
268    pub fn is_generated(&self) -> bool {
269        self.generate.is_some()
270    }
271
272    /// A deployer-provided boolean input, the shape `.enabled(input)` gates
273    /// on. With a default the input is optional; without one it is required,
274    /// since an unanswered gate would leave the resource's existence
275    /// undecided. This is the Rust-side seam gated-stack tests build inputs
276    /// with.
277    #[doc(hidden)]
278    pub fn deployer_boolean(
279        id: &str,
280        label: &str,
281        description: &str,
282        default: Option<bool>,
283    ) -> Self {
284        Self {
285            id: id.to_string(),
286            kind: StackInputKind::Boolean,
287            when: None,
288            provided_by: vec![StackInputProvider::Deployer],
289            required: default.is_none(),
290            label: label.to_string(),
291            description: description.to_string(),
292            placeholder: None,
293            default: default.map(StackInputDefaultValue::Boolean),
294            platforms: None,
295            validation: None,
296            generate: None,
297            env: Vec::new(),
298        }
299    }
300}
301
302/// Finds the boolean deployer input a gate references, for render-time
303/// re-validation. The compile-time preflight enforces the same two rules;
304/// generators repeat them so a caller that renders without preflights cannot
305/// ship a template whose gate variable is undeclared or non-boolean.
306pub fn find_boolean_gate_input<'a>(
307    inputs: &'a [StackInputDefinition],
308    input_id: &str,
309) -> Result<&'a StackInputDefinition, GateInputIssue> {
310    let input = inputs
311        .iter()
312        .find(|input| input.id == input_id)
313        .ok_or(GateInputIssue::Undeclared)?;
314    if input.kind != StackInputKind::Boolean {
315        return Err(GateInputIssue::NotBoolean(input.kind.clone()));
316    }
317    Ok(input)
318}
319
320/// Why a gate input failed render-time validation; the caller owns the
321/// backend-specific error message.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum GateInputIssue {
324    /// The stack declares no input with the gate's id.
325    Undeclared,
326    /// The input exists but is not a boolean.
327    NotBoolean(StackInputKind),
328}
329
330/// Environment variables produced by stack inputs that declare `env`
331/// mappings, from the deployment's input values (or each input's default).
332///
333/// Only inputs that apply to `platform` count: an input that lists
334/// `platforms` without it produces nothing. List values become
335/// comma-separated strings. Secret inputs produce secret variables unless the
336/// mapping sets a type. Two mappings resolving to the same variable name are
337/// rejected.
338pub fn resolve_stack_input_environment_variables(
339    inputs: &[StackInputDefinition],
340    values: &std::collections::HashMap<String, serde_json::Value>,
341    platform: Platform,
342) -> crate::Result<Vec<crate::EnvironmentVariable>> {
343    let mut variables: Vec<crate::EnvironmentVariable> = Vec::new();
344    for input in inputs
345        .iter()
346        .filter(|input| !input.env.is_empty())
347        .filter(|input| input.applies(inputs, values, platform))
348    {
349        let value = match values.get(&input.id) {
350            Some(serde_json::Value::Null) | None => match &input.default {
351                Some(default) => default_environment_string(default),
352                None => continue,
353            },
354            Some(value) => environment_string(value),
355        };
356        for mapping in &input.env {
357            if variables
358                .iter()
359                .any(|variable| variable.name == mapping.name)
360            {
361                return Err(alien_error::AlienError::new(
362                    crate::ErrorData::GenericError {
363                        message: format!(
364                            "Stack inputs map more than one value to environment variable '{}'",
365                            mapping.name
366                        ),
367                    },
368                ));
369            }
370            let secret = match mapping.var_type {
371                Some(StackInputEnvironmentVariableType::Secret) => true,
372                Some(StackInputEnvironmentVariableType::Plain) => false,
373                None => input.kind == StackInputKind::Secret,
374            };
375            variables.push(crate::EnvironmentVariable {
376                name: mapping.name.clone(),
377                value: value.clone(),
378                var_type: if secret {
379                    crate::EnvironmentVariableType::Secret
380                } else {
381                    crate::EnvironmentVariableType::Plain
382                },
383                target_resources: mapping.target_resources.clone(),
384            });
385        }
386    }
387    Ok(variables)
388}
389
390fn environment_string(value: &serde_json::Value) -> String {
391    match value {
392        serde_json::Value::String(value) => value.clone(),
393        serde_json::Value::Array(items) => items
394            .iter()
395            .map(environment_string)
396            .collect::<Vec<_>>()
397            .join(","),
398        other => other.to_string(),
399    }
400}
401
402fn default_environment_string(default: &StackInputDefaultValue) -> String {
403    match default {
404        StackInputDefaultValue::String(value) | StackInputDefaultValue::Number(value) => {
405            value.clone()
406        }
407        StackInputDefaultValue::Boolean(value) => value.to_string(),
408        StackInputDefaultValue::StringList(values) => values.join(","),
409    }
410}
411
412#[cfg(test)]
413mod environment_tests {
414    use std::collections::HashMap;
415
416    use super::*;
417    use crate::EnvironmentVariableType;
418
419    #[test]
420    fn an_env_mapping_targets_every_resource_or_its_patterns() {
421        let mapping = |targets: Option<Vec<&str>>| StackInputEnvironmentMapping {
422            name: "API_KEY".to_string(),
423            target_resources: targets.map(|t| t.into_iter().map(String::from).collect()),
424            var_type: None,
425        };
426
427        assert!(mapping(None).targets("api"));
428        assert!(mapping(Some(vec!["api"])).targets("api"));
429        assert!(!mapping(Some(vec!["api"])).targets("api-worker"));
430        assert!(mapping(Some(vec!["api-*"])).targets("api-worker"));
431        assert!(!mapping(Some(vec!["api-*"])).targets("web"));
432        assert!(mapping(Some(vec!["api-*", "worker"])).targets("worker"));
433        assert!(!mapping(Some(vec!["api-*", "worker"])).targets("scheduler"));
434        assert!(!mapping(Some(vec![])).targets("api"));
435    }
436
437    fn input(id: &str, kind: StackInputKind, env: &str) -> StackInputDefinition {
438        StackInputDefinition {
439            id: id.to_string(),
440            kind,
441            when: None,
442            provided_by: vec![StackInputProvider::Developer],
443            required: false,
444            label: id.to_string(),
445            description: id.to_string(),
446            placeholder: None,
447            default: None,
448            generate: None,
449            platforms: None,
450            validation: None,
451            env: vec![StackInputEnvironmentMapping {
452                name: env.to_string(),
453                target_resources: Some(vec!["api".to_string()]),
454                var_type: None,
455            }],
456        }
457    }
458
459    #[test]
460    fn conditional_environment_uses_provided_boolean_before_default() {
461        let toggle = StackInputDefinition::deployer_boolean(
462            "enabled",
463            "Enabled",
464            "Enable snapshots",
465            Some(true),
466        );
467        let mut bucket = input("bucket", StackInputKind::String, "BUCKET");
468        bucket.required = true;
469        bucket.when = Some(StackInputCondition {
470            input: "enabled".into(),
471            equals: true,
472        });
473        bucket.default = Some(StackInputDefaultValue::String("default-bucket".into()));
474        let inputs = vec![toggle, bucket];
475        for (answer, expected) in [
476            (None, Some("default-bucket")),
477            (Some(serde_json::json!(true)), Some("default-bucket")),
478            (Some(serde_json::json!("true")), Some("default-bucket")),
479            (Some(serde_json::json!(false)), None),
480            (Some(serde_json::json!("false")), None),
481        ] {
482            let values = answer
483                .map(|value| HashMap::from([("enabled".into(), value)]))
484                .unwrap_or_default();
485            let env =
486                resolve_stack_input_environment_variables(&inputs, &values, Platform::Aws).unwrap();
487            assert_eq!(
488                env.first().map(|variable| variable.value.as_str()),
489                expected
490            );
491        }
492        let values = HashMap::from([
493            ("enabled".into(), serde_json::json!(false)),
494            ("bucket".into(), serde_json::json!({"invalid": "ignored"})),
495        ]);
496        assert!(
497            resolve_stack_input_environment_variables(&inputs, &values, Platform::Aws)
498                .unwrap()
499                .is_empty()
500        );
501    }
502
503    #[test]
504    fn conditional_input_requires_an_answer_and_both_platform_scopes() {
505        let mut toggle =
506            StackInputDefinition::deployer_boolean("enabled", "Enabled", "Enable snapshots", None);
507        toggle.platforms = Some(vec![Platform::Aws]);
508        let mut bucket = input("bucket", StackInputKind::String, "BUCKET");
509        bucket.when = Some(StackInputCondition {
510            input: "enabled".into(),
511            equals: false,
512        });
513        let mut inputs = vec![toggle, bucket];
514        assert!(!inputs[1].applies(&inputs, &HashMap::new(), Platform::Aws));
515        let values = HashMap::from([("enabled".into(), serde_json::json!(false))]);
516        assert!(inputs[1].applies(&inputs, &values, Platform::Aws));
517        assert!(inputs[1].applies(&inputs, &values, Platform::Kubernetes));
518        inputs[1].when.as_mut().unwrap().equals = true;
519        assert!(!inputs[1].applies(&inputs, &values, Platform::Kubernetes));
520        inputs[0].platforms = Some(vec![]);
521        assert!(inputs[0].applies_to(Platform::Aws));
522        assert!(!inputs[1].applies(&inputs, &values, Platform::Aws));
523        inputs[1].when.as_mut().unwrap().equals = false;
524        assert!(inputs[1].applies(&inputs, &values, Platform::Aws));
525        inputs[1].platforms = Some(vec![Platform::Kubernetes]);
526        assert!(!inputs[1].applies(&inputs, &values, Platform::Aws));
527    }
528
529    #[test]
530    fn delivery_preserves_stored_values_without_revalidation() {
531        for (kind, value, validation) in [
532            (
533                StackInputKind::Number,
534                "1.50",
535                serde_json::json!({"min":"10"}),
536            ),
537            (StackInputKind::Number, "1e3", serde_json::json!({})),
538            (
539                StackInputKind::Integer,
540                "007",
541                serde_json::json!({"max":"5"}),
542            ),
543            (
544                StackInputKind::String,
545                "not-a-url",
546                serde_json::json!({"format":"url"}),
547            ),
548            (
549                StackInputKind::String,
550                "",
551                serde_json::json!({"minLength":3}),
552            ),
553        ] {
554            let mut stored = input("stored", kind, "STORED");
555            stored.required = true;
556            stored.validation = Some(serde_json::from_value(validation).unwrap());
557            let mut added = input("added", StackInputKind::String, "ADDED");
558            added.required = true;
559            let values = HashMap::from([("stored".into(), serde_json::json!(value))]);
560            let variables =
561                resolve_stack_input_environment_variables(&[stored, added], &values, Platform::Aws)
562                    .expect("delivery must not revalidate existing deployments");
563            assert_eq!(variables.len(), 1);
564            assert_eq!(variables[0].name, "STORED");
565            assert_eq!(variables[0].value, value);
566        }
567    }
568
569    #[test]
570    fn legacy_empty_platform_list_still_maps_environment() {
571        let mut definition = input("region", StackInputKind::String, "REGION");
572        definition.platforms = Some(vec![]);
573        let values = HashMap::from([("region".into(), serde_json::json!("us-east-1"))]);
574        let env = resolve_stack_input_environment_variables(&[definition], &values, Platform::Aws)
575            .unwrap();
576        assert_eq!(env.len(), 1);
577        assert_eq!(env[0].value, "us-east-1");
578    }
579
580    #[test]
581    fn maps_values_defaults_and_secrecy() {
582        let mut region = input("region", StackInputKind::String, "REGION");
583        region.default = Some(StackInputDefaultValue::String("eu-west-1".to_string()));
584        let inputs = vec![
585            input("token", StackInputKind::Secret, "ACCESS_TOKEN"),
586            input("zones", StackInputKind::StringList, "ZONES"),
587            region,
588            input("unset", StackInputKind::String, "UNSET"),
589        ];
590        let values = HashMap::from([
591            ("token".to_string(), serde_json::json!("s3cr3t")),
592            ("zones".to_string(), serde_json::json!(["a", "b"])),
593        ]);
594
595        let variables =
596            resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
597                .unwrap();
598
599        let by_name: HashMap<_, _> = variables.iter().map(|v| (v.name.as_str(), v)).collect();
600        assert_eq!(
601            variables.len(),
602            3,
603            "unset inputs without defaults produce nothing"
604        );
605        assert_eq!(by_name["ACCESS_TOKEN"].value, "s3cr3t");
606        assert_eq!(
607            by_name["ACCESS_TOKEN"].var_type,
608            EnvironmentVariableType::Secret
609        );
610        assert_eq!(
611            by_name["ACCESS_TOKEN"].target_resources,
612            Some(vec!["api".to_string()])
613        );
614        assert_eq!(by_name["ZONES"].value, "a,b");
615        assert_eq!(by_name["ZONES"].var_type, EnvironmentVariableType::Plain);
616        assert_eq!(by_name["REGION"].value, "eu-west-1");
617    }
618
619    #[test]
620    fn rejects_two_inputs_mapped_to_one_variable() {
621        let inputs = vec![
622            input("a", StackInputKind::String, "SHARED"),
623            input("b", StackInputKind::String, "SHARED"),
624        ];
625        let values = HashMap::from([
626            ("a".to_string(), serde_json::json!("1")),
627            ("b".to_string(), serde_json::json!("2")),
628        ]);
629        let error =
630            resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
631                .expect_err("duplicate names must be rejected");
632        assert!(error.message.contains("SHARED"));
633    }
634
635    #[test]
636    fn legacy_empty_platform_scope_preserves_environment_variables() {
637        let mut input = input("region", StackInputKind::String, "REGION");
638        input.platforms = Some(vec![]);
639        input.default = Some(StackInputDefaultValue::String("us-east-1".into()));
640        let values = HashMap::from([("region".into(), serde_json::json!("us-west-2"))]);
641        let variables =
642            resolve_stack_input_environment_variables(&[input], &values, Platform::Aws).unwrap();
643        assert_eq!(variables.len(), 1);
644        assert_eq!(variables[0].name, "REGION");
645        assert_eq!(variables[0].value, "us-west-2");
646    }
647
648    #[test]
649    fn inputs_for_other_platforms_produce_nothing() {
650        // An AWS-only input with a default must not reach a Kubernetes
651        // workload, and may share a variable name with a Kubernetes one.
652        let mut aws_only = input("aws-region", StackInputKind::String, "REGION");
653        aws_only.platforms = Some(vec![Platform::Aws]);
654        aws_only.default = Some(StackInputDefaultValue::String("us-east-1".to_string()));
655        let mut kubernetes_only = input("zone", StackInputKind::String, "REGION");
656        kubernetes_only.platforms = Some(vec![Platform::Kubernetes]);
657        let values = HashMap::from([("zone".to_string(), serde_json::json!("rack-7"))]);
658
659        let variables = resolve_stack_input_environment_variables(
660            &[aws_only, kubernetes_only],
661            &values,
662            Platform::Kubernetes,
663        )
664        .unwrap();
665
666        assert_eq!(variables.len(), 1);
667        assert_eq!(variables[0].name, "REGION");
668        assert_eq!(variables[0].value, "rack-7");
669    }
670}