1use serde::{Deserialize, Serialize};
2
3use crate::Platform;
4
5#[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 Developer,
12 Deployer,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19#[serde(rename_all = "camelCase")]
20pub enum StackInputKind {
21 String,
23 Secret,
25 Number,
27 Integer,
29 Boolean,
31 Enum,
33 StringList,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase")]
41pub struct StackInputEnvironmentMapping {
42 pub name: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub target_resources: Option<Vec<String>>,
47 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
49 pub var_type: Option<StackInputEnvironmentVariableType>,
50}
51
52impl StackInputEnvironmentMapping {
53 pub fn targets(&self, resource_id: &str) -> bool {
55 targets_resource(&self.target_resources, resource_id)
56 }
57}
58
59pub 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#[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,
82 Secret,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase")]
90pub struct StackInputValidation {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub min_length: Option<u32>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub max_length: Option<u32>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub pattern: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub format: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub min: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub max: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub values: Option<Vec<String>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub min_items: Option<u32>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub max_items: Option<u32>,
118}
119
120#[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(String),
127 Number(String),
129 Boolean(bool),
131 StringList(Vec<String>),
133}
134
135pub const STACK_INPUT_GENERATE_MIN_LENGTH: u32 = 16;
137pub const STACK_INPUT_GENERATE_MAX_LENGTH: u32 = 256;
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148#[serde(rename_all = "camelCase")]
149pub struct StackInputGenerate {
150 pub length: u32,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "camelCase")]
158pub struct StackInputDefinition {
159 pub id: String,
161 pub kind: StackInputKind,
163 pub provided_by: Vec<StackInputProvider>,
165 pub required: bool,
167 pub label: String,
169 pub description: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub placeholder: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub default: Option<StackInputDefaultValue>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub platforms: Option<Vec<Platform>>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub validation: Option<StackInputValidation>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub generate: Option<StackInputGenerate>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub env: Vec<StackInputEnvironmentMapping>,
190}
191
192impl StackInputDefinition {
193 pub fn is_generated(&self) -> bool {
196 self.generate.is_some()
197 }
198
199 #[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
228pub 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#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum GateInputIssue {
250 Undeclared,
252 NotBoolean(StackInputKind),
254}
255
256pub 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 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}