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, Default, 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 StackInputCondition {
159 pub input: String,
161 pub equals: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168#[serde(rename_all = "camelCase")]
169pub struct StackInputDefinition {
170 pub id: String,
172 pub kind: StackInputKind,
174 pub provided_by: Vec<StackInputProvider>,
176 pub required: bool,
178 pub label: String,
180 pub description: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub placeholder: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub default: Option<StackInputDefaultValue>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub platforms: Option<Vec<Platform>>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub when: Option<StackInputCondition>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub validation: Option<StackInputValidation>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub generate: Option<StackInputGenerate>,
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub env: Vec<StackInputEnvironmentMapping>,
205}
206
207impl StackInputDefinition {
208 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 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 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 pub fn is_generated(&self) -> bool {
269 self.generate.is_some()
270 }
271
272 #[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
302pub 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#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum GateInputIssue {
324 Undeclared,
326 NotBoolean(StackInputKind),
328}
329
330pub 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 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}