Skip to main content

alien_core/
stack_input_resolution.rs

1//! Resolves applicable inputs before checking requiredness or portable constraints.
2
3use std::collections::HashMap;
4
5use alien_error::{AlienError, Context, IntoAlienError};
6use serde_json::Value;
7
8use crate::{
9    is_deployer_secret_input, ErrorData, Platform, Result, StackInputDefaultValue,
10    StackInputDefinition, StackInputKind,
11};
12
13/// Resolve defaults and validate only inputs that apply. Values for inactive
14/// inputs are ignored, even when they would fail validation. Vault-native
15/// secrets are checked in the deployer's secret store instead.
16pub fn resolve_stack_input_values(
17    inputs: &[StackInputDefinition],
18    values: &HashMap<String, Value>,
19    platform: Platform,
20) -> Result<HashMap<String, Value>> {
21    resolve_selected_stack_input_values(inputs, inputs.iter(), values, platform)
22}
23
24/// Validate registration answers. Developer-only inputs are configured separately,
25/// so only validate those when an answer was actually supplied.
26pub fn validate_stack_import_input_values(
27    inputs: &[StackInputDefinition],
28    values: &HashMap<String, Value>,
29    platform: Platform,
30) -> Result<()> {
31    let selected = inputs.iter().filter(|input| {
32        input
33            .provided_by
34            .contains(&crate::StackInputProvider::Deployer)
35            || values.get(&input.id).is_some_and(|value| !value.is_null())
36    });
37    resolve_selected_stack_input_values(inputs, selected, values, platform).map(|_| ())
38}
39
40fn resolve_selected_stack_input_values<'a>(
41    inputs: &[StackInputDefinition],
42    selected: impl Iterator<Item = &'a StackInputDefinition>,
43    values: &HashMap<String, Value>,
44    platform: Platform,
45) -> Result<HashMap<String, Value>> {
46    let mut resolved = HashMap::new();
47    for input in selected {
48        if !input.applies(inputs, values, platform) {
49            continue;
50        }
51        let value = values
52            .get(&input.id)
53            .filter(|value| !value.is_null())
54            .cloned()
55            .or_else(|| {
56                input.default.as_ref().map(|default| match default {
57                    StackInputDefaultValue::String(value)
58                    | StackInputDefaultValue::Number(value) => Value::String(value.clone()),
59                    StackInputDefaultValue::Boolean(value) => Value::Bool(*value),
60                    StackInputDefaultValue::StringList(value) => serde_json::json!(value),
61                })
62            });
63        let Some(value) = value else {
64            if input.required && !is_deployer_secret_input(input) && !input.is_generated() {
65                return Err(invalid(input, "a value is required"));
66            }
67            continue;
68        };
69        let unanswered = match input.kind {
70            StackInputKind::StringList => value.as_array().is_some_and(Vec::is_empty),
71            _ => value.as_str() == Some(""),
72        };
73        if !input.required && unanswered {
74            continue;
75        }
76        let value = normalize(input, value)?;
77        if input.required
78            && (value.as_str() == Some("") || value.as_array().is_some_and(Vec::is_empty))
79        {
80            return Err(invalid(input, "a non-empty value is required"));
81        }
82        validate(input, &value)?;
83        resolved.insert(input.id.clone(), value);
84    }
85    Ok(resolved)
86}
87
88fn invalid(input: &StackInputDefinition, reason: &str) -> AlienError<ErrorData> {
89    AlienError::new(ErrorData::StackInputInvalid {
90        input_id: input.id.clone(),
91        reason: reason.into(),
92    })
93}
94
95fn normalize(input: &StackInputDefinition, value: Value) -> Result<Value> {
96    match input.kind {
97        StackInputKind::Boolean => crate::gate_value_as_bool(&value)
98            .map(Value::Bool)
99            .ok_or_else(|| invalid(input, "must be a boolean")),
100        StackInputKind::Integer | StackInputKind::Number => {
101            let number = value
102                .as_f64()
103                .or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
104                .filter(|number| number.is_finite())
105                .ok_or_else(|| invalid(input, "must be a finite number"))?;
106            if input.kind == StackInputKind::Integer && number.fract() != 0.0 {
107                return Err(invalid(input, "must be an integer"));
108            }
109            if value.is_number() {
110                return Ok(value);
111            }
112            let parsed = value
113                .as_str()
114                .and_then(|value| value.parse::<serde_json::Number>().ok());
115            Ok(parsed
116                .map(Value::Number)
117                .unwrap_or_else(|| serde_json::json!(number)))
118        }
119        StackInputKind::StringList => {
120            if !value
121                .as_array()
122                .is_some_and(|items| items.iter().all(Value::is_string))
123            {
124                return Err(invalid(input, "must be a list of strings"));
125            }
126            Ok(value)
127        }
128        StackInputKind::String | StackInputKind::Secret | StackInputKind::Enum => {
129            if !value.is_string() {
130                return Err(invalid(input, "must be a string"));
131            }
132            Ok(value)
133        }
134    }
135}
136
137fn validate(input: &StackInputDefinition, value: &Value) -> Result<()> {
138    let Some(validation) = &input.validation else {
139        return Ok(());
140    };
141    if let Some(text) = value.as_str() {
142        let length = text.chars().count();
143        if validation
144            .min_length
145            .is_some_and(|min| length < min as usize)
146            || validation
147                .max_length
148                .is_some_and(|max| length > max as usize)
149        {
150            return Err(invalid(input, "length is outside the declared bounds"));
151        }
152        if validation
153            .values
154            .as_ref()
155            .is_some_and(|values| !values.iter().any(|candidate| candidate == text))
156        {
157            return Err(invalid(input, "must be one of the declared values"));
158        }
159        if let Some(pattern) = &validation.pattern {
160            let regex = regex::Regex::new(&format!("^(?:{pattern})$"))
161                .into_alien_error()
162                .context(ErrorData::StackInputInvalid {
163                    input_id: input.id.clone(),
164                    reason: "declares an invalid pattern".into(),
165                })?;
166            if !regex.is_match(text) {
167                return Err(invalid(input, "does not match the declared pattern"));
168            }
169        }
170        if validation.format.as_deref() == Some("url") && url::Url::parse(text).is_err() {
171            return Err(invalid(input, "must be a URL"));
172        }
173    }
174    if let Some(number) = value.as_f64() {
175        for (bound, minimum) in [(&validation.min, true), (&validation.max, false)] {
176            if let Some(bound) = bound {
177                let bound = bound.parse::<f64>().into_alien_error().context(
178                    ErrorData::StackInputInvalid {
179                        input_id: input.id.clone(),
180                        reason: "declares an invalid numeric bound".into(),
181                    },
182                )?;
183                if (minimum && number < bound) || (!minimum && number > bound) {
184                    return Err(invalid(input, "number is outside the declared bounds"));
185                }
186            }
187        }
188    }
189    if let Some(items) = value.as_array() {
190        if validation
191            .min_items
192            .is_some_and(|min| items.len() < min as usize)
193            || validation
194                .max_items
195                .is_some_and(|max| items.len() > max as usize)
196        {
197            return Err(invalid(input, "item count is outside the declared bounds"));
198        }
199    }
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn inputs() -> Vec<StackInputDefinition> {
208        serde_json::from_value(serde_json::json!([
209            {"id":"bucket", "kind":"string", "required":true, "providedBy":["deployer"], "label":"Bucket", "description":"Snapshot bucket", "when":{"input":"enabled","equals":true}, "validation":{"minLength":3}},
210            {"id":"enabled", "kind":"boolean", "required":true, "providedBy":["deployer"], "label":"Enabled", "description":"Enable snapshots", "default":{"type":"boolean","value":true}}
211        ])).unwrap()
212    }
213
214    #[test]
215    fn optional_blank_values_must_match_the_declared_kind() {
216        for kind in ["string", "integer", "number", "boolean", "enum", "secret"] {
217            let definition: StackInputDefinition = serde_json::from_value(serde_json::json!({
218                "id":"answer", "kind":kind, "providedBy":["deployer"], "required":false,
219                "label":"Answer", "description":"Optional answer"
220            }))
221            .unwrap();
222            let values = HashMap::from([("answer".into(), serde_json::json!([]))]);
223            let error =
224                resolve_stack_input_values(&[definition], &values, Platform::Aws).unwrap_err();
225            assert_eq!(error.code, "STACK_INPUT_INVALID", "{kind}");
226        }
227    }
228
229    #[test]
230    fn optional_blank_values_are_unanswered_before_constraints() {
231        let inputs: Vec<StackInputDefinition> = serde_json::from_value(serde_json::json!([
232            {"id":"url", "kind":"string", "providedBy":["deployer"], "required":false,
233             "label":"URL", "description":"Optional URL", "validation":{"format":"url"}},
234            {"id":"items", "kind":"stringList", "providedBy":["deployer"], "required":false,
235             "label":"Items", "description":"Optional items", "validation":{"minItems":1}}
236        ]))
237        .unwrap();
238        let values = HashMap::from([
239            ("url".into(), serde_json::json!("")),
240            ("items".into(), serde_json::json!([])),
241        ]);
242        assert!(resolve_stack_input_values(&inputs, &values, Platform::Aws)
243            .unwrap()
244            .is_empty());
245    }
246
247    #[test]
248    fn inactive_required_input_is_ignored_including_invalid_values() {
249        let inputs = inputs();
250        for value in [Value::Null, serde_json::json!(42), serde_json::json!("x")] {
251            let values = HashMap::from([
252                ("enabled".into(), Value::Bool(false)),
253                ("bucket".into(), value),
254            ]);
255            assert_eq!(
256                resolve_stack_input_values(&inputs, &values, Platform::Aws).unwrap(),
257                HashMap::from([("enabled".into(), Value::Bool(false))])
258            );
259        }
260    }
261
262    #[test]
263    fn active_input_is_required_and_validated_with_provided_or_default_toggle() {
264        for toggle in [None, Some(Value::Bool(true))] {
265            for bucket in [
266                None,
267                Some(serde_json::json!("")),
268                Some(serde_json::json!("x")),
269                Some(serde_json::json!(42)),
270            ] {
271                let mut values = HashMap::new();
272                if let Some(value) = &toggle {
273                    values.insert("enabled".into(), value.clone());
274                }
275                if let Some(value) = bucket {
276                    values.insert("bucket".into(), value);
277                }
278                let error =
279                    resolve_stack_input_values(&inputs(), &values, Platform::Aws).unwrap_err();
280                assert_eq!(error.code, "STACK_INPUT_INVALID");
281                assert!(error.message.contains("bucket"));
282            }
283        }
284        let values = HashMap::from([("bucket".into(), serde_json::json!("snapshots"))]);
285        let resolved = resolve_stack_input_values(&inputs(), &values, Platform::Aws).unwrap();
286        assert_eq!(resolved["bucket"], "snapshots");
287        assert_eq!(resolved["enabled"], true);
288    }
289}