Skip to main content

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}