1use 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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Table {
24 #[serde(default, deserialize_with = "os_keys")]
27 pub os: IndexMap<String, VmImage>,
28 #[serde(default)]
30 pub access: Option<Access>,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Access {
37 #[serde(default)]
39 pub docker: Option<String>,
40 #[serde(default, deserialize_with = "access_os")]
43 pub vm: Option<String>,
44}
45
46fn 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
57fn 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 fn image_of(&self, os: &str) -> Option<&VmImage> {
70 self.os.get(os)
71 }
72
73 pub fn parse(yaml: &str) -> Result<Table> {
79 serde_yaml_ng::from_str(yaml).map_err(Error::Images)
80 }
81
82 #[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#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct VagrantBox {
137 pub name: String,
139 pub version: Option<String>,
141 pub libvirt: Option<String>,
143 pub qemu: Option<String>,
145 pub hyperv: HyperVBox,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum HyperVBox {
152 Main,
154 Other {
156 name: String,
158 version: Option<String>,
160 },
161 Missing,
163}
164
165pub const HYPERV_DEBIAN_12: (&str, &str) = ("generic/debian12", ROBOXES);
169
170const ROBOXES: &str = "4.3.12";
172
173pub const QEMU_DEBIAN_12: &str = "cloud-image/debian-12";
177
178#[must_use]
180pub fn is_windows(os: &str) -> bool {
181 os.starts_with("windows")
182}
183
184fn 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
195fn with_qemu(mut b: VagrantBox, qemu: &str) -> VagrantBox {
197 b.qemu = Some(qemu.into());
198 b
199}
200
201fn 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
211fn no_hyperv(mut b: VagrantBox) -> VagrantBox {
213 b.hyperv = HyperVBox::Missing;
214 b
215}
216
217fn 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 "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 "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 "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 "alpine-3.19" => b("generic/alpine319", None, None),
247 "alpine-3.24" => no_hyperv(b("cloud-image/alpine-3.24", None, None)),
250 "kali" => b("kalilinux/rolling", None, None),
252 _ => return None,
253 })
254}
255
256fn 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 "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"), Some("jborean93/WindowsServer2016")),
267 "windows-server-2019" => with_hyperv(
271 with_qemu(
272 b(
273 "StefanScherer/windows_2019",
274 Some("2021.05.15"),
275 Some("peru/windows-server-2019-standard-x64-eval"),
276 ),
277 "peru/windows-server-2019-standard-x64-eval",
278 ),
279 "StefanScherer/windows_2019",
280 "2020.02.12",
281 ),
282 "windows-server-2022" => b("gusztavvargadr/windows-server-2022-standard", Some("2607.0.0"), None),
284 "windows-11" => b("gusztavvargadr/windows-11", Some("2607.1.0"), None),
285 "windows-server-2025" => b("gusztavvargadr/windows-server-2025-standard", Some("2607.0.0"), None),
286 _ => return None,
287 })
288}
289
290#[must_use]
293pub fn winrm(vm: &VmImpl) -> crate::model::Winrm {
294 vm.image.as_ref().and_then(|i| i.winrm).unwrap_or_default()
295}
296
297#[must_use]
300pub fn vagrant(vm: &VmImpl) -> Option<VagrantBox> {
301 let image = vm.image.as_ref();
302 let mut b = match image.and_then(|i| i.vagrant.clone()) {
303 Some(name) => VagrantBox {
304 name,
305 version: image.and_then(|i| i.vagrant_version.clone()),
306 libvirt: None,
307 qemu: image.and_then(|i| i.qemu.clone()),
308 hyperv: HyperVBox::Main,
309 },
310 None => builtin_vagrant(&vm.os)?,
311 };
312 if let Some(name) = image.and_then(|i| i.hyperv.clone()) {
313 b.hyperv = HyperVBox::Other { name, version: None };
314 }
315 Some(b)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::model::KNOWN_OS;
322
323 const TABLE: &str = "os:\n debian@12: { vagrant: my-org/debian-12 }\naccess: { docker: kalilinux/kali-rolling, vm: debian }\n";
325
326 #[test]
327 fn a_table_holds_each_os_by_its_image_table_name() {
328 let table = Table::parse(TABLE).unwrap();
329 assert_eq!(table.os.keys().collect::<Vec<_>>(), ["debian-12"]);
330 assert_eq!(table.os["debian-12"].vagrant.as_deref(), Some("my-org/debian-12"));
331 let access = table.access.unwrap();
332 assert_eq!(
333 (access.docker.as_deref(), access.vm.as_deref()),
334 (Some("kalilinux/kali-rolling"), Some("debian-12"))
335 );
336 assert!(
337 Table::parse("access: { vm: windows-95 }")
338 .unwrap_err()
339 .to_string()
340 .contains("unknown OS `windows-95`")
341 );
342 }
343
344 #[test]
345 fn a_table_gives_vms_their_image_unless_the_spec_does() {
346 let table = Table::parse(TABLE).unwrap();
347 let spec = crate::testing::model(&crate::testing::lab(
348 "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",
349 ))
350 .unwrap();
351 let applied = table.apply(&spec);
352 let vagrant = |m: &str| applied.machines[m].vm.as_ref().and_then(|v| v.image.as_ref()).and_then(|i| i.vagrant.clone());
353 assert_eq!(vagrant("a").as_deref(), Some("my-org/debian-12"));
354 assert_eq!(vagrant("b").as_deref(), Some("own/box"));
355 assert_eq!(applied.machines["c"].vm.as_ref().and_then(|v| v.image.clone()), None);
356 }
357
358 #[test]
359 fn a_table_supplies_the_access_machine_in_both_forms() {
360 let table = Table::parse(TABLE).unwrap();
361 let spec = crate::testing::model(&crate::testing::lab("machines:\n user: { networks: { lan: 10 }, access: true }\n")).unwrap();
362 let user = &table.apply(&spec).machines["user"];
363 assert!(user.supplied);
364 assert_eq!(user.docker.as_ref().and_then(|d| d.image.as_deref()), Some("kalilinux/kali-rolling"));
365 let vm = user.vm.as_ref().unwrap();
366 assert_eq!(
367 (vm.os.as_str(), vm.image.as_ref().and_then(|i| i.vagrant.as_deref())),
368 ("debian-12", Some("my-org/debian-12"))
369 );
370 let own = crate::testing::model(&crate::testing::lab(
371 "machines:\n user: { networks: { lan: 10 }, access: true, docker: { image: mine } }\n",
372 ))
373 .unwrap();
374 let kept = &table.apply(&own).machines["user"];
375 assert_eq!(kept.docker.as_ref().and_then(|d| d.image.as_deref()), Some("mine"));
376 }
377
378 #[test]
379 fn a_windows_box_answers_winrm_as_its_image_says() {
380 let declared: VmImpl = serde_yaml_ng::from_str("os: windows-server-2022\nimage: { winrm: ssl }\n").unwrap();
381 assert_eq!(winrm(&declared), crate::model::Winrm::Ssl);
382 let plain: VmImpl = serde_yaml_ng::from_str("os: windows-server-2022\n").unwrap();
383 assert_eq!(winrm(&plain), crate::model::Winrm::Plaintext);
384 }
385
386 #[test]
387 fn every_os_name_has_a_vagrant_box() {
388 for os in KNOWN_OS {
389 assert!(builtin_vagrant(os).is_some(), "no built-in Vagrant box for `{os}`");
390 }
391 }
392
393 #[test]
394 fn hyperv_boxes_where_the_main_one_has_no_build() {
395 let other = |os: &str| match builtin_vagrant(os).map(|b| b.hyperv) {
396 Some(HyperVBox::Other { name, version }) => Some((name, version)),
397 _ => None,
398 };
399 let pinned = |n: &str, v: &str| Some((n.to_owned(), Some(v.to_owned())));
400 assert_eq!(other("debian-12"), pinned("generic/debian12", "4.3.12"));
401 assert_eq!(other("ubuntu-24.04"), pinned("gusztavvargadr/ubuntu-server-2404-lts", "2607.0.0"));
402 assert_eq!(other("windows-server-2019"), pinned("StefanScherer/windows_2019", "2020.02.12"));
403 for os in ["kali", "windows-11", "windows-server-2022"] {
404 assert_eq!(builtin_vagrant(os).map(|b| b.hyperv), Some(HyperVBox::Main), "{os}");
405 }
406 for os in ["debian-13", "ubuntu-14.04", "fedora-42"] {
407 assert_eq!(builtin_vagrant(os).map(|b| b.hyperv), Some(HyperVBox::Missing), "{os}");
408 }
409 }
410
411 #[test]
412 fn the_specs_hyperv_box_wins() {
413 let vm: VmImpl = serde_yaml_ng::from_str("os: debian-13\nimage: { hyperv: my/debian13 }\n").unwrap();
414 let b = vagrant(&vm).unwrap();
415 assert_eq!(b.name, "bento/debian-13");
416 let mine = HyperVBox::Other {
417 name: "my/debian13".into(),
418 version: None,
419 };
420 assert_eq!(b.hyperv, mine);
421 }
422}