Skip to main content

flowey_hvlite/pipelines/
vmm_tests_run.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Pipeline to discover artifacts and run VMM tests in a single command.
5//!
6//! This pipeline:
7//! 1. Discovers required artifacts for the specified test filter (at pipeline
8//!    construction time)
9//! 2. Builds the necessary dependencies
10//! 3. Runs the tests
11
12use anyhow::Context as _;
13use flowey::node::prelude::ReadVar;
14use flowey::pipeline::prelude::*;
15use flowey_lib_hvlite::_jobs::local_build_and_run_nextest_vmm_tests::BuildSelections;
16use flowey_lib_hvlite::_jobs::local_build_and_run_nextest_vmm_tests::VmmTestSelections;
17use flowey_lib_hvlite::build_incubator::IncubatorProfileNameOrPath;
18use flowey_lib_hvlite::common::CommonPlatform;
19use flowey_lib_hvlite::common::CommonTriple;
20use flowey_lib_hvlite::init_vmm_tests_env::PetriParams;
21use flowey_lib_hvlite::install_vmm_tests_external_deps::VmmTestsExternalDeps;
22use flowey_lib_hvlite::install_vmm_tests_external_deps::VmmTestsExternalDepsLinux;
23use flowey_lib_hvlite::install_vmm_tests_external_deps::VmmTestsExternalDepsWindows;
24use petri_artifacts_core::ArtifactId;
25use petri_artifacts_core::ArtifactListOutput;
26use std::collections::BTreeMap;
27use std::collections::BTreeSet;
28use std::io::Write as _;
29use std::num::NonZeroU64;
30use std::path::Path;
31use std::path::PathBuf;
32use std::process::Command;
33use std::process::Stdio;
34use vmm_test_images::KnownTestArtifacts;
35
36/// Build and run VMM tests with automatic artifact discovery
37#[derive(clap::Args)]
38pub struct VmmTestsRunCli {
39    /// Specify what target to build the VMM tests for
40    ///
41    /// If not specified, defaults to the current host target.
42    #[clap(long)]
43    target: Option<VmmTestTargetCli>,
44
45    /// Directory for the output artifacts.
46    ///
47    /// If not specified, defaults to `target/vmm_tests`.
48    /// WSL-to-Windows runs must override this to a Windows-accessible output
49    /// directory (a DrvFs mount like `/mnt/c/...`) only when the selected tests
50    /// use disk images that require a Windows filesystem (Hyper-V disks, or
51    /// VHDX / dynamic VHD1 images); otherwise the default works.
52    #[clap(long)]
53    dir: Option<PathBuf>,
54
55    /// Test filter (nextest filter expression)
56    ///
57    /// Examples:
58    ///   - `test(alpine)` - run tests with "alpine" in the name
59    ///   - `test(/^boot_/)` - run tests starting with "boot_"
60    ///   - `all()` - run all tests
61    #[clap(long, default_value = "all()")]
62    filter: String,
63
64    /// pass `--verbose` to cargo
65    #[clap(long)]
66    verbose: bool,
67    /// Automatically install any missing required dependencies.
68    #[clap(long)]
69    install_missing_deps: bool,
70
71    /// Release build instead of debug build
72    #[clap(long)]
73    release: bool,
74
75    /// Build only, do not run
76    #[clap(long)]
77    build_only: bool,
78    /// Copy extras to output dir (symbols, etc)
79    #[clap(long)]
80    copy_extras: bool,
81
82    /// Skip the interactive VHD download prompt
83    #[clap(long)]
84    skip_vhd_prompt: bool,
85
86    /// Download all disk images upfront instead of streaming on demand.
87    ///
88    /// By default, VHD/ISO disk images are streamed on demand via HTTP
89    /// and cached locally, avoiding large upfront downloads. Use this
90    /// flag to force all images to be downloaded before tests run.
91    #[clap(long)]
92    no_lazy_fetch: bool,
93
94    /// Optional: custom kernel modules
95    #[clap(long)]
96    custom_kernel_modules: Option<PathBuf>,
97    /// Optional: custom kernel image
98    #[clap(long)]
99    custom_kernel: Option<PathBuf>,
100    /// Optional: custom UEFI firmware (MSVM.fd) to use instead of the
101    /// downloaded release. Path to a locally-built MSVM.fd file.
102    #[clap(long)]
103    custom_uefi_firmware: Option<PathBuf>,
104
105    /// use the nextest CI profile rather than the default one
106    #[clap(long)]
107    ci_profile: bool,
108
109    /// Don't reuse prepped vhds, even if they already exist.
110    /// Use when making changes to prep_steps
111    #[clap(long)]
112    no_reuse_prepped_vhds: bool,
113
114    /// Disable secure AVIC support for SNP. This adds the
115    /// `disable_secure_avic` cargo feature and sets `secure_avic` to
116    /// `disabled` in the IGVM manifest.
117    #[clap(long)]
118    pub disable_secure_avic: bool,
119
120    /// How many times to run the tests
121    #[clap(long)]
122    repetitions: Option<u64>,
123
124    /// Run tests inside an emulated incubator.
125    ///
126    /// Pass `--incubator` on its own to use the default profile for the
127    /// selected `--target`, or `--incubator <PATH>` to point at a specific
128    /// profile TOML describing the emulated platform (e.g., AArch64 with
129    /// SMMUv3).
130    ///
131    /// When set, `--target` is required and must match the profile's
132    /// architecture; artifacts are cross-compiled for that target and tests
133    /// run inside the incubator.
134    ///
135    /// Example: `--incubator --target linux-aarch64-musl`
136    #[clap(long, num_args = 0..=1)]
137    #[expect(clippy::option_option)]
138    incubator: Option<Option<PathBuf>>,
139}
140
141struct CargoNextestListRequest<'a> {
142    repo_root: &'a Path,
143    target: &'a str,
144    filter: &'a str,
145    release: bool,
146    include_ignored: bool,
147}
148
149struct RustSuite {
150    binary_path: PathBuf,
151    testcases: Vec<String>,
152}
153
154/// Result of resolving artifact requirements to build/download selections
155#[derive(Default, Debug)]
156struct ResolvedArtifactSelections {
157    /// What to build
158    build: BuildSelections,
159    /// What to download
160    downloads: BTreeSet<KnownTestArtifacts>,
161    /// Downloads that must happen even when lazy fetch is enabled (e.g.
162    /// VHDs needed by prep_steps, which copies them to create prepped images).
163    force_downloads: BTreeSet<KnownTestArtifacts>,
164    /// Whether any tests need release IGVM files from GitHub
165    needs_release_igvm: bool,
166    /// Whether any of the tests require Hyper-V
167    needs_hyperv: bool,
168    /// Whether any of the tests require hardware isolation
169    needs_hardware_isolation: bool,
170}
171
172impl IntoPipeline for VmmTestsRunCli {
173    fn into_pipeline(self, backend_hint: PipelineBackendHint) -> anyhow::Result<Pipeline> {
174        if !matches!(backend_hint, PipelineBackendHint::Local) {
175            anyhow::bail!("vmm-tests-run is for local use only")
176        }
177
178        let Self {
179            target,
180            dir,
181            filter,
182            verbose,
183            install_missing_deps,
184            release,
185            build_only,
186            copy_extras,
187            skip_vhd_prompt,
188            no_lazy_fetch,
189            custom_kernel_modules,
190            custom_kernel,
191            custom_uefi_firmware,
192            ci_profile,
193            no_reuse_prepped_vhds,
194            disable_secure_avic,
195            repetitions,
196            incubator,
197        } = self;
198
199        // When --incubator is set, --target must also be specified
200        // to indicate the cross-compilation target for the incubator.
201        if incubator.is_some() && target.is_none() {
202            anyhow::bail!("--incubator requires --target (e.g., --target linux-aarch64-musl)");
203        }
204
205        let repetitions =
206            NonZeroU64::new(repetitions.unwrap_or(1)).context("repetitions must not be zero")?;
207
208        let target = resolve_target(target, backend_hint)?;
209        let target_os = target.as_triple().operating_system;
210        let target_architecture = target.common_arch()?;
211        let target_str = target.as_triple().to_string();
212
213        // Windows *guest* payloads (e.g. pipette) must be PE binaries even when
214        // the VMM host target is Linux. On a non-WSL Linux build host the MSVC
215        // toolchain / Windows SDK is unavailable, so cross-compile those guest
216        // binaries with the GNU (mingw-w64) toolchain instead.
217        let windows_guest_platform =
218            if matches!(FlowPlatform::host(backend_hint), FlowPlatform::Linux(_))
219                && !flowey_cli::running_in_wsl()
220            {
221                CommonPlatform::WindowsGnu
222            } else {
223                CommonPlatform::WindowsMsvc
224            };
225
226        let repo_root = crate::repo_root();
227
228        let incubator_profile = incubator
229            .map(|i| resolve_incubator(i, &target))
230            .transpose()?;
231
232        // Artifact discovery only needs to execute the test binary far enough
233        // to dump its static artifact metadata (`--list-required-artifacts`),
234        // which never boots a VM. So we run it directly rather than through the
235        // incubator. The binary is built for the test target, so on a foreign
236        // host this relies on the binary being executable (natively, or via
237        // binfmt/user-mode emulation).
238        //
239        // Running outside the real guest means the per-test host capability
240        // checks (the source of nextest's `#[ignore]` flag) would wrongly drop
241        // incubator tests, so for the incubator path we enumerate ignored tests
242        // too — their artifacts still need to be built.
243        let include_ignored = build_only || incubator_profile.is_some();
244
245        // Run artifact discovery inline at pipeline construction time since
246        // flowey doesn't support conditional requests yet
247        log::info!(
248            "Discovering artifacts for filter: {} (target: {})",
249            filter,
250            target
251        );
252
253        // Determine which tests match the filter
254        let suites = run_cargo_nextest_list(CargoNextestListRequest {
255            repo_root: &repo_root,
256            target: &target_str,
257            filter: &filter,
258            release,
259            // When using build-only mode, we need to enumerate tests that could be
260            // run on any system so that we build all necessary dependencies. By default
261            // petri marks incompatible tests as ignored.
262            //
263            include_ignored,
264        })?;
265
266        if suites.is_empty() {
267            anyhow::bail!("No tests found for the given filter");
268        }
269
270        // Query for the required artifacts
271        let mut artifacts = Vec::new();
272        for suite in suites.values() {
273            artifacts.append(&mut query_test_binary_artifacts(suite)?);
274        }
275
276        // Resolve to build selections
277        let mut resolved = ResolvedArtifactSelections::default();
278        for artifact in artifacts {
279            resolved.resolve_artifact(&artifact)?;
280        }
281
282        // Determine whether we need hyper-v and/or hardware isolation
283        resolved.needs_hyperv = suites
284            .values()
285            .any(|s| s.testcases.iter().any(|name| name.contains("hyperv")));
286        resolved.needs_hardware_isolation = suites.values().any(|s| {
287            s.testcases
288                .iter()
289                .any(|name| name.contains("snp") || name.contains("tdx"))
290        });
291
292        // Determine lazy fetch mode.
293        //
294        // By default, VHD/ISO downloads are skipped and disk images are
295        // streamed on demand via HTTP (with local SQLite caching). This
296        // avoids multi-GB upfront downloads for dev-inner-loop scenarios.
297        //
298        // Lazy fetch is disabled for all downloads when the user passes
299        // --no-lazy-fetch and for any downloads that are used by a selected
300        // Hyper-V test.
301        //
302        // When both Hyper-V and non-Hyper-V tests are selected, only the
303        // artifacts required by Hyper-V tests are downloaded upfront; the
304        // rest are lazy-fetched.
305        if no_lazy_fetch {
306            log::info!("Lazy fetch disabled");
307        } else {
308            let mut hyperv_tests: usize = 0;
309            let mut hyperv_artifacts = Vec::new();
310            for suite in suites.values() {
311                let hyperv_testcases: Vec<_> = suite
312                    .testcases
313                    .iter()
314                    .filter(|name| name.contains("hyperv"))
315                    .cloned()
316                    .collect();
317
318                if !hyperv_testcases.is_empty() {
319                    hyperv_tests += hyperv_testcases.len();
320                    hyperv_artifacts.append(&mut query_test_binary_artifacts(&RustSuite {
321                        binary_path: suite.binary_path.clone(),
322                        testcases: hyperv_testcases,
323                    })?);
324                }
325            }
326
327            resolved.downloads.retain(|a| !a.supports_blob_disk());
328
329            // Re-add force_downloads (prep_steps dependencies) that were removed.
330            resolved
331                .downloads
332                .extend(resolved.force_downloads.iter().cloned());
333
334            if hyperv_tests == 0 {
335                log::info!("Lazy fetch enabled: disk images will be streamed on demand via HTTP");
336            } else {
337                log::info!(
338                    "Downloading disk images required by {} Hyper-V tests",
339                    hyperv_tests
340                );
341            }
342
343            // Re-add only the downloads needed for hyper-v. Other selections should
344            // remain the same since resolve_artifact can only add selections
345            for artifact in hyperv_artifacts {
346                resolved.resolve_artifact(&artifact)?;
347            }
348        }
349
350        log::info!("Resolved selections: {:?}", resolved);
351
352        // Validate the output directory now that we know which disk images the
353        // selected tests need. When targeting Windows from WSL, the only hard
354        // filesystem constraint is that certain disk images must live on a
355        // Windows filesystem rather than a `\\wsl$` 9p path:
356        //
357        // - Hyper-V tests attach their VHDs to a real Hyper-V VM, whose worker
358        //   process can't open disks over 9p, so any Hyper-V disk needs a
359        //   Windows path regardless of format.
360        // - OpenVMM opens fixed VHD1, VMGS, and raw/ISO images as plain files
361        //   (fine over 9p), but routes VHDX (and dynamic/differencing VHD1)
362        //   disks through the Windows virtual-disk mount API, which requires a
363        //   real local volume. The only such artifact today is the `.vhdx`.
364        //
365        // All other cases (streamed disks, or fixed-VHD1 files even with
366        // `--no-lazy-fetch`) work fine from the default WSL-side directory.
367        let needs_windows_disk = !build_only
368            && (resolved.needs_hyperv
369                || resolved
370                    .downloads
371                    .iter()
372                    .any(|a| a.filename().ends_with(".vhdx")));
373        validate_output_dir(dir.as_deref(), target_os, needs_windows_disk)?;
374        let test_content_dir = dir.unwrap_or_else(|| repo_root.join("target").join("vmm_tests"));
375        std::fs::create_dir_all(&test_content_dir).context("failed to create output directory")?;
376
377        let openvmm_repo = flowey_lib_common::git_checkout::RepoSource::ExistingClone(
378            ReadVar::from_static(repo_root),
379        );
380
381        let mut pipeline = Pipeline::new();
382
383        let mut job = pipeline.new_job(
384            FlowPlatform::host(backend_hint),
385            FlowArch::host(backend_hint),
386            "build all dependencies and run vmm tests",
387        );
388
389        job = job.dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init);
390
391        // Override kernel with local paths if both kernel and modules are specified
392        if let (Some(kernel_path), Some(modules_path)) =
393            (custom_kernel.clone(), custom_kernel_modules.clone())
394        {
395            job =
396                job.dep_on(
397                    move |_| flowey_lib_hvlite::_jobs::cfg_versions::Request::LocalKernel {
398                        arch: target_architecture,
399                        kernel: ReadVar::from_static(kernel_path),
400                        modules: ReadVar::from_static(modules_path),
401                    },
402                );
403        }
404
405        // Override UEFI firmware with a local MSVM.fd path
406        if let Some(fw_path) = custom_uefi_firmware {
407            job = job.dep_on(move |_| {
408                flowey_lib_hvlite::_jobs::cfg_versions::Request::LocalUefi(
409                    target_architecture,
410                    ReadVar::from_static(fw_path),
411                )
412            });
413        }
414
415        job = job
416            .dep_on(
417                |_| flowey_lib_hvlite::_jobs::cfg_hvlite_reposource::Params {
418                    hvlite_repo_source: openvmm_repo.clone(),
419                },
420            )
421            .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_common::Params {
422                local_only: Some(flowey_lib_hvlite::_jobs::cfg_common::LocalOnlyParams {
423                    interactive: true,
424                    auto_install: install_missing_deps,
425                    ignore_rust_version: true,
426                }),
427                verbose: ReadVar::from_static(verbose),
428                locked: false,
429                deny_warnings: false,
430                no_incremental: false,
431            })
432            .dep_on(|ctx| {
433                flowey_lib_hvlite::_jobs::local_build_and_run_nextest_vmm_tests::Params {
434                    target,
435                    windows_guest_platform,
436                    test_content_dir,
437                    selections: selections_from_resolved(filter, resolved, target_os),
438                    release,
439                    build_only,
440                    copy_extras,
441                    custom_kernel_modules,
442                    custom_kernel,
443                    skip_vhd_prompt,
444                    nextest_profile: if ci_profile {
445                        flowey_lib_hvlite::run_cargo_nextest_run::NextestProfile::Ci
446                    } else {
447                        flowey_lib_hvlite::run_cargo_nextest_run::NextestProfile::Default
448                    },
449                    petri_params: PetriParams {
450                        disable_remote_artifacts: false,
451                        reuse_prepped_vhds: !no_reuse_prepped_vhds,
452                        require_2mb_hugetlb: false, // TODO
453                    },
454                    disable_secure_avic,
455                    repetitions,
456                    incubator_profile,
457                    done: ctx.new_done_handle(),
458                }
459            });
460
461        job.finish();
462
463        Ok(pipeline)
464    }
465}
466
467/// Get test binaries and associated matching tests for a given nextest filter.
468// TODO: this function should really be a flowey node without automatic
469// dependency installation, but that would require conditional requests.
470fn run_cargo_nextest_list<'a>(
471    req: CargoNextestListRequest<'a>,
472) -> anyhow::Result<BTreeMap<String, RustSuite>> {
473    let CargoNextestListRequest {
474        repo_root,
475        target,
476        filter,
477        release,
478        include_ignored,
479    } = req;
480
481    // Check that cargo-nextest is available
482    let nextest_check = Command::new("cargo")
483        .args(["nextest", "--version"])
484        .stdout(Stdio::null())
485        .stderr(Stdio::null())
486        .status();
487    match nextest_check {
488        Ok(status) if status.success() => {}
489        _ => anyhow::bail!(
490            "cargo-nextest not found. Run 'cargo install --locked cargo-nextest' first."
491        ),
492    }
493
494    // Step 1: Use nextest to resolve the filter expression to test names and
495    // get the binary path
496    let mut cmd = Command::new("cargo");
497    cmd.stderr(Stdio::inherit());
498    cmd.current_dir(repo_root).args([
499        "nextest",
500        "list",
501        "-p",
502        "vmm_tests",
503        "--target",
504        target,
505        "--filter-expr",
506        filter,
507        "--message-format",
508        "json",
509    ]);
510    if release {
511        cmd.arg("--release");
512    }
513    if include_ignored {
514        cmd.args(["--run-ignored", "all"]);
515    }
516    let nextest_output = cmd.output().context("failed to run cargo nextest list")?;
517    anyhow::ensure!(nextest_output.status.success(), "cargo nextest list failed",);
518    let nextest_stdout = String::from_utf8(nextest_output.stdout)
519        .map_err(|e| anyhow::anyhow!("nextest output is not valid UTF-8: {}", e))?;
520
521    parse_nextest_output(&nextest_stdout)
522}
523
524/// Parse `cargo nextest list --message-format json` output to extract test
525/// names and binary path.
526fn parse_nextest_output(stdout: &str) -> anyhow::Result<BTreeMap<String, RustSuite>> {
527    let json: serde_json::Value = serde_json::from_str(stdout)
528        .map_err(|e| anyhow::anyhow!("failed to parse nextest JSON output: {}", e))?;
529
530    let mut suites = BTreeMap::new();
531
532    for (name, suite) in json
533        .get("rust-suites")
534        .and_then(|s| s.as_object())
535        .context("no rust-suites object")?
536    {
537        let binary_path = PathBuf::from(
538            suite
539                .get("binary-path")
540                .and_then(|v| v.as_str())
541                .context("no binary-path str")?,
542        );
543
544        let testcases: Vec<_> = suite
545            .get("testcases")
546            .and_then(|t| t.as_object())
547            .context("no testcases object")?
548            .iter()
549            .filter(|(_, test_info)| {
550                test_info
551                    .get("filter-match")
552                    .and_then(|fm| fm.get("status"))
553                    .and_then(|s| s.as_str())
554                    .is_some_and(|s| s == "matches")
555            })
556            .map(|(test_name, _)| test_name.to_owned())
557            .collect();
558
559        if !testcases.is_empty() {
560            suites.insert(
561                name.to_owned(),
562                RustSuite {
563                    binary_path,
564                    testcases,
565                },
566            );
567        }
568    }
569
570    Ok(suites)
571}
572
573/// Runs the test binary with `--list-required-artifacts --tests-from-stdin`
574/// and returns all the required and optional artifacts for all test defined
575/// in the RustSuite.
576fn query_test_binary_artifacts(suite: &RustSuite) -> anyhow::Result<Vec<String>> {
577    log::info!("Using test binary: {}", suite.binary_path.display());
578    log::info!("Querying artifacts for {} tests", suite.testcases.len());
579
580    let mut command = Command::new(&suite.binary_path);
581    command.arg("--list-required-artifacts");
582    command.arg("--tests-from-stdin").stdin(Stdio::piped());
583
584    let mut child = command
585        .stdout(Stdio::piped())
586        .stderr(Stdio::piped())
587        .spawn()
588        .context("failed to spawn test binary")?;
589
590    let stdin_data = suite
591        .testcases
592        .iter()
593        .map(|n| format!("{n}\n"))
594        .collect::<String>();
595    child
596        .stdin
597        .take()
598        .expect("stdin was piped")
599        .write_all(stdin_data.as_bytes())
600        .context("failed to write test names to stdin")?;
601
602    let artifact_output = child
603        .wait_with_output()
604        .context("failed to wait for test binary")?;
605    anyhow::ensure!(
606        artifact_output.status.success(),
607        "test binary failed: {}",
608        String::from_utf8_lossy(&artifact_output.stderr)
609    );
610    let artifact_stdout = String::from_utf8(artifact_output.stdout)
611        .map_err(|e| anyhow::anyhow!("test output is not valid UTF-8: {}", e))?;
612
613    let ArtifactListOutput {
614        mut required,
615        mut optional,
616    } = serde_json::from_str(&artifact_stdout)
617        .map_err(|e| anyhow::anyhow!("failed to parse test output JSON: {}", e))?;
618
619    let mut artifacts = Vec::new();
620    artifacts.append(&mut required);
621    artifacts.append(&mut optional);
622    Ok(artifacts)
623}
624
625#[derive(clap::ValueEnum, Copy, Clone)]
626pub(crate) enum VmmTestTargetCli {
627    /// Windows Aarch64
628    WindowsAarch64,
629    /// Windows X64
630    WindowsX64,
631    /// Linux X64
632    LinuxX64,
633    /// Linux Aarch64 (musl, for incubator cross-compilation)
634    LinuxAarch64Musl,
635}
636
637/// Resolve a CLI target option to a CommonTriple, defaulting to the host.
638pub(crate) fn resolve_target(
639    target: Option<VmmTestTargetCli>,
640    backend_hint: PipelineBackendHint,
641) -> anyhow::Result<CommonTriple> {
642    let target = if let Some(t) = target {
643        t
644    } else {
645        match (
646            FlowArch::host(backend_hint),
647            FlowPlatform::host(backend_hint),
648        ) {
649            (FlowArch::Aarch64, FlowPlatform::Windows) => VmmTestTargetCli::WindowsAarch64,
650            (FlowArch::X86_64, FlowPlatform::Windows) => VmmTestTargetCli::WindowsX64,
651            (FlowArch::X86_64, FlowPlatform::Linux(_)) => VmmTestTargetCli::LinuxX64,
652            _ => anyhow::bail!("unsupported host"),
653        }
654    };
655
656    Ok(match target {
657        VmmTestTargetCli::WindowsAarch64 => CommonTriple::AARCH64_WINDOWS_MSVC,
658        VmmTestTargetCli::WindowsX64 => CommonTriple::X86_64_WINDOWS_MSVC,
659        VmmTestTargetCli::LinuxX64 => CommonTriple::X86_64_LINUX_GNU,
660        VmmTestTargetCli::LinuxAarch64Musl => CommonTriple::AARCH64_LINUX_MUSL,
661    })
662}
663
664/// Validate the output directory path based on the current platform.
665///
666/// When running under WSL and targeting Windows, some disk images must live on
667/// a Windows-accessible path (a DrvFs mount like `/mnt/c/...`) rather than a
668/// `\\wsl$` 9p path: Hyper-V disks (attached to a real Hyper-V VM) and VHDX /
669/// dynamic VHD1 images (opened via the Windows virtual-disk mount API). This
670/// constraint only applies when the selected tests actually use such a disk
671/// (`needs_windows_disk`); fixed-VHD1, VMGS, ISO, and streamed disks work fine
672/// from the WSL side. On native Windows or Linux this check is a no-op.
673fn validate_output_dir(
674    dir: Option<&Path>,
675    target_os: target_lexicon::OperatingSystem,
676    needs_windows_disk: bool,
677) -> anyhow::Result<()> {
678    if needs_windows_disk
679        && flowey_cli::running_in_wsl()
680        && matches!(target_os, target_lexicon::OperatingSystem::Windows)
681    {
682        if let Some(dir) = dir {
683            if !flowey_cli::is_wsl_windows_path(dir) {
684                anyhow::bail!(
685                    "When targeting Windows from WSL, --dir must be a path on Windows \
686                        (i.e., on a DrvFs mount like /mnt/c/vmm_tests) because the selected \
687                        tests use disk images that require a Windows filesystem (Hyper-V \
688                        disks, or VHDX / dynamic VHD1 images). \
689                        Got: {}",
690                    dir.display()
691                );
692            }
693        } else {
694            anyhow::bail!(
695                "The selected tests use disk images that require a Windows filesystem \
696                    (Hyper-V disks, or VHDX / dynamic VHD1 images) when targeting Windows \
697                    from WSL. Specify an output directory on a DrvFs mount with --dir \
698                    (e.g., --dir /mnt/c/vmm_tests)."
699            )
700        }
701    }
702    Ok(())
703}
704
705/// Resolve `ResolvedArtifactSelections` to `VmmTestSelections`.
706fn selections_from_resolved(
707    filter: String,
708    resolved: ResolvedArtifactSelections,
709    target_os: target_lexicon::OperatingSystem,
710) -> VmmTestSelections {
711    VmmTestSelections {
712        filter,
713        downloaded_artifacts: resolved.downloads.into_iter().collect(),
714        build: resolved.build.clone(),
715        external_deps: match target_os {
716            target_lexicon::OperatingSystem::Windows => {
717                VmmTestsExternalDeps::Windows(VmmTestsExternalDepsWindows {
718                    hyperv: resolved.needs_hyperv,
719                    whp: resolved.build.openvmm,
720                    hardware_isolation: resolved.needs_hardware_isolation,
721                })
722            }
723            target_lexicon::OperatingSystem::Linux => {
724                VmmTestsExternalDeps::Linux(VmmTestsExternalDepsLinux {
725                    hugetlb_2mb_overcommit_pages: None, // TODO
726                    prepare_vhost_vsock: false,         // TODO
727                })
728            }
729            _ => unreachable!(),
730        },
731        needs_release_igvm: resolved.needs_release_igvm,
732    }
733}
734
735impl ResolvedArtifactSelections {
736    /// Resolve a single artifact ID and update selections.
737    fn resolve_artifact(&mut self, id: &str) -> anyhow::Result<()> {
738        match id {
739            // OpenVMM binary
740            petri_artifacts_vmm_test::artifacts::OPENVMM_WIN_X64::GLOBAL_UNIQUE_ID
741            | petri_artifacts_vmm_test::artifacts::OPENVMM_LINUX_X64::GLOBAL_UNIQUE_ID
742            | petri_artifacts_vmm_test::artifacts::OPENVMM_WIN_AARCH64::GLOBAL_UNIQUE_ID
743            | petri_artifacts_vmm_test::artifacts::OPENVMM_LINUX_AARCH64::GLOBAL_UNIQUE_ID
744            | petri_artifacts_vmm_test::artifacts::OPENVMM_MACOS_AARCH64::GLOBAL_UNIQUE_ID => {
745                self.build.openvmm = true;
746            }
747
748            // OpenVMM vhost binary (Linux only)
749            petri_artifacts_vmm_test::artifacts::OPENVMM_VHOST_LINUX_X64::GLOBAL_UNIQUE_ID
750            | petri_artifacts_vmm_test::artifacts::OPENVMM_VHOST_LINUX_AARCH64 ::GLOBAL_UNIQUE_ID => {
751                self.build.openvmm_vhost = true;
752            }
753
754            // OpenHCL IGVM files
755            petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_STANDARD_X64::GLOBAL_UNIQUE_ID
756            | petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_STANDARD_AARCH64::GLOBAL_UNIQUE_ID =>
757            {
758                self.build.openhcl_standard = true;
759            }
760            petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_STANDARD_DEV_KERNEL_X64::GLOBAL_UNIQUE_ID
761            | petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_STANDARD_DEV_KERNEL_AARCH64::GLOBAL_UNIQUE_ID => {
762                self.build.openhcl_standard_dev = true;
763            }
764            petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_CVM_X64::GLOBAL_UNIQUE_ID
765             =>
766            {
767                self.build.openhcl_cvm = true;
768            }
769            petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_LINUX_DIRECT_TEST_X64::GLOBAL_UNIQUE_ID =>
770            {
771                self.build.openhcl_linux_direct = true;
772            }
773
774            // Release IGVM files (downloaded, not built)
775            petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_RELEASE_STANDARD_X64::GLOBAL_UNIQUE_ID
776            | petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_RELEASE_LINUX_DIRECT_X64::GLOBAL_UNIQUE_ID
777            | petri_artifacts_vmm_test::artifacts::openhcl_igvm::LATEST_RELEASE_STANDARD_AARCH64::GLOBAL_UNIQUE_ID =>
778            {
779                // These are downloaded from GitHub releases, not built
780                self.needs_release_igvm = true;
781            }
782
783            // Guest test UEFI
784            petri_artifacts_vmm_test::artifacts::test_vhd::GUEST_TEST_UEFI_X64::GLOBAL_UNIQUE_ID
785            | petri_artifacts_vmm_test::artifacts::test_vhd::GUEST_TEST_UEFI_AARCH64 ::GLOBAL_UNIQUE_ID => {
786                self.build.guest_test_uefi = true;
787            }
788
789            // TMKs
790            petri_artifacts_vmm_test::artifacts::tmks::SIMPLE_TMK_X64::GLOBAL_UNIQUE_ID
791            | petri_artifacts_vmm_test::artifacts::tmks::SIMPLE_TMK_AARCH64 ::GLOBAL_UNIQUE_ID => {
792                self.build.tmks = true;
793            }
794
795            // TMK VMM
796            petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_WIN_X64::GLOBAL_UNIQUE_ID
797            | petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_WIN_AARCH64::GLOBAL_UNIQUE_ID => {
798                self.build.tmk_vmm_windows = true;
799            }
800            petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_LINUX_X64::GLOBAL_UNIQUE_ID
801            | petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_LINUX_AARCH64::GLOBAL_UNIQUE_ID
802            | petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_LINUX_X64_MUSL::GLOBAL_UNIQUE_ID
803            | petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_LINUX_AARCH64_MUSL::GLOBAL_UNIQUE_ID
804            | petri_artifacts_vmm_test::artifacts::tmks::TMK_VMM_MACOS_AARCH64::GLOBAL_UNIQUE_ID => {
805                self.build.tmk_vmm_linux = true;
806            }
807
808            // VmgsTool
809            petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_WIN_X64::GLOBAL_UNIQUE_ID
810            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_WIN_AARCH64::GLOBAL_UNIQUE_ID
811            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_LINUX_X64::GLOBAL_UNIQUE_ID
812            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_LINUX_AARCH64::GLOBAL_UNIQUE_ID
813            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_MACOS_AARCH64::GLOBAL_UNIQUE_ID => {
814                self.build.vmgstool = true;
815            }
816
817            // VmgsTool-Dev
818            petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_DEV_WIN_X64::GLOBAL_UNIQUE_ID
819            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_DEV_WIN_AARCH64::GLOBAL_UNIQUE_ID
820            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_DEV_LINUX_X64::GLOBAL_UNIQUE_ID
821            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_DEV_LINUX_AARCH64::GLOBAL_UNIQUE_ID
822            | petri_artifacts_vmm_test::artifacts::vmgstool::VMGSTOOL_DEV_MACOS_AARCH64::GLOBAL_UNIQUE_ID => {
823                self.build.vmgstool_dev = true;
824            }
825
826            // TPM guest tests
827            petri_artifacts_vmm_test::artifacts::guest_tools::TPM_GUEST_TESTS_WINDOWS_X64::GLOBAL_UNIQUE_ID => {
828                self.build.tpm_guest_tests_windows = true;
829            }
830            petri_artifacts_vmm_test::artifacts::guest_tools::TPM_GUEST_TESTS_LINUX_X64::GLOBAL_UNIQUE_ID => {
831                self.build.tpm_guest_tests_linux = true;
832            }
833
834            // Host tools
835            petri_artifacts_vmm_test::artifacts::host_tools::TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64::GLOBAL_UNIQUE_ID =>
836            {
837                self.build.test_igvm_agent_rpc_server = true;
838            }
839
840            // Loadable firmware artifacts (these come from deps, not built)
841            petri_artifacts_vmm_test::artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_X64::GLOBAL_UNIQUE_ID
842            | petri_artifacts_vmm_test::artifacts::loadable::LINUX_DIRECT_TEST_INITRD_X64::GLOBAL_UNIQUE_ID
843            | petri_artifacts_vmm_test::artifacts::loadable::LINUX_DIRECT_TEST_BZIMAGE_X64::GLOBAL_UNIQUE_ID
844            | petri_artifacts_vmm_test::artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_AARCH64::GLOBAL_UNIQUE_ID
845            | petri_artifacts_vmm_test::artifacts::loadable::LINUX_DIRECT_TEST_INITRD_AARCH64::GLOBAL_UNIQUE_ID
846            | petri_artifacts_vmm_test::artifacts::loadable::PCAT_FIRMWARE_X64::GLOBAL_UNIQUE_ID
847            | petri_artifacts_vmm_test::artifacts::loadable::SVGA_FIRMWARE_X64::GLOBAL_UNIQUE_ID
848            | petri_artifacts_vmm_test::artifacts::loadable::UEFI_FIRMWARE_X64::GLOBAL_UNIQUE_ID
849            | petri_artifacts_vmm_test::artifacts::loadable::UEFI_FIRMWARE_AARCH64::GLOBAL_UNIQUE_ID => {
850                // These are resolved from OpenVMM deps, always available
851            }
852
853            // Test VHDs
854            petri_artifacts_vmm_test::artifacts::test_vhd::GEN1_WINDOWS_DATA_CENTER_CORE2022_X64::GLOBAL_UNIQUE_ID =>
855            {
856                self.downloads
857                    .insert(KnownTestArtifacts::Gen1WindowsDataCenterCore2022X64Vhd);
858            }
859            petri_artifacts_vmm_test::artifacts::test_vhd::GEN2_WINDOWS_DATA_CENTER_CORE2022_X64::GLOBAL_UNIQUE_ID =>
860            {
861                self.downloads
862                    .insert(KnownTestArtifacts::Gen2WindowsDataCenterCore2022X64Vhd);
863            }
864            petri_artifacts_vmm_test::artifacts::test_vhd::GEN2_WINDOWS_DATA_CENTER_CORE2025_X64::GLOBAL_UNIQUE_ID =>
865            {
866                self.downloads
867                    .insert(KnownTestArtifacts::Gen2WindowsDataCenterCore2025X64Vhd);
868            }
869            petri_artifacts_vmm_test::artifacts::test_vhd::GEN2_WINDOWS_DATA_CENTER_CORE2025_X64_PREPPED::GLOBAL_UNIQUE_ID =>
870            {
871                self.build.openvmm = true;
872                self.build.prep_steps_standard = true;
873                // prep_steps needs actual VHD files on disk to copy them.
874                // Force download even when lazy fetch is enabled.
875                self.force_downloads
876                    .insert(KnownTestArtifacts::Gen2WindowsDataCenterCore2022X64Vhd);
877                self.force_downloads
878                    .insert(KnownTestArtifacts::Gen2WindowsDataCenterCore2025X64Vhd);
879            }
880            petri_artifacts_vmm_test::artifacts::test_vhd::GEN2_WINDOWS_DATA_CENTER_CORE2022_X64_NO_VMBUS_PREPPED::GLOBAL_UNIQUE_ID =>
881            {
882                self.build.openvmm = true;
883                self.build.prep_steps_no_vmbus = true;
884                self.force_downloads
885                    .insert(KnownTestArtifacts::Gen2WindowsDataCenterCore2022X64Vhd);
886            }
887            petri_artifacts_vmm_test::artifacts::test_vhd::FREE_BSD_13_2_X64::GLOBAL_UNIQUE_ID => {
888                self.downloads.insert(KnownTestArtifacts::FreeBsd13_2X64Vhd);
889            }
890            petri_artifacts_vmm_test::artifacts::test_vhd::ALPINE_3_23_X64::GLOBAL_UNIQUE_ID => {
891                self.downloads.insert(KnownTestArtifacts::Alpine323X64Vhd);
892            }
893            petri_artifacts_vmm_test::artifacts::test_vhd::ALPINE_3_23_AARCH64::GLOBAL_UNIQUE_ID => {
894                self.downloads
895                    .insert(KnownTestArtifacts::Alpine323Aarch64Vhd);
896            }
897            petri_artifacts_vmm_test::artifacts::test_vhd::UBUNTU_2404_SERVER_X64::GLOBAL_UNIQUE_ID => {
898                self.downloads
899                    .insert(KnownTestArtifacts::Ubuntu2404ServerX64Vhd);
900            }
901            petri_artifacts_vmm_test::artifacts::test_vhd::UBUNTU_2504_SERVER_X64::GLOBAL_UNIQUE_ID => {
902                self.downloads
903                    .insert(KnownTestArtifacts::Ubuntu2504ServerX64Vhd);
904            }
905            petri_artifacts_vmm_test::artifacts::test_vhd::UBUNTU_2404_SERVER_AARCH64::GLOBAL_UNIQUE_ID => {
906                self.downloads
907                    .insert(KnownTestArtifacts::Ubuntu2404ServerAarch64Vhd);
908            }
909            petri_artifacts_vmm_test::artifacts::test_vhd::WINDOWS_11_ENTERPRISE_AARCH64::GLOBAL_UNIQUE_ID => {
910                self.downloads
911                    .insert(KnownTestArtifacts::Windows11EnterpriseAarch64Vhdx);
912            }
913
914            // Test ISOs (downloaded)
915            petri_artifacts_vmm_test::artifacts::test_iso::FREE_BSD_13_2_X64::GLOBAL_UNIQUE_ID => {
916                self.downloads.insert(KnownTestArtifacts::FreeBsd13_2X64Iso);
917            }
918
919            // Test VMGS files
920            petri_artifacts_vmm_test::artifacts::test_vmgs::VMGS_WITH_BOOT_ENTRY::GLOBAL_UNIQUE_ID => {
921                self.downloads.insert(KnownTestArtifacts::VmgsWithBootEntry);
922            }
923            petri_artifacts_vmm_test::artifacts::test_vmgs::VMGS_WITH_16K_TPM::GLOBAL_UNIQUE_ID => {
924                self.downloads.insert(KnownTestArtifacts::VmgsWith16kTpm);
925            }
926
927            // OpenHCL usermode binaries (built as part of IGVM)
928            petri_artifacts_vmm_test::artifacts::openhcl_igvm::um_bin::LATEST_LINUX_DIRECT_TEST_X64::GLOBAL_UNIQUE_ID
929            | petri_artifacts_vmm_test::artifacts::openhcl_igvm::um_dbg::LATEST_LINUX_DIRECT_TEST_X64::GLOBAL_UNIQUE_ID =>
930            {
931                self.build.openhcl_linux_direct = true;
932            }
933
934            // Common artifacts (always available, no build needed)
935            petri_artifacts_common::artifacts::TEST_LOG_DIRECTORY::GLOBAL_UNIQUE_ID => {}
936
937            // Virtio-win drivers (downloaded from openvmm-deps, always available)
938            petri_artifacts_vmm_test::artifacts::virtio_win::VIRTIO_WIN_DRIVERS::GLOBAL_UNIQUE_ID => {}
939
940            // Pipette binaries (from petri_artifacts_common)
941            petri_artifacts_common::artifacts::PIPETTE_LINUX_X64::GLOBAL_UNIQUE_ID
942            | petri_artifacts_common::artifacts::PIPETTE_LINUX_AARCH64::GLOBAL_UNIQUE_ID => {
943                self.build.pipette_linux = true;
944            }
945            petri_artifacts_common::artifacts::PIPETTE_WINDOWS_X64::GLOBAL_UNIQUE_ID
946            | petri_artifacts_common::artifacts::PIPETTE_WINDOWS_AARCH64::GLOBAL_UNIQUE_ID => {
947                self.build.pipette_windows = true;
948            }
949
950            _ => anyhow::bail!("unknown artifact: {id}"),
951        };
952        Ok(())
953    }
954}
955
956/// Resolve the incubator profile path. `--incubator` with no value uses
957/// the default profile for the target; `--incubator <PATH>` overrides.
958pub(crate) fn resolve_incubator(
959    incubator: Option<PathBuf>,
960    target: &CommonTriple,
961) -> anyhow::Result<IncubatorProfileNameOrPath> {
962    Ok(match incubator {
963        // If no separators or extension, assume it is a profile name
964        Some(path) if path.components().count() == 1 && path.extension().is_none() => {
965            IncubatorProfileNameOrPath::Name(path.to_string_lossy().to_string())
966        }
967        Some(path) => IncubatorProfileNameOrPath::Path(path),
968        None => IncubatorProfileNameOrPath::Name(
969            flowey_lib_hvlite::build_incubator::default_incubator_profile(target)
970                .ok_or_else(|| {
971                    anyhow::anyhow!(
972                        "no default incubator profile for target {}; \
973                         pass an explicit path with --incubator <PATH>",
974                        target.as_triple().to_string()
975                    )
976                })?
977                .into(),
978        ),
979    })
980}