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