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"), None),
267 "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 "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#[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#[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 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}