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, virtio-console, virtio-vsock Pipette, and 9p shares, without VMBus.
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(true)
33            .with_virtio_console()
34            .with_nested_virt()
35            .with_serial_output()
36            .with_boot_device_type(BootDeviceType::PcieVirtioBlk)
37            .with_pcie_boot_port("s1rc0rp0")
38            .modify_backend(|backend| {
39                backend
40                    .with_pcie_root_topology_at(1, 1, 1, 2)
41                    .with_virtio_nic("s1rc0rp1", NIC_MAC_ADDRESS)
42            })
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::super::PetriVmConfigOpenVmm;
49    use super::*;
50    use crate::Disk;
51    use crate::PetriLogSource;
52    use crate::PetriVmArtifacts;
53    use crate::ResolvedArtifact;
54    use crate::UefiConfig;
55    use crate::UefiGuest;
56    use crate::mshv_boot::MshvBoot;
57    use petri_artifacts_common::tags::OsFlavor;
58    use test_with_tracing::test;
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        for name in [
96            "HvLoader.efi",
97            "lxhvloader.dll",
98            "Windows/System32/hvix64.exe",
99            "Windows/System32/hvax64.exe",
100            "mshv_host_boot.efi",
101        ] {
102            fs_err::write(boot.join(name), name)?;
103        }
104        let kernel = dir.join("kernel");
105        fs_err::write(&kernel, b"root kernel")?;
106        let initrd = tempfile::NamedTempFile::new_in(dir)?;
107        fs_err::write(initrd.path(), b"prepared initrd")?;
108        Ok(UefiGuest::Mshv(MshvBoot {
109            host_boot: ResolvedArtifact::from_local_path(boot.join("mshv_host_boot.efi")),
110            kernel: ResolvedArtifact::from_local_path(kernel),
111            initrd: crate::PetriInitrd {
112                path: crate::TempOrPersistentPath::Temp(std::sync::Arc::new(
113                    initrd.into_temp_path(),
114                )),
115                rdinit_param: "/caller-init".into(),
116            },
117            boot_artifacts: ResolvedArtifact::from_local_path(dir.to_path_buf()),
118            kernel_cmdline: "console=ttyS0 panic=-1".into(),
119            hypervisor_cmdline: "MSHV_ENABLE=TRUE".into(),
120        }))
121    }
122
123    #[test]
124    fn mshv_guest_prepares_an_owned_boot_disk() -> anyhow::Result<()> {
125        let temp = tempfile::tempdir()?;
126        let logs = PetriLogSource::file_only(temp.path())?;
127        pal_async::DefaultPool::run_with(async |driver| {
128            let guest = mshv_guest(temp.path())?;
129            let UefiGuest::Mshv(boot) = &guest else {
130                unreachable!()
131            };
132            let initrd = boot.initrd.path.as_ref().to_path_buf();
133            let builder = builder_with_guest(
134                &driver,
135                &logs,
136                &temp.path().join("unused-firmware"),
137                guest,
138                MachineArch::X86_64,
139            )?
140            .with_boot_device_type(BootDeviceType::PcieVirtioBlk)
141            .with_pcie_boot_port("s1rc0rp0");
142            assert!(builder.config.pcie_virtio_blk_drives.is_empty());
143            assert_eq!(builder.properties().os_flavor, OsFlavor::Linux);
144            assert!(!builder.properties().is_linux_direct);
145            let builder = builder.add_boot_disk()?;
146            let [drive] = builder.config.pcie_virtio_blk_drives.as_slice() else {
147                panic!("expected one mshv boot disk")
148            };
149            assert_eq!(drive.port_name, "s1rc0rp0");
150            let Some(Disk::Temporary(path)) = &drive.drive.disk else {
151                panic!("expected an owned temporary disk")
152            };
153            let output = path.to_path_buf();
154            assert!(output.exists());
155            assert!(initrd.exists());
156            drop(builder);
157            assert!(!output.exists());
158            assert!(!initrd.exists());
159            Ok(())
160        })
161    }
162
163    #[test]
164    fn mshv_guest_rejects_unsupported_firmware_and_reports_missing_inputs() -> anyhow::Result<()> {
165        let temp = tempfile::tempdir()?;
166        let logs = PetriLogSource::file_only(temp.path())?;
167        pal_async::DefaultPool::run_with(async |driver| {
168            let error = builder_with_guest(
169                &driver,
170                &logs,
171                &temp.path().join("unused-firmware"),
172                mshv_guest(temp.path())?,
173                MachineArch::Aarch64,
174            )
175            .expect_err("mshv requires x86-64");
176            assert!(error.to_string().contains("x86-64 UEFI"), "{error:#}");
177            let error = PetriVmBuilder::minimal(
178                "unsupported",
179                &logs,
180                PetriVmArtifacts {
181                    backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
182                    firmware: Firmware::OpenhclUefi {
183                        guest: mshv_guest(temp.path())?,
184                        igvm_firmware: crate::IgvmFirmwareSource::VmgsOrInBox,
185                        isolation: None,
186                        uefi_config: Default::default(),
187                        openhcl_config: Default::default(),
188                    },
189                    arch: MachineArch::X86_64,
190                    agent_image: None,
191                    openhcl_agent_image: None,
192                    pipette_binary: None,
193                    nested_test: None,
194                },
195                &driver,
196            )
197            .expect_err("mshv with OpenHCL is unsupported");
198            assert!(error.to_string().contains("OpenHCL"), "{error:#}");
199            let builder = builder_with_guest(
200                &driver,
201                &logs,
202                &temp.path().join("unused-firmware"),
203                mshv_guest(temp.path())?,
204                MachineArch::X86_64,
205            )?;
206            fs_err::remove_file(temp.path().join("kernel"))?;
207            let error = builder.add_boot_disk().expect_err("missing kernel");
208            assert!(
209                error.to_string().contains("composing mshv boot disk"),
210                "{error:#}"
211            );
212            assert!(format!("{error:#}").contains("kernel"), "{error:#}");
213            Ok(())
214        })
215    }
216
217    async fn configure(
218        builder: PetriVmBuilder<OpenVmmPetriBackend>,
219    ) -> anyhow::Result<PetriVmConfigOpenVmm> {
220        let mut builder = builder.add_boot_disk()?.add_agent_disks()?;
221        let properties = builder.properties();
222        let modify = builder.modify_vmm_config.take();
223        let config = PetriVmConfigOpenVmm::new(
224            &ResolvedArtifact::from_local_path("unused-openvmm".into()),
225            builder.config,
226            &builder.resources,
227            &properties,
228        )
229        .await?;
230        let config = match modify {
231            Some(modify) => modify.0(config),
232            None => config,
233        };
234        Ok(config.with_private_virtio_devices())
235    }
236
237    #[test]
238    fn linux_direct_boot_respects_agent_ownership() -> anyhow::Result<()> {
239        let temp = tempfile::tempdir()?;
240        let logs = PetriLogSource::file_only(temp.path())?;
241        let base = temp.path().join("base");
242        let prepared = temp.path().join("prepared");
243        fs_err::write(&base, b"base initrd")?;
244        fs_err::write(&prepared, b"prepared initrd")?;
245        pal_async::DefaultPool::run_with(async |driver| {
246            // Legacy serial bootstrap requires 16550 wait-for-RTS, unavailable on PL011.
247            for (arch, external, prebuilt) in [
248                (MachineArch::X86_64, false, false),
249                (MachineArch::X86_64, false, true),
250                (MachineArch::X86_64, true, false),
251                (MachineArch::X86_64, true, true),
252                (MachineArch::Aarch64, false, true),
253                (MachineArch::Aarch64, true, false),
254                (MachineArch::Aarch64, true, true),
255            ] {
256                let mut builder = PetriVmBuilder::minimal(
257                    "direct",
258                    &logs,
259                    PetriVmArtifacts {
260                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
261                        firmware: Firmware::LinuxDirect {
262                            kernel: ResolvedArtifact::from_local_path(base.clone()),
263                            initrd: ResolvedArtifact::from_local_path(base.clone()),
264                        },
265                        arch,
266                        agent_image: None,
267                        openhcl_agent_image: None,
268                        pipette_binary: None,
269                        nested_test: None,
270                    },
271                    &driver,
272                )?
273                .with_serial_output();
274                if external {
275                    builder = builder.with_external_pipette();
276                }
277                if prebuilt {
278                    builder = builder.with_prebuilt_initrd(crate::PetriInitrd {
279                        path: crate::TempOrPersistentPath::Persistent(prepared.clone()),
280                        rdinit_param: "/external-init".into(),
281                    });
282                }
283                let backend = configure(builder).await?;
284                assert_eq!(
285                    backend.resources.linux_direct_serial_agent.is_some(),
286                    !external && !prebuilt
287                );
288                let openvmm_defs::config::LoadMode::Linux {
289                    initrd: Some(mut file),
290                    cmdline,
291                    ..
292                } = backend.config.load_mode
293                else {
294                    panic!("expected Linux direct")
295                };
296                if prebuilt {
297                    assert!(cmdline.contains("rdinit=/external-init"));
298                } else if external {
299                    assert!(
300                        !cmdline
301                            .split_whitespace()
302                            .any(|arg| arg.starts_with("rdinit="))
303                    );
304                } else {
305                    assert!(cmdline.contains("rdinit=/bin/sh"));
306                }
307                let mut contents = String::new();
308                std::io::Read::read_to_string(&mut file, &mut contents)?;
309                assert_eq!(
310                    contents,
311                    if prebuilt {
312                        "prepared initrd"
313                    } else {
314                        "base initrd"
315                    }
316                );
317            }
318            Ok(())
319        })
320    }
321
322    #[cfg(any(windows, target_os = "linux"))]
323    #[test]
324    fn shares_get_private_ports_after_all_backend_modifiers() -> anyhow::Result<()> {
325        let temp = tempfile::tempdir()?;
326        let logs = PetriLogSource::file_only(temp.path())?;
327        let input = temp.path().join("boot-input");
328        fs_err::write(&input, b"unused boot input")?;
329        pal_async::DefaultPool::run_with(async |driver| {
330            for custom_topology in [false, true] {
331                let mut builder = PetriVmBuilder::minimal(
332                    "shares",
333                    &logs,
334                    PetriVmArtifacts {
335                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
336                        firmware: Firmware::LinuxDirect {
337                            kernel: ResolvedArtifact::from_local_path(input.clone()),
338                            initrd: ResolvedArtifact::from_local_path(input.clone()),
339                        },
340                        arch: MachineArch::host(),
341                        agent_image: None,
342                        openhcl_agent_image: None,
343                        pipette_binary: None,
344                        nested_test: None,
345                    },
346                    &driver,
347                )?
348                .with_shared_directory("z-data", temp.path(), "/data")
349                .with_shared_directory("a-cache", temp.path(), "/cache");
350                if custom_topology {
351                    builder = builder.modify_backend(|b| {
352                        b.with_pcie_root_topology_at(1, 1, 1, 1)
353                            .with_custom_config(|c| {
354                                c.pcie_root_complexes[0].ports[0].name = "petri-share-0".into();
355                                c.pcie_devices.push(openvmm_defs::config::PcieDeviceConfig {
356                                    port_name: "petri-share-0".into(),
357                                    resource: virtio_resources::VirtioPciDeviceHandle(
358                                        virtio_resources::rng::VirtioRngHandle.into_resource(),
359                                    )
360                                    .into_resource(),
361                                });
362                            })
363                    });
364                }
365                let backend = configure(builder).await?;
366                let [root] = backend.config.pcie_root_complexes.as_slice() else {
367                    panic!("expected one root complex")
368                };
369                assert_eq!(root.segment, u16::from(custom_topology));
370                let first_share = usize::from(custom_topology);
371                for i in 0..2 {
372                    let device = &backend.config.pcie_devices[first_share + i];
373                    assert_eq!(device.port_name, format!("petri-share-{}", first_share + i));
374                    assert!(root.ports.iter().any(|p| p.name == device.port_name));
375                }
376                assert_eq!(root.ports.len(), first_share + 2);
377                assert!(backend.guest_shares.is_empty());
378            }
379            Ok(())
380        })
381    }
382
383    #[test]
384    fn virtio_console_keeps_external_linux_direct_initrd_and_uses_vsock() -> anyhow::Result<()> {
385        let temp = tempfile::tempdir()?;
386        let logs = PetriLogSource::file_only(temp.path())?;
387        let base = temp.path().join("base-initrd");
388        fs_err::write(&base, b"external guest bootstrap")?;
389        pal_async::DefaultPool::run_with(async |driver| {
390            let builder = PetriVmBuilder::minimal(
391                "virtio-console",
392                &logs,
393                PetriVmArtifacts {
394                    backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
395                    firmware: Firmware::LinuxDirect {
396                        kernel: ResolvedArtifact::from_local_path(base.clone()),
397                        initrd: ResolvedArtifact::from_local_path(base.clone()),
398                    },
399                    arch: MachineArch::X86_64,
400                    agent_image: None,
401                    openhcl_agent_image: None,
402                    pipette_binary: None,
403                    nested_test: None,
404                },
405                &driver,
406            )?
407            .with_external_pipette()
408            .with_virtio_console()
409            .with_no_vmbus();
410            let mut backend = configure(builder).await?;
411            assert!(backend.resources.linux_direct_serial_agent.is_none());
412            assert!(backend.resources.tcp_pipette_port.is_none());
413            assert!(backend.config.vmbus.is_none());
414            assert_eq!(backend.config.pcie_devices.len(), 2);
415            let openvmm_defs::config::LoadMode::Linux {
416                cmdline,
417                initrd: Some(ref mut file),
418                ..
419            } = backend.config.load_mode
420            else {
421                panic!("expected Linux direct")
422            };
423            assert!(cmdline.contains("console=hvc0"));
424            assert!(!cmdline.contains("console=ttyS0"));
425            assert!(cmdline.contains("initcall_blacklist=hv_sock_init"));
426            assert!(!cmdline.contains("rdinit="));
427            let mut bytes = Vec::new();
428            std::io::Read::read_to_end(file, &mut bytes)?;
429            assert_eq!(bytes, b"external guest bootstrap");
430            Ok(())
431        })
432    }
433
434    #[test]
435    fn isolated_agent_is_external_only_when_requested() -> anyhow::Result<()> {
436        let temp = tempfile::tempdir()?;
437        let logs = PetriLogSource::file_only(temp.path())?;
438        let igvm = temp.path().join("guest.igvm");
439        fs_err::write(&igvm, b"unused measured image")?;
440        pal_async::DefaultPool::run_with(async |driver| {
441            for requested in [false, true] {
442                let builder = PetriVmBuilder::minimal(
443                    "isolated",
444                    &logs,
445                    PetriVmArtifacts {
446                        backend: OpenVmmPetriBackend::from_binary("unused-openvmm".into()),
447                        firmware: Firmware::IgvmLinuxDirect {
448                            igvm_path: ResolvedArtifact::from_local_path(igvm.clone()),
449                            isolation: crate::IsolationType::Snp,
450                        },
451                        arch: MachineArch::X86_64,
452                        agent_image: None,
453                        openhcl_agent_image: None,
454                        pipette_binary: requested
455                            .then(|| ResolvedArtifact::from_local_path("unused-pipette".into())),
456                        nested_test: None,
457                    },
458                    &driver,
459                )?
460                .with_serial_output();
461                assert_eq!(builder.using_vtl0_pipette(), requested);
462                assert!(!builder.properties().uses_pipette_as_init);
463                assert!(builder.pipette_binary.is_none());
464                let backend = configure(builder).await?;
465                assert!(backend.resources.linux_direct_serial_agent.is_none());
466                assert!(matches!(
467                    backend.config.load_mode,
468                    openvmm_defs::config::LoadMode::Igvm { .. }
469                ));
470            }
471            Ok(())
472        })
473    }
474
475    #[test]
476    fn platform_composes_without_replacing_uefi_or_other_roots() -> anyhow::Result<()> {
477        if MachineArch::host() != MachineArch::X86_64 || !cfg!(target_os = "linux") {
478            return Ok(());
479        }
480        let temp = tempfile::tempdir()?;
481        let firmware = tempfile::NamedTempFile::new()?;
482        let logs = PetriLogSource::file_only(temp.path())?;
483        pal_async::DefaultPool::run_with(async |driver| {
484            let builder = builder_with_guest(
485                &driver,
486                &logs,
487                firmware.path(),
488                mshv_guest(temp.path())?,
489                MachineArch::X86_64,
490            )?
491            .modify_backend(|b| b.with_pcie_root_topology(1, 1, 1))
492            .with_mshv_platform()
493            .with_shared_directory("data", temp.path(), "/mounted elsewhere")
494            .modify_backend(|b| {
495                b.with_pcie_root_topology(1, 1, 2).with_custom_config(|c| {
496                    let root = c.pcie_root_complexes.last_mut().unwrap();
497                    for (port, name) in root
498                        .ports
499                        .iter_mut()
500                        .zip(["petri-console-0", "petri-vsock-0"])
501                    {
502                        port.name = name.into();
503                        c.pcie_devices.push(openvmm_defs::config::PcieDeviceConfig {
504                            port_name: name.into(),
505                            resource: virtio_resources::VirtioPciDeviceHandle(
506                                virtio_resources::rng::VirtioRngHandle.into_resource(),
507                            )
508                            .into_resource(),
509                        });
510                    }
511                })
512            });
513            assert!(matches!(
514                builder.config.firmware,
515                Firmware::Uefi {
516                    guest: UefiGuest::Mshv(_),
517                    uefi_config: UefiConfig {
518                        disable_frontpage: false,
519                        ..
520                    },
521                    ..
522                }
523            ));
524            assert!(!builder.properties().is_linux_direct);
525            assert!(builder.properties().no_vmbus);
526            assert!(builder.properties().use_virtio_vsock);
527            assert!(builder.properties().use_virtio_console);
528            let backend = configure(builder).await?;
529            assert_eq!(
530                backend
531                    .config
532                    .pcie_root_complexes
533                    .iter()
534                    .map(|rc| rc.segment)
535                    .collect::<Vec<_>>(),
536                [0, 1, 2]
537            );
538            assert_eq!(
539                backend.config.pcie_root_complexes[0].ports[0].name,
540                "s0rc0rp0"
541            );
542            assert_eq!(backend.config.pcie_root_complexes[1].name, "s1rc0");
543            assert_eq!(backend.config.pcie_root_complexes[1].ports.len(), 5);
544            assert!(backend.resources.tcp_pipette_port.is_none());
545            for port in [
546                "s1rc0rp0",
547                "s1rc0rp1",
548                "petri-console-1",
549                "petri-vsock-1",
550                "petri-share-0",
551            ] {
552                assert!(
553                    backend
554                        .config
555                        .pcie_devices
556                        .iter()
557                        .any(|d| d.port_name == port)
558                );
559                assert!(
560                    backend.config.pcie_root_complexes[1]
561                        .ports
562                        .iter()
563                        .any(|p| p.name == port)
564                );
565            }
566            assert!(backend.guest_shares.is_empty());
567            Ok(())
568        })
569    }
570
571    #[test]
572    #[should_panic(expected = "PCI segment range overlaps an existing root complex")]
573    fn segment_conflicts_stop_later_modifiers() {
574        let temp = tempfile::tempdir().unwrap();
575        let firmware = tempfile::NamedTempFile::new().unwrap();
576        let logs = PetriLogSource::file_only(temp.path()).unwrap();
577        pal_async::DefaultPool::run_with(async |driver| {
578            let builder = builder_with_guest(
579                &driver,
580                &logs,
581                firmware.path(),
582                UefiGuest::None,
583                MachineArch::host(),
584            )?
585            .modify_backend(|b| b.with_pcie_root_topology_at(1, 1, 1, 1))
586            .modify_backend(|b| b.with_pcie_root_topology_at(1, 1, 1, 1))
587            .modify_backend(|_| panic!("segment conflict should stop later modifiers"));
588            configure(builder).await?;
589            Ok::<_, anyhow::Error>(())
590        })
591        .unwrap();
592    }
593}