Skip to main content

isoloom_render/
steps.rs

1//! Each set-up step of a VM ([`Step`]) written once: as a root shell script for Linux, and as
2//! PowerShell where Windows has the step. A VM backend only picks how the scripts reach the
3//! machine (a Vagrant provisioner, cloud-init, `remote-exec`, WinRM), so every target sets a
4//! machine up the same way.
5
6use isoloom_plan::Environment;
7use isoloom_plan::lifecycle::{RunKind, Step};
8use isoloom_spec::shell::quote;
9
10use crate::configure::{self, Configured, shell};
11use crate::network::{ipv6, more_addresses};
12use crate::{constraints, powershell, secrets};
13
14mod disks;
15mod files;
16mod network;
17mod process;
18mod settings;
19mod shared_volume;
20mod wait;
21
22/// Where the project is unpacked on a VM.
23pub const PROJECT_DIR: &str = "/opt/isoloom";
24/// The project's archive, uploaded before the steps run.
25pub const PROJECT_ARCHIVE: &str = "/tmp/isoloom-project.tgz";
26/// The inputs, uploaded before the steps run (`KEY=value` lines).
27pub const INPUTS_FILE: &str = "/tmp/isoloom-inputs.env";
28/// Where the inputs are kept when a reboot comes (`/tmp` is emptied at boot).
29pub const KEPT_INPUTS_FILE: &str = "/var/lib/isoloom/inputs.env";
30/// Waits for cloud-init to be done (its packages in, `apt` free): the first command of a set-up
31/// run over SSH on a VM cloud-init boots. Its failure doesn't stop the set-up.
32pub const CLOUD_INIT_WAIT: &str = "cloud-init status --wait >/dev/null 2>&1 || true";
33
34/// On a Docker host VM, published ports reach containers whatever their routes: what arrives from
35/// outside the VM (a forward to this computer, a client of the server's address) is DNAT'ed by
36/// Docker with its source kept, and a container of an `internet: false` network, its default route
37/// removed, can't answer it. Masquerading that traffic into the lab's bridges makes it come from
38/// the bridge's own address, which every container reaches. A unit keeps the rule across reboots,
39/// after Docker's own. Run as root; running it again changes nothing.
40pub const PUBLISHED_FROM_OUTSIDE: &str = r"cat > /etc/systemd/system/isoloom-published.service <<'UNIT'
41[Unit]
42Description=Isoloom: published ports reach containers of networks without a default route
43After=docker.service
44Wants=docker.service
45
46[Service]
47Type=oneshot
48RemainAfterExit=yes
49ExecStart=/bin/sh -c 'iptables -t nat -C POSTROUTING -m conntrack --ctstate DNAT -o br-+ -j MASQUERADE 2>/dev/null || iptables -t nat -I POSTROUTING -m conntrack --ctstate DNAT -o br-+ -j MASQUERADE'
50
51[Install]
52WantedBy=multi-user.target
53UNIT
54systemctl daemon-reload
55systemctl enable --now isoloom-published.service
56";
57
58/// The Docker Engine a Docker host gets (`docker-vm`, `cloud-docker`, `hybrid`), exactly: the
59/// version Isoloom's Compose files are proven with, so two runs of one lab get the same engine.
60pub const DOCKER_VERSION: &str = "29.9.0";
61
62/// The Compose plugin a Docker host gets, exactly (on hosts with `apt`: Debian and Ubuntu).
63pub const COMPOSE_VERSION: &str = "5.6.0";
64
65/// Docker's apt signing key, pinned by SHA-256 (the same armored key for Debian and Ubuntu,
66/// `download.docker.com/linux/<id>/gpg`); verified against download.docker.com on 2026-10-16.
67pub const DOCKER_APT_KEY_SHA256: &str = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570";
68
69/// Installs Docker, unless it is there, as a root script. On the apt family (Debian, Ubuntu) it
70/// adds Docker's own repository with the signing key pinned by SHA-256 ([`DOCKER_APT_KEY_SHA256`],
71/// no `curl | sh`), then installs the engine at [`DOCKER_VERSION`] (the exact package resolved from
72/// the repository, so its epoch and build suffix are the repo's) and the Compose plugin at
73/// [`COMPOSE_VERSION`]. The RPM family (Rocky, AlmaLinux, CentOS, Fedora) still uses Docker's own
74/// install script, pinned to [`DOCKER_VERSION`]. TODO(4.7): inline the dnf/RPM path too, its key
75/// pinned by SHA-256 (`e6c650e0…`), proven on a Rocky/Fedora guest. The apt branch fails closed (a
76/// `set -e` subshell, the key verified before use).
77pub const INSTALL_DOCKER: &str = r#"command -v docker >/dev/null || if command -v apt-get >/dev/null; then (
78  set -e
79  export DEBIAN_FRONTEND=noninteractive
80  apt-get update -qq
81  apt-get install -y -qq ca-certificates curl
82  install -m 0755 -d /etc/apt/keyrings
83  k=$(mktemp)
84  # shellcheck disable=SC1091,SC2154 # /etc/os-release is on the guest, not here.
85  _id=$(. /etc/os-release && printf %s "$ID")
86  # shellcheck disable=SC1091,SC2154
87  _cn=$(. /etc/os-release && printf %s "$VERSION_CODENAME")
88  curl -fsSL "https://download.docker.com/linux/$_id/gpg" -o "$k"
89  echo "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570  $k" | sha256sum -c -
90  install -m 0644 "$k" /etc/apt/keyrings/docker.asc
91  rm -f "$k"
92  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$_id $_cn stable" > /etc/apt/sources.list.d/docker.list
93  apt-get update -qq
94  v=$(apt-cache madison docker-ce | awk '$3 ~ /(^|:)29[.]9[.]0/ { print $3; exit }')
95  [ -n "$v" ] || { echo "docker-ce 29.9.0 is not in Docker's apt repository" >&2; exit 1; }
96  apt-get install -y -qq --allow-downgrades "docker-ce=$v" "docker-ce-cli=$v" containerd.io docker-buildx-plugin "docker-compose-plugin=5.6.0-*"
97)
98else
99  curl -fsSL https://get.docker.com | VERSION=29.9.0 sh
100fi"#;
101
102/// Where the project is unpacked on a Windows VM, and the machine's own scripts with it.
103const WINDOWS_PROJECT_DIR: &str = "C:\\isoloom";
104
105/// Installs packages with the machine's own package manager (Debian and Ubuntu, or the RHEL
106/// family: Rocky, AlmaLinux, CentOS, Fedora).
107const PKG: &str = "pkg() { if command -v apt-get >/dev/null; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \"$@\"; elif command -v dnf >/dev/null; then dnf install -y -q \"$@\"; else yum install -y -q \"$@\"; fi; }";
108
109/// This boot's id: it changes at each boot.
110const BOOT_ID: &str = "/proc/sys/kernel/random/boot_id";
111
112/// `step` of `machine` as a root shell script for a Linux VM; `None` when it has nothing to do
113/// there.
114#[must_use]
115pub fn linux(env: &Environment, machine: &str, step: &Step) -> Option<String> {
116    match step {
117        Step::Prelude => constraints::linux_prelude(env.spec()),
118        Step::DiskGrow => Some(disks::GROW_ROOT_SH.to_owned()),
119        Step::Disks { disks } => disks::linux(disks),
120        Step::Settings => env.spec().machines.get(machine).and_then(settings::linux),
121        Step::Hosts { entries } => network::hosts(entries),
122        Step::Routes { routes, default } => network::routes(routes, *default),
123        Step::Ipv6 { addresses, routes } => Some(ipv6::linux(addresses, routes)),
124        Step::MoreAddresses { addresses } => Some(more_addresses::linux(addresses)),
125        Step::Trunks => network::trunks(env, machine),
126        Step::Volumes { paths } => (!paths.is_empty()).then(|| format!("mkdir -p {}\n", paths.iter().map(|p| quote(p)).collect::<Vec<_>>().join(" "))),
127        Step::SharedVolume { volume, path, role } => Some(shared_volume::linux(volume, path, role)),
128        Step::Project => Some(format!(
129            "mkdir -p {PROJECT_DIR} && tar -xzf {PROJECT_ARCHIVE} -C {PROJECT_DIR} --strip-components=1 && rm -f {PROJECT_ARCHIVE}\n"
130        )),
131        Step::Files { files } => Some(files::linux(files)),
132        Step::Secrets => env.machines.get(machine).map(|mp| secrets::step(machine, &mp.secrets)),
133        Step::Inputs { persist } => persist.then(|| format!("mkdir -p /var/lib/isoloom && install -m 600 {INPUTS_FILE} {KEPT_INPUTS_FILE}\n")),
134        Step::WaitFor { machine: dep, ports } => Some(wait::linux(dep, ports)),
135        Step::Run { path, kind, creates } => run(env, machine, path, *kind).map(|line| guarded(line, creates.as_deref())),
136        Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| shell::script(&c, resources, *fresh)),
137        Step::Reboot => Some(format!("{}(sleep 2 && reboot) >/dev/null 2>&1 &\n", record_boot())),
138        Step::Egress => Some(network::egress(env)),
139        Step::Tc => network::tc(env, machine),
140        Step::Command => process::command(env, machine),
141        Step::Setup { paths } => process::setup(env, machine, paths),
142        Step::Ready => Some(format!(
143            "{}mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready\n",
144            process::healthy(env, machine).unwrap_or_default()
145        )),
146    }
147}
148
149/// `step` of `machine` as PowerShell for a Windows VM; `None` when Windows has no such step (or
150/// nothing to do for it).
151#[must_use]
152pub fn windows(env: &Environment, machine: &str, step: &Step) -> Option<String> {
153    match step {
154        Step::DiskGrow => Some(disks::GROW_ROOT_PS1.to_owned()),
155        Step::Disks { disks } => disks::windows(disks),
156        Step::Settings => env.spec().machines.get(machine).and_then(settings::windows),
157        Step::Hosts { entries } => network::windows_hosts(entries),
158        Step::Routes { routes, .. } => network::windows_routes(routes),
159        Step::Ipv6 { addresses, routes } => Some(ipv6::windows(addresses, routes)),
160        Step::MoreAddresses { addresses } => Some(more_addresses::windows(addresses)),
161        Step::Project => Some(format!(
162            "$archive = 'C:\\Windows\\Temp\\isoloom-project.tgz'; $dest = '{WINDOWS_PROJECT_DIR}'; $strip = 1\n{}",
163            powershell::UNTAR
164        )),
165        Step::Files { files } => Some(files::windows(files)),
166        Step::Secrets => env.machines.get(machine).map(|mp| secrets::windows::step(machine, &mp.secrets)),
167        Step::WaitFor { machine: dep, ports } => wait::windows(dep, ports),
168        Step::Run {
169            path,
170            kind: RunKind::PowerShell,
171            creates,
172        } => {
173            let run = format!(
174                "Set-Location {WINDOWS_PROJECT_DIR}; & powershell -NoProfile -ExecutionPolicy Bypass -File {}; if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}",
175                powershell::quote(path)
176            );
177            Some(match creates {
178                Some(done) => format!("if (-not (Test-Path -LiteralPath {})) {{ {run} }}", powershell::quote(done)),
179                None => run,
180            })
181        }
182        Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| configure::powershell::script(&c, resources, *fresh)),
183        Step::Command => process::windows_command(env, machine),
184        Step::Setup { paths } => Some(process::windows_setup(paths)),
185        Step::Ready => Some(format!(
186            "{}New-Item -ItemType Directory -Force {WINDOWS_PROJECT_DIR} | Out-Null; Set-Content {WINDOWS_PROJECT_DIR}\\ready ready",
187            process::windows_healthy(env, machine).unwrap_or_default()
188        )),
189        // Linux's alone: the constraints, trunks and impairment are Linux tools; a Windows VM's
190        // volumes, inputs and reboots are its own scripts'.
191        Step::Prelude
192        | Step::Trunks
193        | Step::Volumes { .. }
194        | Step::SharedVolume { .. }
195        | Step::Inputs { .. }
196        | Step::Run { .. }
197        | Step::Reboot
198        | Step::Egress
199        | Step::Tc => None,
200    }
201}
202
203/// One `provision` step, from the project folder, with the machine's inputs in its environment;
204/// `None` for a PowerShell script, which only [`windows`] runs.
205fn run(env: &Environment, machine: &str, path: &str, kind: RunKind) -> Option<String> {
206    let inputs = inputs_file(env, machine).map(|f| format!("set -a; . {f}; set +a; ")).unwrap_or_default();
207    match kind {
208        RunKind::PowerShell => None,
209        RunKind::Shell => Some(format!("cd {PROJECT_DIR} && sh -c {}\n", quote(&format!("{inputs}sh {path}")))),
210        // Installed with the machine's own package manager; its inventory is the machine, by
211        // its name (a play may name it), run locally.
212        RunKind::Playbook => Some(format!(
213            "{PKG}\ncommand -v ansible-playbook >/dev/null || pkg ansible-core\ncd {PROJECT_DIR} && sh -c {}\n",
214            quote(&format!("{inputs}ansible-playbook -c local -i {machine}, {path}"))
215        )),
216    }
217}
218
219/// A script's line behind its `creates`: skipped when that path exists.
220fn guarded(line: String, creates: Option<&str>) -> String {
221    match creates {
222        Some(path) => format!("[ -e {} ] || {{ {} }}\n", quote(path), line.trim_end()),
223        None => line,
224    }
225}
226
227/// Where machine `machine`'s inputs are, when it reads any: kept on disk when a reboot comes or
228/// a service reads them at every boot, else where they were uploaded.
229fn inputs_file(env: &Environment, machine: &str) -> Option<&'static str> {
230    let m = env.machines.get(machine).filter(|m| !m.inputs.is_empty())?;
231    let kept = m.lifecycle.iter().any(|s| matches!(s, Step::Inputs { persist: true }));
232    Some(if kept { KEPT_INPUTS_FILE } else { INPUTS_FILE })
233}
234
235/// Records this boot's id, for [`reboot_check`]: a transport that restarts the machine itself
236/// (Terraform's `remote-exec`) runs it before restarting.
237#[must_use]
238pub fn record_boot() -> String {
239    format!("mkdir -p /var/lib/isoloom && cp {BOOT_ID} /var/lib/isoloom/boot-id\n")
240}
241
242/// After a reboot step: fails when the machine didn't actually restart.
243#[must_use]
244pub fn reboot_check() -> String {
245    format!("if [ \"$(cat {BOOT_ID})\" = \"$(cat /var/lib/isoloom/boot-id)\" ]; then echo 'the reboot step: the machine did not restart' >&2; exit 1; fi\n")
246}
247
248#[cfg(test)]
249mod tests;