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
75pub 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#[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,
95 Secret,
97}
98
99#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub min_length: Option<u32>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub max_length: Option<u32>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub pattern: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub format: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub min: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub max: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub values: Option<Vec<String>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub min_items: Option<u32>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub max_items: Option<u32>,
131}
132
133#[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(String),
140 Number(String),
142 Boolean(bool),
144 StringList(Vec<String>),
146}
147
148pub const STACK_INPUT_GENERATE_MIN_LENGTH: u32 = 16;
150pub const STACK_INPUT_GENERATE_MAX_LENGTH: u32 = 256;
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
161#[serde(rename_all = "camelCase")]
162pub struct StackInputGenerate {
163 pub length: u32,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170#[serde(rename_all = "camelCase")]
171pub struct StackInputCondition {
172 pub input: String,
174 pub equals: bool,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
181#[serde(rename_all = "camelCase")]
182pub struct StackInputDefinition {
183 pub id: String,
185 pub kind: StackInputKind,
187 pub provided_by: Vec<StackInputProvider>,
189 pub required: bool,
191 pub label: String,
193 pub description: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub placeholder: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub default: Option<StackInputDefaultValue>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub platforms: Option<Vec<Platform>>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub when: Option<StackInputCondition>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub validation: Option<StackInputValidation>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub generate: Option<StackInputGenerate>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub env: Vec<StackInputEnvironmentMapping>,
218}
219
220impl StackInputDefinition {
221 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 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 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 pub fn is_generated(&self) -> bool {
282 self.generate.is_some()
283 }
284
285 #[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
315pub 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#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum GateInputIssue {
337 Undeclared,
339 NotBoolean(StackInputKind),
341}
342
343pub 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 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}