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