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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! Steps: one native command (an argv) or one documented driver action, with the folder,
//! environment, input, time limit and failure policy it runs under.
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::argv::HostArgv;
use crate::identity::{HostOs, Provider};
use crate::names::{EnvName, SnakeName, StepId};
use crate::path::{PathTemplate, RelPath};
use crate::template::Template;
use crate::{Error, Result};
/// A driver primitive: a read-then-act sequence one argv can't express (list, then remove what
/// was listed). The manifest's documentation fixes each action's native commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum Action {
/// Pins the ephemeral host ports of a fresh run in the pinned-ports file.
#[serde(rename = "compose.pin_ports")]
ComposePinPorts,
/// Fails with `PortInUse` when a published host port is taken (unless the run is up).
#[serde(rename = "compose.check_ports_free")]
ComposeCheckPortsFree,
/// Removes the project's networks its Compose files no longer define.
#[serde(rename = "compose.remove_stale_networks")]
ComposeRemoveStaleNetworks,
/// Removes the project's containers (`with`: `service`, `one_off`).
#[serde(rename = "compose.remove_containers")]
ComposeRemoveContainers,
/// Unplugs and removes every network of the project.
#[serde(rename = "compose.remove_networks")]
ComposeRemoveNetworks,
/// Destroys a run recorded under another provider.
#[serde(rename = "vagrant.reconcile_provider")]
VagrantReconcileProvider,
/// Discards a VirtualBox VM's aborted saved state.
#[serde(rename = "vagrant.discard_aborted_saved")]
VagrantDiscardAbortedSaved,
/// Removes this run's VMs Vagrant lost track of, by their exact names.
#[serde(rename = "vagrant.remove_orphans")]
VagrantRemoveOrphans,
/// Removes Vagrant's state folder.
#[serde(rename = "vagrant.reset_state")]
VagrantResetState,
/// Boots the controller, waiting for a halting one first.
#[serde(rename = "vagrant.wake_controller")]
VagrantWakeController,
/// Halts the controller, waiting for one that powers itself off.
#[serde(rename = "vagrant.park_controller")]
VagrantParkController,
/// Fails with `PortInUse` when a host port the VMs forward (a published port with a `fixed`
/// host port) is taken, unless the run's VMs exist: two labs forwarding one port collide.
#[serde(rename = "vagrant.check_forwarded_ports")]
VagrantCheckForwardedPorts,
/// Removes a stale Terraform state lock.
#[serde(rename = "terraform.clear_lock")]
TerraformClearLock,
/// Reads the Terraform state's outputs, resources and taint.
#[serde(rename = "terraform.read_state")]
TerraformReadState,
/// Copies the lab's own Terraform module (`with`: `from`, its folder relative to the
/// manifest's, inside the project) into `${run.state_dir}/module`, where the steps run it:
/// the lock file and what the module writes stay with the run, out of the project. The
/// copy's Terraform files are replaced (a file renamed or removed in the module isn't applied
/// any more), what the module wrote there before is kept, and `.terraform` and state files
/// are left out. `with.when: missing` copies only when there is no copy (a `down` destroys
/// with the module as it was applied).
#[serde(rename = "terraform.copy_module")]
TerraformCopyModule,
/// Runs a command over SSH with the runner's key (`with`: `host`, `user`, `via`, `command`;
/// `wait_s`, at most 1800, the seconds the runner retries while ssh itself can't connect
/// (its exit 255 before the command ran: a machine still booting), its failure then naming the
/// machine's address. Only for an idempotent command, such as a delivery).
#[serde(rename = "ssh.run")]
SshRun,
/// Archives the project as `${run.state_dir}/project.tgz` (`with`: `root`, the project's folder
/// relative to the manifest's): Isoloom's outputs in, version control, the tools' state and
/// what `.isoloomignore` lists ([`crate::ignore`]) out. A step pipes it to a guest.
#[serde(rename = "project.archive")]
ProjectArchive,
}
/// When a step runs.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum When {
/// Skipped once a step of the operation failed.
#[default]
IfOk,
/// A clean-up that runs anyway.
Always,
}
/// What a step's failure does.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Failure {
/// Fails the operation.
#[default]
Fail,
/// Logs and goes on.
Tolerate,
}
/// What a step runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Run {
/// A native command.
Argv(HostArgv),
/// A driver action, with its arguments.
Action {
/// The action.
action: Action,
/// Its arguments (templates).
with: BTreeMap<SnakeName, Template>,
},
}
/// One step of an operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "StepFields", into = "StepFields")]
pub struct Step {
/// Its id, unique within its list.
pub id: StepId,
/// A line the runner emits when the step starts.
pub note: Option<String>,
/// What it runs.
pub run: Run,
/// Its working folder (default: the manifest's folder).
pub cwd: Option<RelPath>,
/// Its non-secret environment, values as templates.
pub env: BTreeMap<EnvName, Template>,
/// A file piped into the program: a generated one beside the manifest (a guest script), or one
/// of the run's state folder (`${run.state_dir}/project.tgz`). A shell runs in the guest, never
/// on the host.
pub stdin: Option<PathTemplate>,
/// How long it may run.
pub timeout_s: Option<NonZeroU32>,
/// How long it may run on these Vagrant providers, in place of `timeout_s` (only there): a
/// provider whose tool is slower (`vmware_esxi` asks the ESXi host once per VM).
pub provider_timeout_s: BTreeMap<Provider, NonZeroU32>,
/// When it runs.
pub when: When,
/// What its failure does.
pub failure: Failure,
/// Only on these hosts (empty: every host).
pub hosts: BTreeSet<HostOs>,
/// Only on these Vagrant providers (empty: every provider).
pub providers: BTreeSet<Provider>,
/// A regular expression over the step's lines; the last match leads the step's error.
pub verdict: Option<String>,
}
impl Step {
/// A step running `argv`, with every other field at its default.
#[must_use]
pub fn argv(id: StepId, argv: HostArgv) -> Step {
Step::new(id, Run::Argv(argv))
}
/// A step running `action` with no argument, every other field at its default.
#[must_use]
pub fn action(id: StepId, action: Action) -> Step {
Step::new(id, Run::Action { action, with: BTreeMap::new() })
}
fn new(id: StepId, run: Run) -> Step {
Step {
id,
note: None,
run,
cwd: None,
env: BTreeMap::new(),
stdin: None,
timeout_s: None,
provider_timeout_s: BTreeMap::new(),
when: When::IfOk,
failure: Failure::Fail,
hosts: BTreeSet::new(),
providers: BTreeSet::new(),
verdict: None,
}
}
}
/// Whether `v` is its type's default: a field left out when it holds it.
pub(crate) fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
/// A step as written: `argv` or `action` (with its `with`), never both.
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(
title = "Step",
description = "One step of an operation: an `argv` or an `action`, never both.",
extend("oneOf" = [{ "required": ["argv"] }, { "required": ["action"] }], "dependentRequired" = { "with": ["action"] })
)]
struct StepFields {
/// Its id, unique within its list.
id: StepId,
/// A line the runner emits when the step starts.
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
/// A native command, run without a shell.
#[serde(default, skip_serializing_if = "Option::is_none")]
argv: Option<HostArgv>,
/// A driver action.
#[serde(default, skip_serializing_if = "Option::is_none")]
action: Option<Action>,
/// An action's arguments (templates).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
with: BTreeMap<SnakeName, Template>,
/// Its working folder (default: the manifest's folder).
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<RelPath>,
/// Non-secret environment of this step, values as templates (`TF_DATA_DIR`,
/// `TF_IN_AUTOMATION`). Secrets and params come through their slots.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
env: BTreeMap<EnvName, Template>,
/// A file piped into the program: a generated one beside the manifest (a check script into
/// `docker compose exec -T <m> sh -s` or `ssh … sh -s`), or one of the run's state folder (the
/// project's archive, `${run.state_dir}/project.tgz`). A shell runs in the guest, never on the
/// host.
#[serde(default, skip_serializing_if = "Option::is_none")]
stdin: Option<PathTemplate>,
/// How long it may run, in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_s: Option<NonZeroU32>,
/// How long it may run on these Vagrant providers, in seconds, in place of `timeout_s`
/// there only (`{"vmware_esxi": 120}`).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
provider_timeout_s: BTreeMap<Provider, NonZeroU32>,
/// `if_ok` (default): skipped once a step of the operation failed. `always`: a clean-up that
/// runs anyway.
#[serde(default, skip_serializing_if = "is_default")]
when: When,
/// `fail` (default) fails the operation; `tolerate` logs and goes on.
#[serde(default, skip_serializing_if = "is_default")]
failure: Failure,
/// Only on these hosts.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
hosts: BTreeSet<HostOs>,
/// Only on these Vagrant providers (`${run.provider}`).
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
providers: BTreeSet<Provider>,
/// A regular expression over the step's lines; the last match leads the step's error
/// (Ansible's `fatal: [host]: …`).
#[serde(default, skip_serializing_if = "Option::is_none")]
verdict: Option<String>,
}
impl TryFrom<StepFields> for Step {
type Error = Error;
fn try_from(f: StepFields) -> Result<Step> {
let shape = |reason| Error::StepShape { id: f.id.to_string(), reason };
let run = match (f.argv, f.action) {
(Some(_), Some(_)) => return Err(shape("it has both `argv` and `action`: keep one")),
(None, None) => return Err(shape("it has neither `argv` nor `action`: give one")),
(Some(_), None) if !f.with.is_empty() => return Err(shape("`with` goes with an `action`, not an `argv`")),
(Some(argv), None) => Run::Argv(argv),
(None, Some(action)) => Run::Action { action, with: f.with },
};
Ok(Step {
id: f.id,
note: f.note,
run,
cwd: f.cwd,
env: f.env,
stdin: f.stdin,
timeout_s: f.timeout_s,
provider_timeout_s: f.provider_timeout_s,
when: f.when,
failure: f.failure,
hosts: f.hosts,
providers: f.providers,
verdict: f.verdict,
})
}
}
impl From<Step> for StepFields {
fn from(s: Step) -> StepFields {
let (argv, action, with) = match s.run {
Run::Argv(a) => (Some(a), None, BTreeMap::new()),
Run::Action { action, with } => (None, Some(action), with),
};
StepFields {
id: s.id,
note: s.note,
argv,
action,
with,
cwd: s.cwd,
env: s.env,
stdin: s.stdin,
timeout_s: s.timeout_s,
provider_timeout_s: s.provider_timeout_s,
when: s.when,
failure: s.failure,
hosts: s.hosts,
providers: s.providers,
verdict: s.verdict,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_step_runs_an_argv_or_an_action() {
let s: Step = serde_json::from_str(r#"{"id": "up", "argv": ["docker", "compose", "up"], "timeout_s": 1800}"#).unwrap();
assert!(matches!(s.run, Run::Argv(_)));
assert_eq!(s.timeout_s.map(NonZeroU32::get), Some(1800));
let a: Step = serde_json::from_str(r#"{"id": "rm", "action": "compose.remove_containers", "with": {"service": "x"}, "failure": "tolerate"}"#).unwrap();
assert!(matches!(a.run, Run::Action { action: Action::ComposeRemoveContainers, ref with } if with.len() == 1));
let back = serde_json::to_string(&a).unwrap();
assert_eq!(
back,
r#"{"id":"rm","action":"compose.remove_containers","with":{"service":"x"},"failure":"tolerate"}"#
);
}
#[test]
fn a_provider_may_have_its_own_time_limit() {
let text = r#"{"id":"status","argv":["vagrant","status"],"timeout_s":25,"provider_timeout_s":{"vmware_esxi":120}}"#;
let s: Step = serde_json::from_str(text).unwrap();
assert_eq!(s.provider_timeout_s.get(&Provider::VmwareEsxi).map(|t| t.get()), Some(120));
assert_eq!(serde_json::to_string(&s).unwrap(), text);
// Left out when no provider has one.
let plain: Step = serde_json::from_str(r#"{"id":"status","argv":["vagrant","status"]}"#).unwrap();
assert!(plain.provider_timeout_s.is_empty());
assert!(!serde_json::to_string(&plain).unwrap().contains("provider_timeout_s"));
assert!(serde_json::from_str::<Step>(r#"{"id":"s","argv":["v"],"provider_timeout_s":{"vmware_esxi":0}}"#).is_err());
assert!(serde_json::from_str::<Step>(r#"{"id":"s","argv":["v"],"provider_timeout_s":{"esx":60}}"#).is_err());
}
#[test]
fn a_step_with_both_or_neither_is_refused() {
let both = r#"{"id": "x", "argv": ["docker"], "action": "ssh.run"}"#;
assert!(serde_json::from_str::<Step>(both).unwrap_err().to_string().contains("both"));
assert!(serde_json::from_str::<Step>(r#"{"id": "x"}"#).unwrap_err().to_string().contains("neither"));
let with = r#"{"id": "x", "argv": ["docker"], "with": {"a": "b"}}"#;
assert!(serde_json::from_str::<Step>(with).unwrap_err().to_string().contains("`with`"));
assert!(serde_json::from_str::<Step>(r#"{"id": "x", "argv": ["docker"], "shell": true}"#).is_err());
}
}