isoloom_spec/model/
cloud.rs1use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::placeholders::{self, Piece};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct CloudServices {
13 pub provider: CloudProvider,
16 pub terraform: String,
18 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
23 #[schemars(with = "IndexMap<String, serde_json::Value>")]
24 pub vars: IndexMap<String, serde_json::Value>,
25 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
28 pub outputs: IndexMap<String, String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub hourly_usd: Option<f64>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub before: Vec<String>,
41 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
44 pub env: IndexMap<String, String>,
45 #[serde(default, skip_serializing_if = "super::is_default")]
48 pub apply_retries: u8,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub requires: Vec<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "lowercase")]
59pub enum CloudProvider {
60 Aws,
62 Azure,
64 Gcp,
66}
67
68impl CloudServices {
69 pub fn input_of(value: &serde_json::Value) -> Option<&str> {
73 let s = value.as_str()?.trim();
74 s.strip_prefix("{{ inputs.")?.strip_suffix(" }}").map(str::trim)
75 }
76
77 #[must_use]
80 pub fn inputs_in(value: &serde_json::Value) -> Vec<String> {
81 let mut found = Vec::new();
82 Self::fill_inputs(value, &mut |name| {
83 found.push(name.to_owned());
84 Some(String::new())
85 });
86 found
87 }
88
89 pub fn fill_inputs(value: &serde_json::Value, input: &mut dyn FnMut(&str) -> Option<String>) -> serde_json::Value {
92 use serde_json::Value;
93 match value {
94 Value::String(s) => Value::String(fill_string(s, input)),
95 Value::Array(a) => Value::Array(a.iter().map(|v| Self::fill_inputs(v, input)).collect()),
96 Value::Object(m) => Value::Object(m.iter().map(|(k, v)| (k.clone(), Self::fill_inputs(v, input))).collect()),
97 other => other.clone(),
98 }
99 }
100}
101
102fn fill_string(s: &str, input: &mut dyn FnMut(&str) -> Option<String>) -> String {
105 let mut out = String::new();
106 for piece in placeholders::pieces(s) {
107 match piece {
108 Piece::Text(text) => out.push_str(text),
109 Piece::Placeholder(inner) => {
110 if let Some(value) = inner.trim().strip_prefix("inputs.").map(str::trim).and_then(&mut *input) {
111 out.push_str(&value);
112 } else {
113 out.push_str("{{");
114 out.push_str(inner);
115 out.push_str("}}");
116 }
117 }
118 Piece::Unclosed(after) => {
119 out.push_str("{{");
120 out.push_str(after);
121 }
122 }
123 }
124 out
125}
126
127impl CloudProvider {
128 #[must_use]
130 pub fn id(self) -> &'static str {
131 match self {
132 CloudProvider::Aws => "aws",
133 CloudProvider::Azure => "azure",
134 CloudProvider::Gcp => "gcp",
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use serde_json::json;
142
143 use super::*;
144
145 #[test]
146 fn a_variable_takes_an_input_when_it_is_exactly_one() {
147 assert_eq!(CloudServices::input_of(&json!(" {{ inputs.PLAYER_IP }} ")), Some("PLAYER_IP"));
148 assert_eq!(CloudServices::input_of(&json!("{{ inputs.PLAYER_IP }}/32")), None);
149 assert_eq!(CloudServices::input_of(&json!(3)), None);
150 }
151
152 fn variable() -> serde_json::Value {
154 json!({
155 "allow": ["{{ inputs.PLAYER_IP }}/32", 22],
156 "name": "{{inputs.TEAM}}-{{ inputs.PLAYER_IP }}",
157 "other": "{{ cloud.outputs.url }} {{ inputs.TEAM",
158 })
159 }
160
161 #[test]
162 fn inputs_are_found_at_any_depth_in_order() {
163 assert_eq!(CloudServices::inputs_in(&variable()), ["PLAYER_IP", "TEAM", "PLAYER_IP"]);
164 }
165
166 #[test]
167 fn inputs_are_filled_and_everything_else_stays_as_written() {
168 let filled = CloudServices::fill_inputs(&variable(), &mut |name| (name == "PLAYER_IP").then(|| "198.51.100.7".to_owned()));
169 assert_eq!(
170 filled,
171 json!({
172 "allow": ["198.51.100.7/32", 22],
173 "name": "{{inputs.TEAM}}-198.51.100.7",
174 "other": "{{ cloud.outputs.url }} {{ inputs.TEAM",
175 })
176 );
177 }
178
179 #[test]
180 fn a_cloud_goes_by_the_name_a_spec_writes() -> Result<(), serde_yaml_ng::Error> {
181 for cloud in [CloudProvider::Aws, CloudProvider::Azure, CloudProvider::Gcp] {
182 assert_eq!(serde_yaml_ng::from_str::<CloudProvider>(cloud.id())?, cloud);
183 }
184 Ok(())
185 }
186}