Skip to main content

petri/vm/openvmm/
mshv.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! The OpenVMM device platform for an mshv root partition.
5
6use super::NIC_MAC_ADDRESS;
7use super::OpenVmmPetriBackend;
8use crate::BootDeviceType;
9use crate::Firmware;
10use crate::PetriVmBuilder;
11use petri_artifacts_common::tags::MachineArch;
12
13impl PetriVmBuilder<OpenVmmPetriBackend> {
14    /// Add the mshv platform to a UEFI guest: nested virtualization, PCIe on
15    /// segment 1, TCP Pipette, and 9p shares, without VMBus or virtio-vsock.
16    ///
17    /// This does not build or replace guest media. It appends a root complex
18    /// rather than changing existing ones, and composes with backend modifiers
19    /// registered before or after this call. Segment 1 must be available.
20    pub fn with_mshv_platform(self) -> Self {
21        assert!(
22            self.arch() == MachineArch::X86_64
23                && MachineArch::host() == MachineArch::X86_64
24                && cfg!(target_os = "linux"),
25            "mshv platform requires x86-64 Linux"
26        );
27        assert!(
28            matches!(self.config.firmware, Firmware::Uefi { .. }),
29            "mshv platform requires UEFI firmware"
30        );
31        self.with_no_vmbus()
32            .with_virtio_vsock(false)
33            .with_nested_virt()
34            .with_serial_output()
35            .with_boot_device_type(BootDeviceType::PcieVirtioBlk)
36            .with_pcie_boot_port("s1rc0rp0")
37            .modify_backend(|backend| {
38                backend
39                    .with_pcie_root_topology_at(1, 1, 1, 2)
40                    .with_tcp_pipette_nic("s1rc0rp1", NIC_MAC_ADDRESS)
41            })
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::super::PetriVmConfigOpenVmm;
48    use super::*;
49    use crate::Disk;
50    use crate::PetriLogSource;
51    use crate::PetriVmArtifacts;
52    use crate::ResolvedArtifact;
53    use crate::UefiConfig;
54    use crate::UefiGuest;
55    use crate::mshv_boot::MshvBoot;
56    use petri_artifacts_common::tags::OsFlavor;
57    use test_with_tracing::test;
58    #[cfg(any(windows, target_os = "linux"))]
59    use vm_resource::IntoResource;
60
61    fn builder_with_guest(
62        driver: &pal_async::DefaultDriver,
63        logs: &PetriLogSource,
64        firmware: &std::path::Path,
65        guest: UefiGuest,
66        arch: MachineArch,
67    ) -> anyhow::Result<PetriVmBuilder<OpenVmmPetriBackend>> {
68        Ok(PetriVmBuilder::minimal(
69            "platform",
70            logs,
71            PetriVmArtifacts {
72                backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
73                firmware: Firmware::Uefi {
74                    uefi_firmware: ResolvedArtifact::from_local_path(firmware.to_path_buf()),
75                    guest,
76                    uefi_config: UefiConfig {
77                        disable_frontpage: false,
78                        ..Default::default()
79                    },
80                },
81                arch,
82                agent_image: None,
83                openhcl_agent_image: None,
84                pipette_binary: None,
85                nested_test: None,
86            },
87            driver,
88        )?
89        .with_external_pipette())
90    }
91
92    fn mshv_guest(dir: &std::path::Path) -> anyhow::Result<UefiGuest> {
93        let boot = dir.join("boot");
94        fs_err::create_dir_all(boot.join("Windows/System32"))?;
95        fs_err::create_dir_all(boot.join("EFI/BOOT"))?;
96        for name in [
97            "HvLoader.efi",
98            "lxhvloader.dll",
99            "Windows/System32/hvix64.exe",
100            "Windows/System32/hvax64.exe",
101            "EFI/BOOT/bootx64.efi",
102            "EFI/BOOT/grubx64.efi",
103        ] {
104            fs_err::write(boot.join(name), name)?;
105        }
106        let kernel = dir.join("kernel");
107        fs_err::write(&kernel, b"root kernel")?;
108        let initrd = tempfile::NamedTempFile::new_in(dir)?;
109        fs_err::write(initrd.path(), b"prepared initrd")?;
110        Ok(UefiGuest::Mshv(MshvBoot {
111            kernel: ResolvedArtifact::from_local_path(kernel),
112            initrd: crate::PetriInitrd {
113                path: crate::TempOrPersistentPath::Temp(std::sync::Arc::new(
114                    initrd.into_temp_path(),
115                )),
116                rdinit_param: "/caller-init".into(),
117            },
118            boot_artifacts: ResolvedArtifact::from_local_path(dir.to_path_buf()),
119        }))
120    }
121
122    #[test]
123    fn mshv_guest_prepares_an_owned_boot_disk() -> anyhow::Result<()> {
124        let temp = tempfile::tempdir()?;
125        let logs = PetriLogSource::file_only(temp.path())?;
126        pal_async::DefaultPool::run_with(async |driver| {
127            let guest = mshv_guest(temp.path())?;
128            let UefiGuest::Mshv(boot) = &guest else {
129                unreachable!()
130            };
131            let initrd = boot.initrd.path.as_ref().to_path_buf();
132            let builder = builder_with_guest(
133                &driver,
134                &logs,
135                &temp.path().join("unused-firmware"),
136                guest,
137                MachineArch::X86_64,
138            )?
139            .with_boot_device_type(BootDeviceType::PcieVirtioBlk)
140            .with_pcie_boot_port("s1rc0rp0");
141            assert!(builder.config.pcie_virtio_blk_drives.is_empty());
142            assert_eq!(builder.properties().os_flavor, OsFlavor::Linux);
143            assert!(!builder.properties().is_linux_direct);
144            let builder = builder.add_boot_disk()?;
145            let [drive] = builder.config.pcie_virtio_blk_drives.as_slice() else {
146                panic!("expected one mshv boot disk")
147            };
148            assert_eq!(drive.port_name, "s1rc0rp0");
149            let Some(Disk::Temporary(path)) = &drive.drive.disk else {
150                panic!("expected an owned temporary disk")
151            };
152            let output = path.to_path_buf();
153            assert!(output.exists());
154            assert!(initrd.exists());
155            drop(builder);
156            assert!(!output.exists());
157            assert!(!initrd.exists());
158            Ok(())
159        })
160    }
161
162    #[test]
163    fn mshv_guest_rejects_unsupported_firmware_and_reports_missing_inputs() -> anyhow::Result<()> {
164        let temp = tempfile::tempdir()?;
165        let logs = PetriLogSource::file_only(temp.path())?;
166        pal_async::DefaultPool::run_with(async |driver| {
167            let error = builder_with_guest(
168                &driver,
169                &logs,
170                &temp.path().join("unused-firmware"),
171                mshv_guest(temp.path())?,
172                MachineArch::Aarch64,
173            )
174            .expect_err("mshv requires x86-64");
175            assert!(error.to_string().contains("x86-64 UEFI"), "{error:#}");
176            let error = PetriVmBuilder::minimal(
177                "unsupported",
178                &logs,
179                PetriVmArtifacts {
180                    backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
181                    firmware: Firmware::OpenhclUefi {
182                        guest: mshv_guest(temp.path())?,
183                        igvm_firmware: crate::IgvmFirmwareSource::VmgsOrInBox,
184                        isolation: None,
185                        uefi_config: Default::default(),
186                        openhcl_config: Default::default(),
187                    },
188                    arch: MachineArch::X86_64,
189                    agent_image: None,
190                    openhcl_agent_image: None,
191                    pipette_binary: None,
192                    nested_test: None,
193                },
194                &driver,
195            )
196            .expect_err("mshv with OpenHCL is unsupported");
197            assert!(error.to_string().contains("OpenHCL"), "{error:#}");
198            let builder = builder_with_guest(
199                &driver,
200                &logs,
201                &temp.path().join("unused-firmware"),
202                mshv_guest(temp.path())?,
203                MachineArch::X86_64,
204            )?;
205            fs_err::remove_file(temp.path().join("kernel"))?;
206            let error = builder.add_boot_disk().expect_err("missing kernel");
207            assert!(
208                error.to_string().contains("composing mshv boot disk"),
209                "{error:#}"
210            );
211            assert!(format!("{error:#}").contains("kernel"), "{error:#}");
212            Ok(())
213        })
214    }
215
216    async fn configure(
217        builder: PetriVmBuilder<OpenVmmPetriBackend>,
218    ) -> anyhow::Result<PetriVmConfigOpenVmm> {
219        let mut builder = builder.add_boot_disk()?.add_agent_disks()?;
220        let properties = builder.properties();
221        let modify = builder.modify_vmm_config.take();
222        let config = PetriVmConfigOpenVmm::new(
223            &ResolvedArtifact::from_local_path("unused-openvmm".into()),
224            builder.config,
225            &builder.resources,
226            &properties,
227        )
228        .await?;
229        let config = match modify {
230            Some(modify) => modify.0(config),
231            None => config,
232        };
233        Ok(config.with_guest_shares())
234    }
235
236    #[test]
237    fn linux_direct_boot_respects_agent_ownership() -> anyhow::Result<()> {
238        let temp = tempfile::tempdir()?;
239        let logs = PetriLogSource::file_only(temp.path())?;
240        let base = temp.path().join("base");
241        let prepared = temp.path().join("prepared");
242        fs_err::write(&base, b"base initrd")?;
243        fs_err::write(&prepared, b"prepared initrd")?;
244        pal_async::DefaultPool::run_with(async |driver| {
245            // Legacy serial bootstrap requires 16550 wait-for-RTS, unavailable on PL011.
246            for (arch, external, prebuilt) in [
247                (MachineArch::X86_64, false, false),
248                (MachineArch::X86_64, false, true),
249                (MachineArch::X86_64, true, false),
250                (MachineArch::X86_64, true, true),
251                (MachineArch::Aarch64, false, true),
252                (MachineArch::Aarch64, true, false),
253                (MachineArch::Aarch64, true, true),
254            ] {
255                let mut builder = PetriVmBuilder::minimal(
256                    "direct",
257                    &logs,
258                    PetriVmArtifacts {
259                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
260                        firmware: Firmware::LinuxDirect {
261                            kernel: ResolvedArtifact::from_local_path(base.clone()),
262                            initrd: ResolvedArtifact::from_local_path(base.clone()),
263                        },
264                        arch,
265                        agent_image: None,
266                        openhcl_agent_image: None,
267                        pipette_binary: None,
268                        nested_test: None,
269                    },
270                    &driver,
271                )?
272                .with_serial_output();
273                if external {
274                    builder = builder.with_external_pipette();
275                }
276                if prebuilt {
277                    builder = builder.with_prebuilt_initrd(crate::PetriInitrd {
278                        path: crate::TempOrPersistentPath::Persistent(prepared.clone()),
279                        rdinit_param: "/external-init".into(),
280                    });
281                }
282                let backend = configure(builder).await?;
283                assert_eq!(
284                    backend.resources.linux_direct_serial_agent.is_some(),
285                    !external && !prebuilt
286                );
287                let openvmm_defs::config::LoadMode::Linux {
288                    initrd: Some(mut file),
289                    cmdline,
290                    ..
291                } = backend.config.load_mode
292                else {
293                    panic!("expected Linux direct")
294                };
295                if prebuilt {
296                    assert!(cmdline.contains("rdinit=/external-init"));
297                } else if external {
298                    assert!(
299                        !cmdline
300                            .split_whitespace()
301                            .any(|arg| arg.starts_with("rdinit="))
302                    );
303                } else {
304                    assert!(cmdline.contains("rdinit=/bin/sh"));
305                }
306                let mut contents = String::new();
307                std::io::Read::read_to_string(&mut file, &mut contents)?;
308                assert_eq!(
309                    contents,
310                    if prebuilt {
311                        "prepared initrd"
312                    } else {
313                        "base initrd"
314                    }
315                );
316            }
317            Ok(())
318        })
319    }
320
321    #[cfg(any(windows, target_os = "linux"))]
322    #[test]
323    fn shares_get_private_ports_after_all_backend_modifiers() -> anyhow::Result<()> {
324        let temp = tempfile::tempdir()?;
325        let logs = PetriLogSource::file_only(temp.path())?;
326        let input = temp.path().join("boot-input");
327        fs_err::write(&input, b"unused boot input")?;
328        pal_async::DefaultPool::run_with(async |driver| {
329            for custom_topology in [false, true] {
330                let mut builder = PetriVmBuilder::minimal(
331                    "shares",
332                    &logs,
333                    PetriVmArtifacts {
334                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
335                        firmware: Firmware::LinuxDirect {
336                            kernel: ResolvedArtifact::from_local_path(input.clone()),
337                            initrd: ResolvedArtifact::from_local_path(input.clone()),
338                        },
339                        arch: MachineArch::host(),
340                        agent_image: None,
341                        openhcl_agent_image: None,
342                        pipette_binary: None,
343                        nested_test: None,
344                    },
345                    &driver,
346                )?
347                .with_shared_directory("z-data", temp.path(), "/data")
348                .with_shared_directory("a-cache", temp.path(), "/cache");
349                if custom_topology {
350                    builder = builder.modify_backend(|b| {
351                        b.with_pcie_root_topology_at(1, 1, 1, 1)
352                            .with_custom_config(|c| {
353                                c.pcie_root_complexes[0].ports[0].name = "petri-share-0".into();
354                                c.pcie_devices.push(openvmm_defs::config::PcieDeviceConfig {
355                                    port_name: "petri-share-0".into(),
356                                    resource: virtio_resources::VirtioPciDeviceHandle(
357                                        virtio_resources::rng::VirtioRngHandle.into_resource(),
358                                    )
359                                    .into_resource(),
360                                });
361                            })
362                    });
363                }
364                let backend = configure(builder).await?;
365                let [root] = backend.config.pcie_root_complexes.as_slice() else {
366                    panic!("expected one root complex")
367                };
368                assert_eq!(root.segment, u16::from(custom_topology));
369                let first_share = usize::from(custom_topology);
370                for i in 0..2 {
371                    let device = &backend.config.pcie_devices[first_share + i];
372                    assert_eq!(device.port_name, format!("petri-share-{}", first_share + i));
373                    assert!(root.ports.iter().any(|p| p.name == device.port_name));
374                }
375                assert_eq!(root.ports.len(), first_share + 2);
376                assert!(backend.guest_shares.is_empty());
377            }
378            Ok(())
379        })
380    }
381
382    #[test]
383    fn isolated_agent_is_external_only_when_requested() -> anyhow::Result<()> {
384        let temp = tempfile::tempdir()?;
385        let logs = PetriLogSource::file_only(temp.path())?;
386        let igvm = temp.path().join("guest.igvm");
387        fs_err::write(&igvm, b"unused measured image")?;
388        pal_async::DefaultPool::run_with(async |driver| {
389            for requested in [false, true] {
390                let builder = PetriVmBuilder::minimal(
391                    "isolated",
392                    &logs,
393                    PetriVmArtifacts {
394                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
395                        firmware: Firmware::IgvmLinuxDirect {
396                            igvm_path: ResolvedArtifact::from_local_path(igvm.clone()),
397                            isolation: crate::IsolationType::Snp,
398                        },
399                        arch: MachineArch::X86_64,
400                        agent_image: None,
401                        openhcl_agent_image: None,
402                        pipette_binary: requested
403                            .then(|| ResolvedArtifact::from_local_path("unused-pipette".into())),
404                        nested_test: None,
405                    },
406                    &driver,
407                )?
408                .with_serial_output();
409                assert_eq!(builder.using_vtl0_pipette(), requested);
410                assert!(!builder.properties().uses_pipette_as_init);
411                assert!(builder.pipette_binary.is_none());
412                let backend = configure(builder).await?;
413                assert!(backend.resources.linux_direct_serial_agent.is_none());
414                assert!(matches!(
415                    backend.config.load_mode,
416                    openvmm_defs::config::LoadMode::Igvm { .. }
417                ));
418            }
419            Ok(())
420        })
421    }
422
423    #[test]
424    fn platform_composes_without_replacing_uefi_or_other_roots() -> anyhow::Result<()> {
425        if MachineArch::host() != MachineArch::X86_64 || !cfg!(target_os = "linux") {
426            return Ok(());
427        }
428        let temp = tempfile::tempdir()?;
429        let firmware = tempfile::NamedTempFile::new()?;
430        let logs = PetriLogSource::file_only(temp.path())?;
431        pal_async::DefaultPool::run_with(async |driver| {
432            let builder = builder_with_guest(
433                &driver,
434                &logs,
435                firmware.path(),
436                mshv_guest(temp.path())?,
437                MachineArch::X86_64,
438            )?
439            .modify_backend(|b| b.with_pcie_root_topology(1, 1, 1))
440            .with_mshv_platform()
441            .with_shared_directory("data", temp.path(), "/mounted elsewhere")
442            .modify_backend(|b| b.with_pcie_root_topology(1, 1, 1));
443            assert!(matches!(
444                builder.config.firmware,
445                Firmware::Uefi {
446                    guest: UefiGuest::Mshv(_),
447                    uefi_config: UefiConfig {
448                        disable_frontpage: false,
449                        ..
450                    },
451                    ..
452                }
453            ));
454            assert!(!builder.properties().is_linux_direct);
455            assert!(builder.properties().no_vmbus);
456            assert!(!builder.properties().use_virtio_vsock);
457            let backend = configure(builder).await?;
458            assert_eq!(
459                backend
460                    .config
461                    .pcie_root_complexes
462                    .iter()
463                    .map(|rc| rc.segment)
464                    .collect::<Vec<_>>(),
465                [0, 1, 2]
466            );
467            assert_eq!(
468                backend.config.pcie_root_complexes[0].ports[0].name,
469                "s0rc0rp0"
470            );
471            assert_eq!(backend.config.pcie_root_complexes[1].name, "s1rc0");
472            assert_eq!(backend.config.pcie_root_complexes[1].ports.len(), 3);
473            for port in ["s1rc0rp0", "s1rc0rp1", "petri-share-0"] {
474                assert!(
475                    backend
476                        .config
477                        .pcie_devices
478                        .iter()
479                        .any(|d| d.port_name == port)
480                );
481            }
482            assert!(backend.guest_shares.is_empty());
483            Ok(())
484        })
485    }
486
487    #[test]
488    #[should_panic(expected = "PCI segment range overlaps an existing root complex")]
489    fn segment_conflicts_stop_later_modifiers() {
490        let temp = tempfile::tempdir().unwrap();
491        let firmware = tempfile::NamedTempFile::new().unwrap();
492        let logs = PetriLogSource::file_only(temp.path()).unwrap();
493        pal_async::DefaultPool::run_with(async |driver| {
494            let builder = builder_with_guest(
495                &driver,
496                &logs,
497                firmware.path(),
498                UefiGuest::None,
499                MachineArch::host(),
500            )?
501            .modify_backend(|b| b.with_pcie_root_topology_at(1, 1, 1, 1))
502            .modify_backend(|b| b.with_pcie_root_topology_at(1, 1, 1, 1))
503            .modify_backend(|_| panic!("segment conflict should stop later modifiers"));
504            configure(builder).await?;
505            Ok::<_, anyhow::Error>(())
506        })
507        .unwrap();
508    }
509}