Skip to main content

isoloom_spec/model/
cloud.rs

1//! Cloud services an environment deploys beside its machines, and how their outputs fill in.
2
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::placeholders::{self, Piece};
8
9/// An environment of cloud services: a Terraform module applied into the user's account.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct CloudServices {
13    /// The cloud the module deploys into (its credentials come from the environment, as
14    /// Terraform expects).
15    pub provider: CloudProvider,
16    /// A Terraform root module in the project (a folder holding `.tf` files), used as is.
17    pub terraform: String,
18    /// The module's variables: any value (a string, number, boolean, list or map). A string
19    /// holding `{{ inputs.NAME }}`, at any depth (`["{{ inputs.PLAYER_IP }}/32"]`), takes the
20    /// launch-time input `NAME` (declared in `inputs`), e.g. the user's IP for an allow-list:
21    /// required at `isothrum up`, kept for `isothrum down`.
22    #[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    /// What the environment exposes, by name: a module output each. Checks and the message
26    /// use them as `{{ cloud.outputs.<name> }}`.
27    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
28    pub outputs: IndexMap<String, String>,
29    /// About what the environment costs per hour while it exists, in US dollars: the author's
30    /// estimate, for whoever runs it. `isothrum up` says it before applying, and the resolved
31    /// snapshot carries it (`cloud.hourly_usd`) for anything that shows costs or keeps to a
32    /// budget. Isoloom doesn't bill or stop anything by it.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub hourly_usd: Option<f64>,
35    /// Steps run on this machine before Terraform applies the module (a key pair the module
36    /// reads, a build): scripts in the project, run in order from the module's copy, with the
37    /// spec's inputs and `env` in their environment. They run at every `isothrum up`: make them
38    /// idempotent.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub before: Vec<String>,
41    /// Environment variables for Terraform and the `before` steps (`AWS_REGION: us-east-1`).
42    /// Not for credentials: those come from the user's own environment.
43    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
44    pub env: IndexMap<String, String>,
45    /// How many times to apply again when an apply fails (a module that only converges on a
46    /// second apply), 0 to 5. 0 by default.
47    #[serde(default, skip_serializing_if = "super::is_default")]
48    pub apply_retries: u8,
49    /// What the module needs on this machine besides Terraform: a command name (`jq`, `gsed`)
50    /// that must be on the PATH, or a shell command that must succeed
51    /// (`python3 -c 'import boto3'`). `isothrum up` checks them before applying.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub requires: Vec<String>,
54}
55
56/// A cloud an environment of cloud services deploys into.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "lowercase")]
59pub enum CloudProvider {
60    /// Amazon Web Services.
61    Aws,
62    /// Microsoft Azure.
63    Azure,
64    /// Google Cloud.
65    Gcp,
66}
67
68impl CloudServices {
69    /// The launch-time input a variable takes, when its value is exactly `{{ inputs.NAME }}`.
70    /// A value can also hold inputs inside its strings, at any depth: see
71    /// [`inputs_in`](Self::inputs_in).
72    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    /// The launch-time inputs a variable's value takes: every `{{ inputs.NAME }}` in its strings,
78    /// at any depth, in order (repeats kept).
79    #[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    /// The value with each `{{ inputs.NAME }}` in its strings, at any depth, replaced by
90    /// `input(NAME)` (left as written when it gives nothing).
91    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
102/// `s` with each `{{ inputs.NAME }}` replaced by `input(NAME)`; any other `{{ ... }}`, and an
103/// input `input` gives nothing for, left as written.
104fn 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    /// The name the spec writes it with (`aws`, `azure`, `gcp`).
129    #[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    /// A module variable holding inputs at every depth, beside other placeholders.
153    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}