Skip to content

Commit 49d2e1b

Browse files
committed
phd: be pedantic about what test skips are OK
This gives us some additional confidence that all tests that run either: * pass * skip, but were expected to given the test hardware/guest configuration Importantly, not all PHD tests pass on all hardware/guest OS combinations, and not all PHD tests even *run* on all hardware/guest combinations. As a bit of a fluke, *Intel* happens to run one additional test that is skipped on AMD processors, even. The strategy is to run "skipped" tests and verify they skip because if guest artifacts are changed unexpectedly we may need to better understand how the artifact differs from PHD expectations. Or if Propolis/PHD implementation changes such that the skip is no longer taken, failing the test instead of silently passing will force us to notice if that change is wanted, if the test *should* pass, etc. As an example, the test Alpine image is currently expected to be an `alpine-virt` on an ISO 9660 (read-only) filesystem. Since this means the EFI system partition is also read-only, `guest_can_adjust_boot_order` is not a meaningful test (for now). We run this test anyway and *expect* a skip, because if it passed then either: * the guest Alpine is on a writable root FS; are we missing test coverage involving read-only guest images? * we've moved EFI NvVars to out-of-guest storage, and efivarfs writes succeed. In either case, the corresponding changes should have some consideration for how they relate Propolis and guest operations.
1 parent 70e98b1 commit 49d2e1b

File tree

12 files changed

+197
-34
lines changed

12 files changed

+197
-34
lines changed

phd-tests/framework/src/guest_os/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ impl<'a> CommandSequenceEntry<'a> {
5959
}
6060
}
6161

62-
pub(super) struct CommandSequence<'a>(pub Vec<CommandSequenceEntry<'a>>);
62+
pub struct CommandSequence<'a>(pub(crate) Vec<CommandSequenceEntry<'a>>);
6363

