Skip to main content

isoloom_spec/
images.rs

1//! The image each target uses for an OS name: the built-in table, then the runner's own
2//! table ([`Table`]: a file given to `isoloom generate --images`, or a tool's own settings),
3//! then what a spec gives itself (`vm.image`).
4
5use indexmap::IndexMap;
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer};
8
9use crate::model::{DockerImpl, Spec, VmImage, VmImpl};
10use crate::{Error, Result};
11
12/// The runner's own image table, between the built-in one and the spec's `vm.image`:
13///
14/// ```yaml
15/// os:                     # an OS's image, instead of the built-in one
16///   debian@12: { vagrant: my-org/debian-12, vagrant_version: "1.2.0" }
17/// access:                 # the access machine, when the spec leaves it to the runner
18///   docker: kalilinux/kali-rolling
19///   vm: kali
20/// ```
21#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Table {
24    /// An OS's image per target, instead of the built-in one. Written as a spec writes the OS
25    /// (`debian@12`, `debian`), held by the image table's name for it (`debian-12`).
26    #[serde(default, deserialize_with = "os_keys")]
27    pub os: IndexMap<String, VmImage>,
28    /// The access machine, when the spec leaves it to the runner.
29    #[serde(default)]
30    pub access: Option<Access>,
31}
32
33/// How the runner supplies an access machine that has no implementation in the spec.
34#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Access {
37    /// A container image (kept running idle) for container targets.
38    #[serde(default)]
39    pub docker: Option<String>,
40    /// An OS for VM targets, written as a spec writes it (`kali`, `debian@12`), held by the
41    /// image table's name for it (its image from this table or the built-in one).
42    #[serde(default, deserialize_with = "access_os")]
43    pub vm: Option<String>,
44}
45
46/// The table's `os:` keys, each resolved to the image table's name for its OS.
47fn os_keys<'de, D: Deserializer<'de>>(d: D) -> Result<IndexMap<String, VmImage>, D::Error> {
48    IndexMap::<String, VmImage>::deserialize(d)?
49        .into_iter()
50        .map(|(key, image)| match crate::os::resolve(&key) {
51            Ok(os) => Ok((os.name.to_owned(), image)),
52            Err(e) => Err(D::Error::custom(format!("os.{key}: {e}"))),
53        })
54        .collect()
55}
56
57/// The access machine's OS, resolved to the image table's name for it.
58fn access_os<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
59    Option::<String>::deserialize(d)?
60        .map(|text| match crate::os::resolve(&text) {
61            Ok(os) => Ok(os.name.to_owned()),
62            Err(e) => Err(D::Error::custom(e)),
63        })
64        .transpose()
65}
66
67impl Table {
68    /// The table's image for an OS by its image-table name (`debian-12`).
69    fn image_of(&self, os: &str) -> Option<&VmImage> {
70        self.os.get(os)
71    }
72
73    /// Reads a table from YAML. Unknown fields are errors.
74    ///
75    /// # Errors
76    ///
77    /// [`Error::Images`]: text that isn't YAML, an unknown field, or an OS no family names.
78    pub fn parse(yaml: &str) -> Result<Table> {
79        serde_yaml_ng::from_str(yaml).map_err(Error::Images)
80    }
81
82    /// The spec with this table applied: each VM's image from `os:` (unless the spec gives its
83    /// own `vm.image`), and the access machine's missing implementations from `access:`. The
84    /// generators then treat a supplied machine like any other, on every target.
85    #[must_use]
86    pub fn apply(&self, spec: &Spec) -> Spec {
87        let mut out = spec.clone();
88        for m in out.machines.values_mut() {
89            if let Some(vm) = &mut m.vm
90                && vm.image.is_none()
91                && let Some(image) = self.image_of(&vm.os)
92            {
93                vm.image = Some(image.clone());
94            }
95            let Some(access) = self.access.as_ref().filter(|_| m.access) else { continue };
96            if m.docker.is_none()
97                && let Some(image) = &access.docker
98            {
99                m.docker = Some(DockerImpl {
100                    image: Some(image.clone()),
101                    build: None,
102                    dockerfile: None,
103                    target: None,
104                    args: IndexMap::new(),
105                    init: Vec::new(),
106                    idle: false,
107                    appliance: None,
108                    config: None,
109                    firmware: None,
110                    lxc: false,
111                    generated: None,
112                });
113                m.supplied = true;
114            }
115            if m.vm.is_none()
116                && let Some(os) = &access.vm
117            {
118                m.vm = Some(VmImpl {
119                    image: self.image_of(os).cloned(),
120                    os: os.clone(),
121                    provision: Vec::new(),
122                    tpm: None,
123                    boot: None,
124                    setup: Vec::new(),
125                });
126                m.supplied = true;
127            }
128        }
129        out
130    }
131}
132
133/// A Vagrant box: its name, a pinned version when known-good, and the libvirt, QEMU and Hyper-V
134/// boxes when the main one has no build for them.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct VagrantBox {
137    /// The box's name on Vagrant Cloud (`bento/debian-12`).
138    pub name: String,
139    /// The version known to work (`None`: the latest).
140    pub version: Option<String>,
141    /// The box the libvirt provider boots, when the main one has no libvirt build.
142    pub libvirt: Option<String>,
143    /// The box the QEMU provider boots, when the main one has no QEMU build.
144    pub qemu: Option<String>,
145    /// The box the Hyper-V provider boots.
146    pub hyperv: HyperVBox,
147}
148
149/// The box Vagrant's Hyper-V provider boots for a machine.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum HyperVBox {
152    /// The main box (it has a Hyper-V build at its version).
153    Main,
154    /// Another box, or another version of the main one (`None`: its latest).
155    Other {
156        /// The box's name.
157        name: String,
158        /// Its version (`None`: the latest).
159        version: Option<String>,
160    },
161    /// No Hyper-V build known for this OS: give one with `vm.image.hyperv`.
162    Missing,
163}
164
165/// Debian 12 on Hyper-V (bento's boxes no longer have Hyper-V builds; the roboxes `generic/*`
166/// boxes do, their last version 4.3.12): Isoloom's own Debian VMs (router, tool shell) use it
167/// there too.
168pub const HYPERV_DEBIAN_12: (&str, &str) = ("generic/debian12", ROBOXES);
169
170/// The last version of the roboxes `generic/*` boxes, which have Hyper-V builds.
171const ROBOXES: &str = "4.3.12";
172
173/// Debian 12 for QEMU: the official Debian cloud image as a box (amd64 and arm64), with the
174/// cloud-init vagrant-qemu sets a private network's address through. Isoloom's own Debian VMs
175/// (router, controller, tool shell) use it there too.
176pub const QEMU_DEBIAN_12: &str = "cloud-image/debian-12";
177
178/// Whether an OS name is a Windows one (WinRM and PowerShell instead of SSH and sh).
179#[must_use]
180pub fn is_windows(os: &str) -> bool {
181    os.starts_with("windows")
182}
183
184/// A box, its version when pinned, and its libvirt build when the main one has none.
185fn boxed(name: &str, version: Option<&str>, libvirt: Option<&str>) -> VagrantBox {
186    VagrantBox {
187        name: name.into(),
188        version: version.map(Into::into),
189        libvirt: libvirt.map(Into::into),
190        qemu: None,
191        hyperv: HyperVBox::Main,
192    }
193}
194
195/// `b` with its QEMU box (vagrant-qemu boots libvirt-format boxes), where the main one has none.
196fn with_qemu(mut b: VagrantBox, qemu: &str) -> VagrantBox {
197    b.qemu = Some(qemu.into());
198    b
199}
200
201/// `b` with its Hyper-V box, where the main one has no Hyper-V build (bento's current boxes
202/// have none).
203fn with_hyperv(mut b: VagrantBox, name: &str, version: &str) -> VagrantBox {
204    b.hyperv = HyperVBox::Other {
205        name: name.into(),
206        version: Some(version.into()),
207    };
208    b
209}
210
211/// `b`, with no Hyper-V build known anywhere.
212fn no_hyperv(mut b: VagrantBox) -> VagrantBox {
213    b.hyperv = HyperVBox::Missing;
214    b
215}
216
217/// The built-in Vagrant boxes, by OS name.
218fn builtin_vagrant(os: &str) -> Option<VagrantBox> {
219    if is_windows(os) {
220        return builtin_windows(os);
221    }
222    let (b, h) = (boxed, with_hyperv);
223    Some(match os {
224        "debian-11" => h(b("bento/debian-11", None, Some("generic/debian11")), "generic/debian11", ROBOXES),
225        "debian-12" => h(
226            with_qemu(b("bento/debian-12", None, Some("generic/debian12")), QEMU_DEBIAN_12),
227            HYPERV_DEBIAN_12.0,
228            HYPERV_DEBIAN_12.1,
229        ),
230        "debian-13" => no_hyperv(b("bento/debian-13", None, None)),
231        // End of life: for labs about older systems (Metasploitable 3 runs on 14.04). No cloud
232        // or Proxmox image is mapped for these, so those targets decline them.
233        "ubuntu-14.04" => no_hyperv(b("ubuntu/trusty64", None, None)),
234        "ubuntu-16.04" => h(b("bento/ubuntu-16.04", None, Some("generic/ubuntu1604")), "generic/ubuntu1604", ROBOXES),
235        "ubuntu-18.04" => h(b("bento/ubuntu-18.04", None, Some("generic/ubuntu1804")), "generic/ubuntu1804", ROBOXES),
236        "ubuntu-20.04" => h(b("bento/ubuntu-20.04", None, Some("generic/ubuntu2004")), "generic/ubuntu2004", ROBOXES),
237        "ubuntu-22.04" => h(b("bento/ubuntu-22.04", None, Some("generic/ubuntu2204")), "generic/ubuntu2204", ROBOXES),
238        // No roboxes build of 24.04: gusztavvargadr's, its last version with a Hyper-V build.
239        "ubuntu-24.04" => h(b("bento/ubuntu-24.04", None, None), "gusztavvargadr/ubuntu-server-2404-lts", "2607.0.0"),
240        "rocky-9" => h(b("bento/rockylinux-9", None, Some("generic/rocky9")), "generic/rocky9", ROBOXES),
241        "almalinux-9" => h(b("bento/almalinux-9", None, Some("generic/alma9")), "generic/alma9", ROBOXES),
242        // End of life (2024): for labs about older systems.
243        "centos-7" => h(b("bento/centos-7", None, Some("generic/centos7")), "generic/centos7", ROBOXES),
244        "fedora-42" => no_hyperv(b("bento/fedora-42", None, None)),
245        // The box Isoloom's own controller boots (roboxes, with libvirt and Hyper-V builds).
246        "alpine-3.19" => b("generic/alpine319", None, None),
247        // A supported Alpine: the official cloud image as a box (VirtualBox, libvirt and QEMU
248        // builds, none for Hyper-V).
249        "alpine-3.24" => no_hyperv(b("cloud-image/alpine-3.24", None, None)),
250        // Kali's own box has a Hyper-V build.
251        "kali" => b("kalilinux/rolling", None, None),
252        _ => return None,
253    })
254}
255
256/// The built-in Windows boxes: each has a Hyper-V build at its pinned version, but
257/// windows-server-2019's.
258fn builtin_windows(os: &str) -> Option<VagrantBox> {
259    let b = boxed;
260    Some(match os {
261        "windows-10" => b("gusztavvargadr/windows-10", Some("2511.0.0"), None),
262        // End of life, evaluation boxes (Ansible's own test boxes), pinned: for labs about
263        // older Windows. No cloud or Proxmox image is mapped for these.
264        "windows-server-2008r2" => b("jborean93/WindowsServer2008R2", Some("0.7.0"), None),
265        "windows-server-2012r2" => b("jborean93/WindowsServer2012R2", Some("1.2.0"), None),
266        "windows-server-2016" => b("StefanScherer/windows_2016", Some("2019.02.14"), None),
267        // The box GOAD uses (VirtualBox, VMware), pinned to its known-good version. On QEMU (an
268        // Apple Silicon Mac emulating x86), an evaluation box built for libvirt. On Hyper-V, its
269        // last version with a Hyper-V build.
270        "windows-server-2019" => with_hyperv(
271            with_qemu(
272                b("StefanScherer/windows_2019", Some("2021.05.15"), None),
273                "peru/windows-server-2019-standard-x64-eval",
274            ),
275            "StefanScherer/windows_2019",
276            "2020.02.12",
277        ),
278        // Pinned so a rebuilt box can't change an environment under it.
279        "windows-server-2022" => b("gusztavvargadr/windows-server-2022-standard", Some("2607.0.0"), None),
280        "windows-11" => b("gusztavvargadr/windows-11", Some("2607.1.0"), None),
281        "windows-server-2025" => b("gusztavvargadr/windows-server-2025-standard", Some("2607.0.0"), None),
282        _ => return None,
283    })
284}
285
286/// How a machine's Windows box answers WinRM: what its image declares, else plain HTTP (every
287/// built-in Windows box uses plain HTTP today).
288#[must_use]
289pub fn winrm(vm: &VmImpl) -> crate::model::Winrm {
290    vm.image.as_ref().and_then(|i| i.winrm).unwrap_or_default()
291}
292
293/// The Vagrant box for a machine: the spec's `vm.image.vagrant` when set, else the built-in one;
294/// on Hyper-V, the spec's `vm.image.hyperv` when set (its latest version with a Hyper-V build).
295#[must_use]
296pub fn vagrant(vm: &VmImpl) -> Option<VagrantBox> {
297    let image = vm.image.as_ref();
298    let mut b = match image.and_then(|i| i.vagrant.clone()) {
299        Some(name) => VagrantBox {
300            name,
301            version: image.and_then(|i| i.vagrant_version.clone()),
302            libvirt: None,
303            qemu: image.and_then(|i| i.qemu.clone()),
304            hyperv: HyperVBox::Main,
305        },
306        None => builtin_vagrant(&vm.os)?,
307    };
308    if let Some(name) = image.and_then(|i| i.hyperv.clone()) {
309        b.hyperv = HyperVBox::Other { name, version: None };
310    }
311    Some(b)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::model::KNOWN_OS;
318
319    /// A table with Debian 12's box and an access machine of each form.
320    const TABLE: &str = "os:\n  debian@12: { vagrant: my-org/debian-12 }\naccess: { docker: kalilinux/kali-rolling, vm: debian }\n";
321
322    #[test]
323    fn a_table_holds_each_os_by_its_image_table_name() {
324        let table = Table::parse(TABLE).unwrap();
325        assert_eq!(table.os.keys().collect::<Vec<_>>(), ["debian-12"]);
326        assert_eq!(table.os["debian-12"].vagrant.as_deref(), Some("my-org/debian-12"));
327        let access = table.access.unwrap();
328        assert_eq!(
329            (access.docker.as_deref(), access.vm.as_deref()),
330            (Some("kalilinux/kali-rolling"), Some("debian-12"))
331        );
332        assert!(
333            Table::parse("access: { vm: windows-95 }")
334                .unwrap_err()
335                .to_string()
336                .contains("unknown OS `windows-95`")
337        );
338    }
339
340    #[test]
341    fn a_table_gives_vms_their_image_unless_the_spec_does() {
342        let table = Table::parse(TABLE).unwrap();
343        let spec = crate::testing::model(&crate::testing::lab(
344            "machines:\n  a: { networks: { lan: 10 }, vm: { os: debian@12 } }\n  b: { networks: { lan: 11 }, vm: { os: debian@12, image: { vagrant: own/box } } }\n  c: { networks: { lan: 12 }, vm: { os: debian@13 } }\n",
345        ))
346        .unwrap();
347        let applied = table.apply(&spec);
348        let vagrant = |m: &str| applied.machines[m].vm.as_ref().and_then(|v| v.image.as_ref()).and_then(|i| i.vagrant.clone());
349        assert_eq!(vagrant("a").as_deref(), Some("my-org/debian-12"));
350        assert_eq!(vagrant("b").as_deref(), Some("own/box"));
351        assert_eq!(applied.machines["c"].vm.as_ref().and_then(|v| v.image.clone()), None);
352    }
353
354    #[test]
355    fn a_table_supplies_the_access_machine_in_both_forms() {
356        let table = Table::parse(TABLE).unwrap();
357        let spec = crate::testing::model(&crate::testing::lab("machines:\n  user: { networks: { lan: 10 }, access: true }\n")).unwrap();
358        let user = &table.apply(&spec).machines["user"];
359        assert!(user.supplied);
360        assert_eq!(user.docker.as_ref().and_then(|d| d.image.as_deref()), Some("kalilinux/kali-rolling"));
361        let vm = user.vm.as_ref().unwrap();
362        assert_eq!(
363            (vm.os.as_str(), vm.image.as_ref().and_then(|i| i.vagrant.as_deref())),
364            ("debian-12", Some("my-org/debian-12"))
365        );
366        let own = crate::testing::model(&crate::testing::lab(
367            "machines:\n  user: { networks: { lan: 10 }, access: true, docker: { image: mine } }\n",
368        ))
369        .unwrap();
370        let kept = &table.apply(&own).machines["user"];
371        assert_eq!(kept.docker.as_ref().and_then(|d| d.image.as_deref()), Some("mine"));
372    }
373
374    #[test]
375    fn a_windows_box_answers_winrm_as_its_image_says() {
376        let declared: VmImpl = serde_yaml_ng::from_str("os: windows-server-2022\nimage: { winrm: ssl }\n").unwrap();
377        assert_eq!(winrm(&declared), crate::model::Winrm::Ssl);
378        let plain: VmImpl = serde_yaml_ng::from_str("os: windows-server-2022\n").unwrap();
379        assert_eq!(winrm(&plain), crate::model::Winrm::Plaintext);
380    }
381
382    #[test]
383    fn every_os_name_has_a_vagrant_box() {
384        for os in KNOWN_OS {
385            assert!(builtin_vagrant(os).is_some(), "no built-in Vagrant box for `{os}`");
386        }
387    }
388
389    #[test]
390    fn hyperv_boxes_where_the_main_one_has_no_build() {
391        let other = |os: &str| match builtin_vagrant(os).map(|b| b.hyperv) {
392            Some(HyperVBox::Other { name, version }) => Some((name, version)),
393            _ => None,
394        };
395        let pinned = |n: &str, v: &str| Some((n.to_owned(), Some(v.to_owned())));
396        assert_eq!(other("debian-12"), pinned("generic/debian12", "4.3.12"));
397        assert_eq!(other("ubuntu-24.04"), pinned("gusztavvargadr/ubuntu-server-2404-lts", "2607.0.0"));
398        assert_eq!(other("windows-server-2019"), pinned("StefanScherer/windows_2019", "2020.02.12"));
399        for os in ["kali", "windows-11", "windows-server-2022"] {
400            assert_eq!(builtin_vagrant(os).map(|b| b.hyperv), Some(HyperVBox::Main), "{os}");
401        }
402        for os in ["debian-13", "ubuntu-14.04", "fedora-42"] {
403            assert_eq!(builtin_vagrant(os).map(|b| b.hyperv), Some(HyperVBox::Missing), "{os}");
404        }
405    }
406
407    #[test]
408    fn the_specs_hyperv_box_wins() {
409        let vm: VmImpl = serde_yaml_ng::from_str("os: debian-13\nimage: { hyperv: my/debian13 }\n").unwrap();
410        let b = vagrant(&vm).unwrap();
411        assert_eq!(b.name, "bento/debian-13");
412        let mine = HyperVBox::Other {
413            name: "my/debian13".into(),
414            version: None,
415        };
416        assert_eq!(b.hyperv, mine);
417    }
418}