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/// Stack input definition serialized into a release stack.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "camelCase")]
158pub struct StackInputDefinition {
159    /// Stable input ID used by CLI/API calls.
160    pub id: String,
161    /// Input primitive kind.
162    pub kind: StackInputKind,
163    /// Who can provide this value.
164    pub provided_by: Vec<StackInputProvider>,
165    /// Whether a resolved value is required before deployment can proceed.
166    pub required: bool,
167    /// Human-facing field label.
168    pub label: String,
169    /// Human-facing helper text.
170    pub description: String,
171    /// Example placeholder shown in UI.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub placeholder: Option<String>,
174    /// Default value for optional/plain inputs.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub default: Option<StackInputDefaultValue>,
177    /// Platforms where this input applies.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub platforms: Option<Vec<Platform>>,
180    /// Portable validation constraints.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub validation: Option<StackInputValidation>,
183    /// Alien generates the value when none is provided. Secret,
184    /// developer-provided inputs only.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub generate: Option<StackInputGenerate>,
187    /// Runtime env-var mappings for v1 input resolution.
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub env: Vec<StackInputEnvironmentMapping>,
190}
191
192impl StackInputDefinition {
193    /// Whether Alien generates this input's value when none is provided. A
194    /// generated input never needs a value from the developer or deployer.
195    pub fn is_generated(&self) -> bool {
196        self.generate.is_some()
197    }
198
199    /// A deployer-provided boolean input, the shape `.enabled(input)` gates
200    /// on. With a default the input is optional; without one it is required,
201    /// since an unanswered gate would leave the resource's existence
202    /// undecided. This is the Rust-side seam gated-stack tests build inputs
203    /// with.
204    #[doc(hidden)]
205    pub fn deployer_boolean(
206        id: &str,
207        label: &str,
208        description: &str,
209        default: Option<bool>,
210    ) -> Self {
211        Self {
212            id: id.to_string(),
213            kind: StackInputKind::Boolean,
214            provided_by: vec![StackInputProvider::Deployer],
215            required: default.is_none(),
216            label: label.to_string(),
217            description: description.to_string(),
218            placeholder: None,
219            default: default.map(StackInputDefaultValue::Boolean),
220            platforms: None,
221            validation: None,
222            generate: None,
223            env: Vec::new(),
224        }
225    }
226}
227
228/// Finds the boolean deployer input a gate references, for render-time
229/// re-validation. The compile-time preflight enforces the same two rules;
230/// generators repeat them so a caller that renders without preflights cannot
231/// ship a template whose gate variable is undeclared or non-boolean.
232pub fn find_boolean_gate_input<'a>(
233    inputs: &'a [StackInputDefinition],
234    input_id: &str,
235) -> Result<&'a StackInputDefinition, GateInputIssue> {
236    let input = inputs
237        .iter()
238        .find(|input| input.id == input_id)
239        .ok_or(GateInputIssue::Undeclared)?;
240    if input.kind != StackInputKind::Boolean {
241        return Err(GateInputIssue::NotBoolean(input.kind.clone()));
242    }
243    Ok(input)
244}
245
246/// Why a gate input failed render-time validation; the caller owns the
247/// backend-specific error message.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum GateInputIssue {
250    /// The stack declares no input with the gate's id.
251    Undeclared,
252    /// The input exists but is not a boolean.
253    NotBoolean(StackInputKind),
254}
255
256/// Environment variables produced by stack inputs that declare `env`
257/// mappings, from the deployment's input values (or each input's default).
258///
259/// Only inputs that apply to `platform` count: an input that lists
260/// `platforms` without it produces nothing. List values become
261/// comma-separated strings. Secret inputs produce secret variables unless the
262/// mapping sets a type. Two mappings resolving to the same variable name are
263/// rejected.
264pub fn resolve_stack_input_environment_variables(
265    inputs: &[StackInputDefinition],
266    values: &std::collections::HashMap<String, serde_json::Value>,
267    platform: Platform,
268) -> crate::Result<Vec<crate::EnvironmentVariable>> {
269    let mut variables: Vec<crate::EnvironmentVariable> = Vec::new();
270    let applies = |input: &&StackInputDefinition| match &input.platforms {
271        Some(platforms) if !platforms.is_empty() => platforms.contains(&platform),
272        _ => true,
273    };
274    for input in inputs
275        .iter()
276        .filter(|input| !input.env.is_empty())
277        .filter(applies)
278    {
279        let value = match values.get(&input.id) {
280            Some(serde_json::Value::Null) | None => match &input.default {
281                Some(default) => default_environment_string(default),
282                None => continue,
283            },
284            Some(value) => environment_string(value),
285        };
286        for mapping in &input.env {
287            if variables
288                .iter()
289                .any(|variable| variable.name == mapping.name)
290            {
291                return Err(alien_error::AlienError::new(
292                    crate::ErrorData::GenericError {
293                        message: format!(
294                            "Stack inputs map more than one value to environment variable '{}'",
295                            mapping.name
296                        ),
297                    },
298                ));
299            }
300            let secret = match mapping.var_type {
301                Some(StackInputEnvironmentVariableType::Secret) => true,
302                Some(StackInputEnvironmentVariableType::Plain) => false,
303                None => input.kind == StackInputKind::Secret,
304            };
305            variables.push(crate::EnvironmentVariable {
306                name: mapping.name.clone(),
307                value: value.clone(),
308                var_type: if secret {
309                    crate::EnvironmentVariableType::Secret
310                } else {
311                    crate::EnvironmentVariableType::Plain
312                },
313                target_resources: mapping.target_resources.clone(),
314            });
315        }
316    }
317    Ok(variables)
318}
319
320fn environment_string(value: &serde_json::Value) -> String {
321    match value {
322        serde_json::Value::String(value) => value.clone(),
323        serde_json::Value::Array(items) => items
324            .iter()
325            .map(environment_string)
326            .collect::<Vec<_>>()
327            .join(","),
328        other => other.to_string(),
329    }
330}
331
332fn default_environment_string(default: &StackInputDefaultValue) -> String {
333    match default {
334        StackInputDefaultValue::String(value) | StackInputDefaultValue::Number(value) => {
335            value.clone()
336        }
337        StackInputDefaultValue::Boolean(value) => value.to_string(),
338        StackInputDefaultValue::StringList(values) => values.join(","),
339    }
340}
341
342#[cfg(test)]
343mod environment_tests {
344    use std::collections::HashMap;
345
346    use super::*;
347    use crate::EnvironmentVariableType;
348
349    #[test]
350    fn an_env_mapping_targets_every_resource_or_its_patterns() {
351        let mapping = |targets: Option<Vec<&str>>| StackInputEnvironmentMapping {
352            name: "API_KEY".to_string(),
353            target_resources: targets.map(|t| t.into_iter().map(String::from).collect()),
354            var_type: None,
355        };
356
357        assert!(mapping(None).targets("api"));
358        assert!(mapping(Some(vec!["api"])).targets("api"));
359        assert!(!mapping(Some(vec!["api"])).targets("api-worker"));
360        assert!(mapping(Some(vec!["api-*"])).targets("api-worker"));
361        assert!(!mapping(Some(vec!["api-*"])).targets("web"));
362        assert!(mapping(Some(vec!["api-*", "worker"])).targets("worker"));
363        assert!(!mapping(Some(vec!["api-*", "worker"])).targets("scheduler"));
364        assert!(!mapping(Some(vec![])).targets("api"));
365    }
366
367    fn input(id: &str, kind: StackInputKind, env: &str) -> StackInputDefinition {
368        StackInputDefinition {
369            id: id.to_string(),
370            kind,
371            provided_by: vec![StackInputProvider::Developer],
372            required: false,
373            label: id.to_string(),
374            description: id.to_string(),
375            placeholder: None,
376            default: None,
377            generate: None,
378            platforms: None,
379            validation: None,
380            env: vec![StackInputEnvironmentMapping {
381                name: env.to_string(),
382                target_resources: Some(vec!["api".to_string()]),
383                var_type: None,
384            }],
385        }
386    }
387
388    #[test]
389    fn maps_values_defaults_and_secrecy() {
390        let mut region = input("region", StackInputKind::String, "REGION");
391        region.default = Some(StackInputDefaultValue::String("eu-west-1".to_string()));
392        let inputs = vec![
393            input("token", StackInputKind::Secret, "ACCESS_TOKEN"),
394            input("zones", StackInputKind::StringList, "ZONES"),
395            region,
396            input("unset", StackInputKind::String, "UNSET"),
397        ];
398        let values = HashMap::from([
399            ("token".to_string(), serde_json::json!("s3cr3t")),
400            ("zones".to_string(), serde_json::json!(["a", "b"])),
401        ]);
402
403        let variables =
404            resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
405                .unwrap();
406
407        let by_name: HashMap<_, _> = variables.iter().map(|v| (v.name.as_str(), v)).collect();
408        assert_eq!(
409            variables.len(),
410            3,
411            "unset inputs without defaults produce nothing"
412        );
413        assert_eq!(by_name["ACCESS_TOKEN"].value, "s3cr3t");
414        assert_eq!(
415            by_name["ACCESS_TOKEN"].var_type,
416            EnvironmentVariableType::Secret
417        );
418        assert_eq!(
419            by_name["ACCESS_TOKEN"].target_resources,
420            Some(vec!["api".to_string()])
421        );
422        assert_eq!(by_name["ZONES"].value, "a,b");
423        assert_eq!(by_name["ZONES"].var_type, EnvironmentVariableType::Plain);
424        assert_eq!(by_name["REGION"].value, "eu-west-1");
425    }
426
427    #[test]
428    fn rejects_two_inputs_mapped_to_one_variable() {
429        let inputs = vec![
430            input("a", StackInputKind::String, "SHARED"),
431            input("b", StackInputKind::String, "SHARED"),
432        ];
433        let values = HashMap::from([
434            ("a".to_string(), serde_json::json!("1")),
435            ("b".to_string(), serde_json::json!("2")),
436        ]);
437        let error =
438            resolve_stack_input_environment_variables(&inputs, &values, Platform::Kubernetes)
439                .expect_err("duplicate names must be rejected");
440        assert!(error.message.contains("SHARED"));
441    }
442
443    #[test]
444    fn inputs_for_other_platforms_produce_nothing() {
445        // An AWS-only input with a default must not reach a Kubernetes
446        // workload, and may share a variable name with a Kubernetes one.
447        let mut aws_only = input("aws-region", StackInputKind::String, "REGION");
448        aws_only.platforms = Some(vec![Platform::Aws]);
449        aws_only.default = Some(StackInputDefaultValue::String("us-east-1".to_string()));
450        let mut kubernetes_only = input("zone", StackInputKind::String, "REGION");
451        kubernetes_only.platforms = Some(vec![Platform::Kubernetes]);
452        let values = HashMap::from([("zone".to_string(), serde_json::json!("rack-7"))]);
453
454        let variables = resolve_stack_input_environment_variables(
455            &[aws_only, kubernetes_only],
456            &values,
457            Platform::Kubernetes,
458        )
459        .unwrap();
460
461        assert_eq!(variables.len(), 1);
462        assert_eq!(variables[0].name, "REGION");
463        assert_eq!(variables[0].value, "rack-7");
464    }
465}