1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
//! Cloud services an environment deploys beside its machines, and how their outputs fill in.
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::placeholders::{self, Piece};
/// An environment of cloud services: a Terraform module applied into the user's account.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CloudServices {
/// The cloud the module deploys into (its credentials come from the environment, as
/// Terraform expects).
pub provider: CloudProvider,
/// A Terraform root module in the project (a folder holding `.tf` files), used as is.
pub terraform: String,
/// The module's variables: any value (a string, number, boolean, list or map). A string
/// holding `{{ inputs.NAME }}`, at any depth (`["{{ inputs.PLAYER_IP }}/32"]`), takes the
/// launch-time input `NAME` (declared in `inputs`), e.g. the user's IP for an allow-list:
/// required at `run`, kept for `down`.
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
#[schemars(with = "IndexMap<String, serde_json::Value>")]
pub vars: IndexMap<String, serde_json::Value>,
/// What the environment exposes, by name: a module output each. Checks and the message
/// use them as `{{ cloud.outputs.<name> }}`.
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub outputs: IndexMap<String, String>,
/// About what the environment costs per hour while it exists, in US dollars: the author's
/// estimate, for whoever runs it. `isoloom run` says it before applying, and the resolved
/// snapshot carries it (`cloud.hourly_usd`) for anything that shows costs or keeps to a
/// budget. Isoloom doesn't bill or stop anything by it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hourly_usd: Option<f64>,
/// Steps run on this machine before Terraform applies the module (a key pair the module
/// reads, a build): scripts in the project, run in order from the module's copy, with the
/// spec's inputs and `env` in their environment. They run at every `run`: make them
/// idempotent.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub before: Vec<String>,
/// Environment variables for Terraform and the `before` steps (`AWS_REGION: us-east-1`).
/// Not for credentials: those come from the user's own environment.
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub env: IndexMap<String, String>,
/// How many times to apply again when an apply fails (a module that only converges on a
/// second apply), 0 to 5. 0 by default.
#[serde(default, skip_serializing_if = "super::is_default")]
pub apply_retries: u8,
/// What the module needs on this machine besides Terraform: a command name (`jq`, `gsed`)
/// that must be on the PATH, or a shell command that must succeed
/// (`python3 -c 'import boto3'`). `isoloom doctor` and `isoloom run` check them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
}
/// A cloud an environment of cloud services deploys into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum CloudProvider {
/// Amazon Web Services.
Aws,
/// Microsoft Azure.
Azure,
/// Google Cloud.
Gcp,
}
impl CloudServices {
/// The launch-time input a variable takes, when its value is exactly `{{ inputs.NAME }}`.
/// A value can also hold inputs inside its strings, at any depth: see
/// [`inputs_in`](Self::inputs_in).
pub fn input_of(value: &serde_json::Value) -> Option<&str> {
let s = value.as_str()?.trim();
s.strip_prefix("{{ inputs.")?.strip_suffix(" }}").map(str::trim)
}
/// The launch-time inputs a variable's value takes: every `{{ inputs.NAME }}` in its strings,
/// at any depth, in order (repeats kept).
#[must_use]
pub fn inputs_in(value: &serde_json::Value) -> Vec<String> {
let mut found = Vec::new();
Self::fill_inputs(value, &mut |name| {
found.push(name.to_owned());
Some(String::new())
});
found
}
/// The value with each `{{ inputs.NAME }}` in its strings, at any depth, replaced by
/// `input(NAME)` (left as written when it gives nothing).
pub fn fill_inputs(value: &serde_json::Value, input: &mut dyn FnMut(&str) -> Option<String>) -> serde_json::Value {
use serde_json::Value;
match value {
Value::String(s) => Value::String(fill_string(s, input)),
Value::Array(a) => Value::Array(a.iter().map(|v| Self::fill_inputs(v, input)).collect()),
Value::Object(m) => Value::Object(m.iter().map(|(k, v)| (k.clone(), Self::fill_inputs(v, input))).collect()),
other => other.clone(),
}
}
}
/// `s` with each `{{ inputs.NAME }}` replaced by `input(NAME)`; any other `{{ ... }}`, and an
/// input `input` gives nothing for, left as written.
fn fill_string(s: &str, input: &mut dyn FnMut(&str) -> Option<String>) -> String {
let mut out = String::new();
for piece in placeholders::pieces(s) {
match piece {
Piece::Text(text) => out.push_str(text),
Piece::Placeholder(inner) => {
if let Some(value) = inner.trim().strip_prefix("inputs.").map(str::trim).and_then(&mut *input) {
out.push_str(&value);
} else {
out.push_str("{{");
out.push_str(inner);
out.push_str("}}");
}
}
Piece::Unclosed(after) => {
out.push_str("{{");
out.push_str(after);
}
}
}
out
}
impl CloudProvider {
/// The name the spec writes it with (`aws`, `azure`, `gcp`).
#[must_use]
pub fn id(self) -> &'static str {
match self {
CloudProvider::Aws => "aws",
CloudProvider::Azure => "azure",
CloudProvider::Gcp => "gcp",
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_variable_takes_an_input_when_it_is_exactly_one() {
assert_eq!(CloudServices::input_of(&json!(" {{ inputs.PLAYER_IP }} ")), Some("PLAYER_IP"));
assert_eq!(CloudServices::input_of(&json!("{{ inputs.PLAYER_IP }}/32")), None);
assert_eq!(CloudServices::input_of(&json!(3)), None);
}
/// A module variable holding inputs at every depth, beside other placeholders.
fn variable() -> serde_json::Value {
json!({
"allow": ["{{ inputs.PLAYER_IP }}/32", 22],
"name": "{{inputs.TEAM}}-{{ inputs.PLAYER_IP }}",
"other": "{{ cloud.outputs.url }} {{ inputs.TEAM",
})
}
#[test]
fn inputs_are_found_at_any_depth_in_order() {
assert_eq!(CloudServices::inputs_in(&variable()), ["PLAYER_IP", "TEAM", "PLAYER_IP"]);
}
#[test]
fn inputs_are_filled_and_everything_else_stays_as_written() {
let filled = CloudServices::fill_inputs(&variable(), &mut |name| (name == "PLAYER_IP").then(|| "198.51.100.7".to_owned()));
assert_eq!(
filled,
json!({
"allow": ["198.51.100.7/32", 22],
"name": "{{inputs.TEAM}}-198.51.100.7",
"other": "{{ cloud.outputs.url }} {{ inputs.TEAM",
})
);
}
#[test]
fn a_cloud_goes_by_the_name_a_spec_writes() -> Result<(), serde_yaml_ng::Error> {
for cloud in [CloudProvider::Aws, CloudProvider::Azure, CloudProvider::Gcp] {
assert_eq!(serde_yaml_ng::from_str::<CloudProvider>(cloud.id())?, cloud);
}
Ok(())
}
}