6464
impl<'a> CommandSequence<'a> {
6565
fn extend(mut self, other: CommandSequence<'a>) -> CommandSequence<'a> {
@@ -68,15 +68,15 @@ impl<'a> CommandSequence<'a> {
6868
}
6969
}
7070

71-
pub(super) trait GuestOs: Send + Sync {
71+
pub trait GuestOs: Send + Sync {
7272
/// Retrieves the command sequence used to wait for the OS to boot and log
7373
/// into it.
7474
fn get_login_sequence(&self) -> CommandSequence<'_>;
7575

7676
/// Retrieves the default shell prompt for this OS.
7777
fn get_shell_prompt(&self) -> &'static str;
7878

79-
/// Indicates whether the guest has a read-only filesystem.
79+
/// Indicates whether the guest is expected to have a read-only filesystem.
8080
fn read_only_fs(&self) -> bool;
8181

8282
/// Returns the sequence of serial console operations a test VM should issue

phd-tests/framework/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use camino::Utf8PathBuf;
3535

3636
use disk::DiskFactory;
3737
use futures::{stream::FuturesUnordered, StreamExt};
38-
use guest_os::GuestOsKind;
38+
use guest_os::{GuestOs, GuestOsKind};
3939
use log_config::LogConfig;
4040
use port_allocator::PortAllocator;
4141
pub use test_vm::TestVm;
@@ -155,6 +155,11 @@ impl TestCtx {
155155
self.framework.default_guest_os_kind()
156156
}
157157

158+
/// Returns the guest OS adapter corresponding to the default guest OS artifact.
159+
pub fn default_guest_os_adapter(&self) -> anyhow::Result<Box<dyn GuestOs>> {
160+
self.default_guest_os_kind().map(guest_os::get_guest_os_adapter)
161+
}
162+
158163
/// Indicates whether the disk factory in this framework supports the
159164
/// creation of Crucible disks. This can be used to skip tests that require
160165
/// Crucible support.

phd-tests/testcase/src/lib.rs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub struct TestCase {
5050

5151
/// The test function to execute to run this test.
5252
pub(crate) function: TestFunction,
53+
54+
/// A function used to check if a test's skip or pass was actually expected
55+
/// for the given `TestCtx`.
56+
pub(crate) check_skip_fn: Option<fn(&TestCtx) -> bool>,
5357
}
5458

5559
#[allow(dead_code)]
@@ -59,8 +63,9 @@ impl TestCase {
5963
module_path: &'static str,
6064
name: &'static str,
6165
function: TestFunction,
66+
check_skip_fn: Option<fn(&TestCtx) -> bool>,
6267
) -> Self {
63-
Self { module_path, name, function }
68+
Self { module_path, name, function, check_skip_fn }
6469
}
6570

6671
/// Returns the test case's fully qualified name, i.e. `module_path::name`.
@@ -76,7 +81,52 @@ impl TestCase {
7681
/// Runs the test case's body with the supplied test context and returns its
7782
/// outcome.
7883
pub async fn run(&self, ctx: &TestCtx) -> TestOutcome {
79-
(self.function.f)(ctx).await
84+
let outcome = (self.function.f)(ctx).await;
85+
match (outcome, self.check_skip_fn) {
86+
(TestOutcome::Passed, check_skip_fn) => {
87+
let wanted_skip = if let Some(check_skip_fn) = check_skip_fn {
88+
check_skip_fn(ctx)
89+
} else {
90+
false
91+
};
92+
93+
if wanted_skip {
94+
return TestOutcome::Failed(Some(
95+
"test passed but expected skip?".to_string()
96+
));
97+
}
98+
99+
TestOutcome::Passed
100+
}
101+
(TestOutcome::Failed(skip_msg), _) => TestOutcome::Failed(skip_msg),
102+
(TestOutcome::Skipped(skip_msg), None) => {
103+
let fail_msg = match skip_msg {
104+
Some(msg) => {
105+
format!("skipped without check_skip attribute: {msg}")
106+
}
107+
None => {
108+
"skipped without check_skip attribute".to_string()
109+
}
110+
};
111+
TestOutcome::Failed(Some(fail_msg))
112+
}
113+
(TestOutcome::Skipped(skip_msg), Some(check_skip_fn)) => {
114+
if check_skip_fn(ctx) {
115+
return TestOutcome::Skipped(skip_msg);
116+
}
117+
118+
let fail_msg = match skip_msg {
119+
Some(msg) => {
120+
format!("skipped but did not expect skip: {msg}")
121+
}
122+
None => {
123+
"skipped but did not expect skip".to_string()
124+
}
125+
};
126+
127+
TestOutcome::Failed(Some(fail_msg))
128+
}
129+
}
80130
}
81131
}
82132

phd-tests/testcase_macro/src/lib.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,61 @@ use heck::ToShoutySnakeCase;
66
use proc_macro::TokenStream;
77
use proc_macro_error::{abort, proc_macro_error};
88
use quote::{format_ident, quote};
9-
use syn::{parse_macro_input, spanned::Spanned, ItemFn};
9+
use syn::parse::{Parse, ParseStream};
10+
use syn::punctuated::Punctuated;
11+
use syn::spanned::Spanned;
12+
use syn::{parse_macro_input, ItemFn, Token};
13+
14+
// More or less directly inspired by Omicron's
15+
// `nexus/test-utils-macros/src/lib.rs`
16+
struct PhdTestAttrs {
17+
/// The top-level function determining if a `phd_skip!()` test conclusion is
18+
/// acceptable or not. See the documentation of `fn phd_testcase` below for
19+
/// more.
20+
check_skip_fn: Option<syn::Path>,
21+
}
22+
23+
#[derive(Debug, PartialEq, Eq, Hash)]
24+
pub(crate) enum PhdTestArg {
25+
CheckSkipFn(syn::Path),
26+
}
27+
28+
impl Parse for PhdTestArg {
29+
fn parse(input: ParseStream) -> syn::Result<Self> {
30+
let name: syn::Path = input.parse()?;
31+
let _eq_token: syn::token::Eq = input.parse()?;
32+
33+
if name.is_ident("check_skip") {
34+
let fn_ident: syn::Path = input.parse()?;
35+
return Ok(Self::CheckSkipFn(fn_ident));
36+
}
37+
38+
Err(syn::Error::new(name.span(), "unrecognized argument to phd_test"))
39+
}
40+
}
41+
42+
impl Parse for PhdTestAttrs {
43+
fn parse(input: ParseStream) -> syn::Result<Self> {
44+
let vars =
45+
Punctuated::<PhdTestArg, Token![,]>::parse_terminated(input)?;
46+
47+
let mut check_skip_fn = None;
48+
49+
for var in vars {
50+
match var {
51+
PhdTestArg::CheckSkipFn(path) => {
52+
assert!(
53+
check_skip_fn.is_none(),
54+
"check_skip function can be provided at most once"
55+
);
56+
check_skip_fn = Some(path);
57+
}
58+
}
59+
}
60+
61+
Ok(PhdTestAttrs { check_skip_fn })
62+
}
63+
}
1064

1165
/// The macro for labeling PHD testcases.
1266
///
@@ -15,23 +69,37 @@ use syn::{parse_macro_input, spanned::Spanned, ItemFn};
1569
/// wrapper function that returns a `phd_testcase::TestOutcome` and creates an
1670
/// entry in the test case inventory that allows the PHD runner to enumerate the
1771
/// test.
72+
///
73+
/// PHD checks if a test is allowed to skip for a given test configuration. A
74+
/// test that may skip must have a `check_skip(some::check::function)`; if the
75+
/// test skips then the provided function is called to determine if skipping is
76+
/// allowed. If such a test passes instead of skipping, the will also be failed
77+
/// if it was expected to skip in the test environment.
1878
#[proc_macro_error]
1979
#[proc_macro_attribute]
20-
pub fn phd_testcase(_attrib: TokenStream, input: TokenStream) -> TokenStream {
80+
pub fn phd_testcase(attrs: TokenStream, input: TokenStream) -> TokenStream {
2181
let item_fn = parse_macro_input!(input as ItemFn);
2282

83+
let test_attrs = parse_macro_input!(attrs as PhdTestAttrs);
84+
2385
// Build the inventory record for this test. The `module_path!()` in the
2486
// generated code allows the test case to report the fully-qualified path to
2587
// itself regardless of where it's located.
2688
let fn_ident = item_fn.sig.ident.clone();
2789
let fn_name = fn_ident.to_string();
2890
let static_ident = format_ident!("{}", fn_name.to_shouty_snake_case());
91+
let check_skip_fn = if let Some(check_skip_fn) = test_attrs.check_skip_fn {
92+
quote! { Some(#check_skip_fn) }
93+
} else {
94+
quote! { None }
95+
};
2996
let submit: proc_macro2::TokenStream = quote! {
3097
#[linkme::distributed_slice(phd_testcase::TEST_CASES)]
3198
static #static_ident: phd_testcase::TestCase = phd_testcase::TestCase::new(
3299
module_path!(),
33100
#fn_name,
34-
phd_testcase::TestFunction { f: |ctx| Box::pin(#fn_ident(ctx)) }
101+
phd_testcase::TestFunction { f: |ctx| Box::pin(#fn_ident(ctx)) },
102+
#check_skip_fn,
35103
);
36104
};
37105

phd-tests/tests/src/boot_order.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ use efi_utils::{
2020
EDK2_FIRMWARE_VOL_GUID, EDK2_UI_APP_GUID,
2121
};
2222

23+
fn no_efivarfs(ctx: &TestCtx) -> bool {
24+
// A predicate function most boot order tests are gated on. There is some
25+
// minimum Linux version where efivarfs first shipped, but we're assuming
26+
// that no one is testing such an old Linux under PHD; kernel version's
27+
// aren't directly represented in the guest OS kind anyway.
28+
!ctx.default_guest_os_kind().expect("has default guest os kind").is_linux()
29+
}
30+
2331
// This test checks that with a specified boot order, the guest boots whichever
2432
// disk we wanted to come first. This is simple enough, until you want to know
2533
// "what you booted from"..
@@ -48,7 +56,7 @@ use efi_utils::{
4856
//
4957
// Unlike later tests, this test does not manipulate boot configuration from
5058
// inside the guest OS.
51-
#[phd_testcase]
59+
#[phd_testcase(check_skip = no_efivarfs)]
5260
async fn configurable_boot_order(ctx: &TestCtx) {
5361
let mut cfg = ctx.vm_config_builder("configurable_boot_order");
5462

@@ -114,7 +122,7 @@ async fn configurable_boot_order(ctx: &TestCtx) {
114122
// specifically asserts that the unbootable disk is first in the boot order; the
115123
// system booting means that boot order is respected and a non-bootable disk
116124
// does not wedge startup.
117-
#[phd_testcase]
125+
#[phd_testcase(check_skip = no_efivarfs)]
118126
async fn unbootable_disk_skipped(ctx: &TestCtx) {
119127
let mut cfg = ctx.vm_config_builder("unbootable_disk_skipped");
120128

@@ -233,7 +241,12 @@ async fn unbootable_disk_skipped(ctx: &TestCtx) {
233241
// Start with the boot order being `["boot-disk", "unbootable"]`, then change it
234242
// so that next boot we'll boot from `unbootable` first. Then reboot and verify
235243
// that the boot order is still "boot-disk" first.
236-
#[phd_testcase]
244+
//
245+
// TODO: this test will `skip` if the guest is a read-only Alpine, for example.
246+
// if the guest doesn't have an efivarfs, this test warns loudly and then
247+
// passes. The predicate function checks for a much simpler "is linux". Both of
248+
// these are hard to discover from a predicate function (probably?)
249+
#[phd_testcase(check_skip = no_efivarfs)]
237250
async fn guest_can_adjust_boot_order(ctx: &TestCtx) {
238251
let mut cfg = ctx.vm_config_builder("guest_can_adjust_boot_order");
239252

@@ -400,7 +413,7 @@ async fn guest_can_adjust_boot_order(ctx: &TestCtx) {
400413
// If `bootorder` is removed for subsequent reboots, the EFI System Partition's
401414
// store of NvVar variables is the source of boot order, and guests can control
402415
// their boot fates.
403-
#[phd_testcase]
416+
#[phd_testcase(check_skip = no_efivarfs)]
404417
async fn boot_order_source_priority(ctx: &TestCtx) {
405418
let mut cfg = ctx.vm_config_builder("boot_order_source_priority");
406419

@@ -507,7 +520,7 @@ async fn boot_order_source_priority(ctx: &TestCtx) {
507520
);
508521
}
509522

510-
#[phd_testcase]
523+
#[phd_testcase(check_skip = no_efivarfs)]
511524
async fn nvme_boot_option_description(ctx: &TestCtx) {
512525
let mut cfg = ctx.vm_config_builder("nvme_boot_option_description");
513526

phd-tests/tests/src/cpuid.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// License, v. 2.0. If a copy of the MPL was not distributed with this
33
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5-
use cpuid_utils::{CpuidIdent, CpuidSet, CpuidValues};
5+
use cpuid_utils::{CpuidIdent, CpuidSet, CpuidValues, CpuidVendor};
66
use itertools::Itertools;
77
use phd_framework::{test_vm::MigrationTimeout, TestVm};
88
use phd_testcase::*;
@@ -276,7 +276,16 @@ impl<'a> LinuxGuestTopo<'a> {
276276
}
277277
}
278278

279-
#[phd_testcase]
279+
fn host_not_intel(_ctx: &TestCtx) -> bool {
280+
let base_leaf: CpuidIdent = CpuidIdent::leaf(0);
281+
282+
let values = cpuid_utils::host::query(base_leaf);
283+
let vendor = CpuidVendor::try_from(values).expect("recognized CPU vendor");
284+
285+
vendor != CpuidVendor::Intel
286+
}
287+
288+
#[phd_testcase(check_skip = host_not_intel)]
280289
async fn guest_cpu_topo_test(ctx: &TestCtx) {
281290
let vm = launch_cpuid_smoke_test_vm(ctx, "guest_cpu_topo_test").await?;
282291

phd-tests/tests/src/crucible/migrate.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use phd_testcase::*;
77
use tracing::info;
88
use uuid::Uuid;
99

10-
#[phd_testcase]
10+
#[phd_testcase(check_skip = super::crucible_disabled)]
1111
async fn smoke_test(ctx: &TestCtx) {
1212
let mut config = ctx.vm_config_builder("crucible_migrate_smoke_source");
1313
super::add_default_boot_disk(ctx, &mut config)?;
@@ -37,7 +37,7 @@ async fn smoke_test(ctx: &TestCtx) {
3737
assert_eq!(lsout, "foo.bar");
3838
}
3939

40-
#[phd_testcase]
40+
#[phd_testcase(check_skip = super::crucible_disabled)]
4141
async fn load_test(ctx: &TestCtx) {
4242
let mut config = ctx.vm_config_builder("crucible_load_test_source");
4343
super::add_default_boot_disk(ctx, &mut config)?;

phd-tests/tests/src/crucible/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ use phd_testcase::{
1313
mod migrate;
1414
mod smoke;
1515

16+
// This predicate is useful for tests depending on these functions
17+
// which need to report if they expected to be skipped or not.
18+
pub(crate) fn crucible_disabled(ctx: &TestCtx) -> bool {
19+
!ctx.crucible_enabled()
20+
}
21+
1622
fn add_crucible_boot_disk_or_skip<'a>(
1723
ctx: &TestCtx,
1824
config: &mut VmConfig<'a>,

0 commit comments

Comments
 (0)