isoloom_spec/model/implementation.rs
1//! How each kind of target produces a machine: the `docker:` and `vm:` blocks, appliances,
2//! images, and the provisioning a VM runs.
3
4use indexmap::IndexMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::Resources;
9
10/// How a container produces a machine: an image, or one built from the project.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct DockerImpl {
14 /// A published image (exactly one of `image` and `build`).
15 #[serde(default)]
16 pub image: Option<String>,
17 /// A folder of the project, built into the machine's image as its build context (exactly
18 /// one of `image` and `build`).
19 #[serde(default)]
20 pub build: Option<String>,
21 /// The Dockerfile, a file anywhere in the project (default: `Dockerfile` in `build`): an
22 /// image of your own around a vendored project's source, without moving that source.
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub dockerfile: Option<String>,
25 /// The stage of a multi-stage Dockerfile to build (`FROM ... AS <target>`), when not its
26 /// last: the lab's image beside the release one, from one Dockerfile.
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub target: Option<String>,
29 /// Build arguments (`ARG`s of the Dockerfile), fixed values.
30 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
31 pub args: IndexMap<String, String>,
32 /// One-shot jobs (scripts or folders in the project) run before the machine counts as ready.
33 #[serde(default)]
34 pub init: Vec<String>,
35 /// The image runs no service of its own (a stock Linux image whose command is a shell that
36 /// exits at once): keep the container running idle, as a machine to work from.
37 #[serde(default)]
38 pub idle: bool,
39 /// The image is a network appliance Isoloom knows how to wire (a router or switch OS in a
40 /// container): its management port, interface order and startup configuration are set the
41 /// way the image expects, its addresses go into that configuration. Docker only.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub appliance: Option<Appliance>,
44 /// An appliance's own configuration (a file in the project), added to the one Isoloom
45 /// generates (hostname, credentials, interfaces and their addresses).
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub config: Option<String>,
48 /// The firmware an emulator boots (a file in the project): the IOS `.bin` of a Dynamips
49 /// router. Isoloom builds the emulator's container itself, so no `image` then.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub firmware: Option<String>,
52 /// On Proxmox, a machine with no `vm:` may run as an LXC container from its `image` (an
53 /// OCI image, Proxmox VE 9.1 or later) instead of ruling the `proxmox` target out. Opt-in:
54 /// an LXC container is no Docker container (no Docker runtime, no healthcheck, no named
55 /// volumes), so the author says the image runs as one. Ignored by every other target.
56 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
57 pub lxc: bool,
58 /// The image Isoloom builds from the machine's configuration (`app.steps`): a Dockerfile it
59 /// generates, `FROM` the OS's official image. Set by lowering, never written in a spec.
60 #[serde(skip)]
61 #[schemars(skip)]
62 pub generated: Option<Generated>,
63}
64
65/// A container image Isoloom builds from a machine's configuration ([`super::Configuration`]).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Generated {
68 /// The OS's image-table name (`debian-12`): its package installer.
69 pub os: String,
70 /// The image it starts `FROM`, pinned by digest.
71 pub base: String,
72 /// What that image is.
73 pub from: BaseImage,
74 /// An entry's official image whose `start` steps Isoloom's runner runs (`image.start:
75 /// run`): the image's own process, which the runner `exec`s after them.
76 pub after_start: Option<OwnProcess>,
77}
78
79/// An official image's own process: its entrypoint, and the command it runs.
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub struct OwnProcess {
82 /// Its entrypoint (empty: the command alone).
83 pub entrypoint: Vec<String>,
84 /// Its command (the entry's, when it gives one; else the image's own).
85 pub command: Vec<String>,
86}
87
88/// What a generated image starts from.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90#[non_exhaustive]
91pub enum BaseImage {
92 /// The OS's official image: the configuration installs everything, and the container runs
93 /// the recipe's services (or the machine's command, or nothing).
94 #[default]
95 Os,
96 /// A catalogue entry's official image, whose OS is the machine's: the image is the entry's
97 /// `install` phase, and the container keeps the image's own entrypoint and command.
98 Entry,
99}
100
101/// A network appliance image (see [`DockerImpl::appliance`]). The images aren't Isoloom's to
102/// ship: the user builds them from software they are licensed for.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(rename_all = "kebab-case")]
105pub enum Appliance {
106 /// Cisco IOL (IOS on Linux, from Cisco Modeling Labs) as vrnetlab packages it
107 /// (`vrnetlab/cisco_iol:<version>`): a router.
108 CiscoIol,
109 /// Cisco IOL-L2 (`vrnetlab/cisco_iol:L2-<version>`): a switch.
110 CiscoIolL2,
111 /// Cisco IOSv in QEMU (`vrnetlab/cisco_vios:<version>`, needs /dev/kvm): a router.
112 CiscoVios,
113 /// Cisco IOSvL2 in QEMU (`vrnetlab/cisco_viosl2:<version>`, needs /dev/kvm): a switch.
114 CiscoViosL2,
115 /// Cisco CSR1000v (IOS XE) in QEMU (`vrnetlab/cisco_csr1000v:<version>`, needs /dev/kvm).
116 CiscoCsr1000v,
117 /// Cisco Catalyst 8000v (IOS XE) in QEMU (`vrnetlab/cisco_c8000v:<version>`, needs /dev/kvm).
118 CiscoC8000v,
119 /// A classic Cisco 7200 router emulated by Dynamips, booting the IOS `.bin` in `firmware`.
120 CiscoDynamips,
121}
122
123/// How a VM produces a machine: its OS, and the steps that set it up.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct VmImpl {
127 /// The OS, as `<family>@<version>` (`debian@12`) or a family alone for its pinned default;
128 /// each target maps it to an image. Once parsed, it holds the image table's name for the OS
129 /// ([`KNOWN_OS`], see [`crate::os::Os::name`]), as lowering writes it. Defaults to the machine's
130 /// `os`, which it must agree with when both are set. May come from `common:` or a group
131 /// (`vm: {}` in the machine).
132 #[serde(default)]
133 pub os: String,
134 /// Steps run inside the VM, in order: `.sh` on Linux (and Ansible playbooks, `.yml`),
135 /// `.ps1` on Windows. On Linux, the step `reboot` restarts the machine and goes on with the
136 /// next step once it is back (a new kernel, a changed boot configuration).
137 #[serde(default)]
138 pub provision: Vec<String>,
139 /// The image to use instead of the built-in one for `os`, per target.
140 #[serde(default)]
141 pub image: Option<VmImage>,
142 /// A virtual TPM 2.0 (BitLocker, Windows Hello, measured boot). Unset, a Windows 11 machine
143 /// gets one wherever the target can give one, and runs without where it can't (its
144 /// built-in images install without one). `true` requires one: a target that gives none
145 /// refuses the environment. `false` gives none.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub tpm: Option<bool>,
148 /// How it boots, when not the way its image does: its own kernel, firmware, boot order or
149 /// network boot.
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub boot: Option<super::Boot>,
152 /// Project scripts run once the machine is up, after its command started (`setup:`). Set by
153 /// lowering, never written in a spec.
154 #[serde(skip)]
155 #[schemars(skip)]
156 pub setup: Vec<String>,
157}
158
159/// The `vm.provision` step that restarts a Linux machine; the steps after it run once the
160/// machine is back.
161pub const REBOOT_STEP: &str = "reboot";
162
163/// Whether a `vm.provision` step is the [`REBOOT_STEP`] rather than a file.
164#[must_use]
165pub fn is_reboot(step: &str) -> bool {
166 step == REBOOT_STEP
167}
168
169/// How a Windows box answers WinRM. Most boxes (StefanScherer, gusztavvargadr) use plain HTTP
170/// on 5985 with basic auth; some (GOAD's Windows Server 2025 box) only listen on HTTPS 5986.
171/// Isoloom can't tell from the box name, so a non-default box declares it.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
173#[serde(rename_all = "lowercase")]
174pub enum Winrm {
175 /// HTTP on 5985, basic auth (the default for the common Windows boxes).
176 #[default]
177 Plaintext,
178 /// HTTPS on 5986, NTLM (certificate not verified).
179 Ssl,
180}
181
182/// How a machine resolves names.
183#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185pub struct Dns {
186 /// DNS server addresses, in order (e.g. the lab's domain controller).
187 #[serde(default)]
188 pub servers: Vec<String>,
189 /// Search domains appended to bare names.
190 #[serde(default)]
191 pub search: Vec<String>,
192 /// The machine's own domain name.
193 #[serde(default)]
194 pub domain: Option<String>,
195 /// Resolver options, as resolv.conf's `options` line writes them: `name` or `name:value`
196 /// (`ndots:2`, `timeout:1`, `attempts:3`, `rotate`). A container's `dns_opt`, a pod's
197 /// `dnsConfig` options.
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub options: Vec<String>,
200}
201
202/// A machine's own image, per target, instead of the built-in one for its OS.
203#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct VmImage {
206 /// A Vagrant box name (e.g. `StefanScherer/windows_2019`).
207 #[serde(default)]
208 pub vagrant: Option<String>,
209 /// The box version to pin.
210 #[serde(default)]
211 pub vagrant_version: Option<String>,
212 /// The box Vagrant's QEMU provider boots instead (libvirt format), when `vagrant` has none
213 /// (e.g. `peru/windows-server-2019-standard-x64-eval`).
214 #[serde(default)]
215 pub qemu: Option<String>,
216 /// The box Vagrant's Hyper-V provider boots instead, when `vagrant` has no Hyper-V build
217 /// (its latest version that has one).
218 #[serde(default)]
219 pub hyperv: Option<String>,
220 /// How this Windows box answers WinRM, when it isn't the usual plain-HTTP box.
221 #[serde(default)]
222 pub winrm: Option<Winrm>,
223 /// This Windows box answers OpenSSH (Windows' own `sshd`, PowerShell as its shell) instead
224 /// of WinRM: Vagrant connects with `config.winssh` and the playbooks over SSH, as the box's
225 /// `vagrant` account. Not with `winrm`.
226 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
227 pub winssh: bool,
228 /// Where Vagrant downloads the `vagrant` box from, when it isn't on Vagrant Cloud: a URL, or
229 /// a `.box` file in the project (list its folder in `.isoloomignore`, or the project copied
230 /// into every VM carries it).
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub box_url: Option<String>,
233 /// The box server Vagrant looks the `vagrant` box up on, instead of Vagrant Cloud (a
234 /// private registry).
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub box_server_url: Option<String>,
237 /// The Proxmox image: a cloud image's URL (the server downloads it), or the id of a template
238 /// VM on the node (cloned).
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub proxmox: Option<String>,
241 /// The AWS image: an AMI id (`ami-0123...`) in the module's region.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub aws: Option<String>,
244 /// The Azure image: a managed image's or gallery image version's resource id.
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub azure: Option<String>,
247 /// The Google Cloud image: `projects/<project>/global/images/<image>` (or `.../family/<f>`).
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub gcp: Option<String>,
250 /// The DigitalOcean image: a slug or a custom image's id.
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub digitalocean: Option<String>,
253 /// The Linode image: `linode/<distribution>` or `private/<id>`.
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub linode: Option<String>,
256 /// The Oracle Cloud image: its OCID (`ocid1.image...`).
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub oci: Option<String>,
259}
260
261/// The controller's settings (`controller:`), the fields a machine uses for the same things.
262#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
263#[serde(deny_unknown_fields)]
264pub struct Controller {
265 /// The box to use instead of Isoloom's (Vagrant): a specific Ansible version, a mirror.
266 #[serde(default)]
267 #[schemars(skip)] // lowering fills it from `overrides:`; a spec never writes it here.
268 pub image: Option<VmImage>,
269 /// CPUs, memory (and disk on Proxmox), for many forks against a large environment.
270 #[serde(default)]
271 pub resources: Option<Resources>,
272 /// Keep it running once provisioning is done (to use it as a jump host). By default it
273 /// halts, keeping its disk: `isoloom test` and `isoloom provision` boot it when they need it.
274 #[serde(default)]
275 pub keep_running: bool,
276}
277
278/// One environment-level provisioning step.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
280#[serde(deny_unknown_fields)]
281pub struct Provision {
282 /// An Ansible playbook in the project, run against the whole environment.
283 pub ansible: String,
284 /// More inventory files in the project (groups, variables), next to the one Isoloom writes
285 /// (every machine's address and connection, in the `linux` or `windows` group).
286 #[serde(default)]
287 pub inventory: Vec<String>,
288 /// Inventory groups: a name -> the machines in it.
289 #[serde(default)]
290 pub groups: IndexMap<String, Vec<String>>,
291 /// Extra variables for the playbook, of any YAML type (strings, numbers, booleans, lists,
292 /// maps): passed as JSON (`-e`), which keeps their types.
293 #[serde(default)]
294 pub vars: IndexMap<String, serde_json::Value>,
295 /// Variables per machine (a machine's name -> its variables), on its line of the inventory
296 /// Isoloom writes: what a playbook keys on per host (its domain, a role flag).
297 #[serde(default)]
298 pub host_vars: IndexMap<String, IndexMap<String, serde_json::Value>>,
299 /// The Ansible Galaxy requirements to install first (collections, roles); by default
300 /// `requirements.yml` next to the playbook, when there is one.
301 #[serde(default)]
302 pub requirements: Option<String>,
303}
304
305/// The image table's OS names, the ones every target maps to its own images (e.g. Windows from
306/// an evaluation ISO locally, a license-included image in the cloud). A spec writes the OS as
307/// `<family>@<version>` ([`crate::os`]); a parsed spec's `vm.os` holds one of these names. In
308/// the order of [`crate::os::FAMILIES`].
309pub const KNOWN_OS: &[&str] = &[
310 "debian-11",
311 "debian-12",
312 "debian-13",
313 "ubuntu-14.04",
314 "ubuntu-16.04",
315 "ubuntu-18.04",
316 "ubuntu-20.04",
317 "ubuntu-22.04",
318 "ubuntu-24.04",
319 "rocky-9",
320 "almalinux-9",
321 "alpine-3.19",
322 "alpine-3.24",
323 "kali",
324 "fedora-42",
325 "centos-7",
326 "windows-10",
327 "windows-11",
328 "windows-server-2008r2",
329 "windows-server-2012r2",
330 "windows-server-2016",
331 "windows-server-2019",
332 "windows-server-2022",
333 "windows-server-2025",
334];
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn only_the_reboot_step_restarts_the_machine() {
342 assert_eq!([is_reboot("reboot"), is_reboot("provision/reboot.sh")], [true, false]);
343 }
344}