Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

phd: check exit status of commands, have tests expect success/failure #797

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
79 changes: 76 additions & 3 deletions phd-tests/framework/src/test_vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,51 @@ enum VmState {
Ensured { serial: SerialConsole },
}

/// Both the output and status of a command.
pub struct ShellOutput {
status: u16,
output: String,
}

impl ShellOutput {
/// Consume this [`ShellOutput`], returning the command's output as text
/// if the command completed successfully, or an error if it did not.
pub fn expect_ok(self) -> Result<String> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a bit weird to me that expect_ok() returns a Result instead of panicking --- i would generally expect (haha) a function named expect_foo to panic if the expectation is violated. Perhaps these would make more sense as just .ok()/.err()? I dunno. Maybe this is clearer to others...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hum yeah that seems pretty reasonable. this naming probably made a little more sense in the first draft where i was hoping this would configure a future to expect the result is ok rather than this goofy-Result-to-Result transformation. i'll see if i can get something nice with IntoFuture and if not i'll s/expect_/g

if self.status == 0 {
Ok(self.output)
} else {
Err(anyhow!("command exited with non-zero status: {}", self.status))
}
}

/// Consume this [`ShellOutput`], returning the command's output as text
/// if the command exited with non-zero status, or an error if it exited
/// with status zero.
pub fn expect_err(self) -> Result<String> {
if self.status != 0 {
Ok(self.output)
} else {
Err(anyhow!("command unexpectedly succeeded"))
}
}

pub fn ignore_status(self) -> String {
iximeow marked this conversation as resolved.
Show resolved Hide resolved
self.output
}

pub fn status(&self) -> u16 {
self.status
}

/// Get the textual output of the command. You probably should use
/// [`ShellOutput::expect_ok`] or [`ShellOutput::expect_err`] to ensure
/// the command was processed as expected before asserting on its
/// output.
pub fn output(&self) -> &str {
self.output.as_str()
}
}

/// A virtual machine running in a Propolis server. Test cases create these VMs
/// using the `factory::VmFactory` embedded in their test contexts.
///
Expand Down Expand Up @@ -868,7 +913,21 @@ impl TestVm {
/// waits for another shell prompt to appear using
/// [`Self::wait_for_serial_output`] and returns any text that was buffered
/// to the serial console after the command was sent.
pub async fn run_shell_command(&self, cmd: &str) -> Result<String> {
///
/// After running the shell command, sends `echo $?` to query and return the
/// command's return status as well.
// TO REVIEWERS: it would be really nice to write this as a function
// returning a `struct ShellCommandExecutor` that impls
// `Future<Output=String>` where the underlying ShellOutput is automatically
// `.expect_ok()`'d. In such a case it would be possible for the struct to
// have an `.expect_err()` that replaces teh default `.expect_ok()`
// behavior, so that the likely case doesn't need any change in PHD tests.
//
// unfortunately I don't know how to plumb the futures for that, since we'd
// have to close over `&self`, so doing any Boxing to hold an
// `async move {}` immediately causes issues.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my thinking is that it'd be nice if vm.run_shell_command("foo").await automatically did what expect_ok does in this change, but if you know to expect an error you could vm.run_shell_command("foo").expect_err().await to modify the future and then run it. concretely it looked something like:

struct ShellCommandExecutor {
    fut: Pin<Box<impl Future<Output=Result<ShellOutput>> + Send + Sync>>
}

impl ShellCommandExecutor {
    fn expect_err(self) -> ShellCommandExecutorExpectErr { /* build the thing */ }
}
impl Future for ShellCommandExecutor {
    fn poll(&self, cx: &mut Context) -> Self::Output {
        match self.fut.poll(cx) {
            Poll::Ready(v) => Poll::Ready(v.expect_ok()),
            Poll::Pending => Poll::Pending
        }
    }
}

but i couldn't figure out how to impl Future in a way that doesn't conflict with fut holding &self. so this patch does the next best thing i could think of.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it's what IntoFuture is supposed to be for, right? "I want to have a thing which is not actually a Future implementation, but knows how to turn itself into a future, so you can still .await it and it will go and turn itself into a future".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok! now that i got the more Future-ful approach together i think this all is much cleaner (and incidentally resolves your other comments). it's kinda sickrad that this only comes with like 10 changes in tests now, since it's going along with the general assumption that commands run in guests should be successful.

#[must_use]
pub async fn run_shell_command(&self, cmd: &str) -> Result<ShellOutput> {
// Allow the guest OS to transform the input command into a
// guest-specific command sequence. This accounts for the guest's shell
// type (which affects e.g. affects how it displays multi-line commands)
Expand All @@ -881,7 +940,18 @@ impl TestVm {
// before actually issuing the final '\n' that issues the command.
// This ensures that the buffer contents returned by this call contain
// only the command's output.
let out = self
let output = self
.wait_for_serial_output(
self.guest_os.get_shell_prompt(),
Duration::from_secs(300),
)
.await?;

let status_command_sequence =
self.guest_os.shell_command_sequence("echo $?");
self.run_command_sequence(status_command_sequence).await?;

let status = self
.wait_for_serial_output(
self.guest_os.get_shell_prompt(),
Duration::from_secs(300),
Expand All @@ -891,7 +961,10 @@ impl TestVm {
// Trim any leading newlines inserted when the command was issued and
// any trailing whitespace that isn't actually part of the command
// output. Any other embedded whitespace is the caller's problem.
Ok(out.trim().to_string())
let output = output.trim().to_string();
let status = status.trim().parse::<u16>()?;

Ok(ShellOutput { status, output })
}

pub async fn graceful_reboot(&self) -> Result<()> {
Expand Down
8 changes: 5 additions & 3 deletions phd-tests/tests/src/boot_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use anyhow::{bail, Error};
use phd_framework::{
disk::{fat::FatFilesystem, DiskSource},
test_vm::{DiskBackend, DiskInterface},
test_vm::{DiskBackend, DiskInterface, ShellOutput},
};
use phd_testcase::*;
use std::io::Cursor;
Expand All @@ -23,7 +23,7 @@ use efi_utils::{
pub(crate) async fn run_long_command(
vm: &phd_framework::TestVm,
cmd: &str,
) -> Result<String, Error> {
) -> Result<ShellOutput, Error> {
// Ok, this is a bit whacky: something about the line wrapping for long
// commands causes `run_shell_command` to hang instead of ever actually
// seeing a response prompt.
Expand Down Expand Up @@ -284,7 +284,8 @@ async fn guest_can_adjust_boot_order(ctx: &Framework) {

// If the guest doesn't have an EFI partition then there's no way for boot
// order preferences to be persisted.
let mountline = vm.run_shell_command("mount | grep efivarfs").await?;
let mountline =
vm.run_shell_command("mount | grep efivarfs").await?.ignore_status();

if !mountline.starts_with("efivarfs on ") {
warn!(
Expand All @@ -298,6 +299,7 @@ async fn guest_can_adjust_boot_order(ctx: &Framework) {
// reboot, and make sure they're all as we set them.
if !run_long_command(&vm, &format!("ls {}", efipath(&bootvar(0xffff))))
.await?
.expect_err()?
.is_empty()
{
warn!(
Expand Down
7 changes: 4 additions & 3 deletions phd-tests/tests/src/boot_order/efi_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ pub(crate) async fn read_efivar(
efipath(varname)
);

let hex = run_long_command(vm, &cmd).await?;
let hex = run_long_command(vm, &cmd).await?.expect_ok()?;

Ok(unhex(&hex))
}
Expand All @@ -345,7 +345,8 @@ pub(crate) async fn write_efivar(
efipath(varname)
);

let attr_read_bytes = run_long_command(vm, &attr_cmd).await?;
let attr_read_bytes =
run_long_command(vm, &attr_cmd).await?.ignore_status();
let attrs = if attr_read_bytes.ends_with(": No such file or directory") {
// Default attributes if the variable does not exist yet. We expect it
// to be non-volatile because we are writing it, we expect it to be
Expand Down Expand Up @@ -390,7 +391,7 @@ pub(crate) async fn write_efivar(
efipath(varname)
);

let res = run_long_command(vm, &cmd).await?;
let res = run_long_command(vm, &cmd).await?.expect_ok()?;
// If something went sideways and the write failed with something like
// `invalid argument`...
if !res.is_empty() {
Expand Down
3 changes: 2 additions & 1 deletion phd-tests/tests/src/cpuid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ async fn cpuid_boot_test(ctx: &Framework) {
vm.launch().await?;
vm.wait_to_boot().await?;

let cpuinfo = vm.run_shell_command("cat /proc/cpuinfo").await?;
let cpuinfo =
vm.run_shell_command("cat /proc/cpuinfo").await?.expect_ok()?;
info!(cpuinfo, "/proc/cpuinfo output");
assert!(cpuinfo.contains(
std::str::from_utf8(BRAND_STRING).unwrap().trim_matches('\0')
Expand Down
30 changes: 20 additions & 10 deletions phd-tests/tests/src/crucible/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ async fn smoke_test(ctx: &Framework) {
source.launch().await?;
source.wait_to_boot().await?;

let lsout = source.run_shell_command("ls foo.bar 2> /dev/null").await?;
let lsout = source
.run_shell_command("ls foo.bar 2> /dev/null")
.await?
.expect_err()?;
assert_eq!(lsout, "");
source.run_shell_command("touch ./foo.bar").await?;
source.run_shell_command("sync ./foo.bar").await?;
source.run_shell_command("touch ./foo.bar").await?.expect_ok()?;
source.run_shell_command("sync ./foo.bar").await?.expect_ok()?;

disk.set_generation(2);
let mut target = ctx
Expand All @@ -32,7 +35,7 @@ async fn smoke_test(ctx: &Framework) {
target
.migrate_from(&source, Uuid::new_v4(), MigrationTimeout::default())
.await?;
let lsout = target.run_shell_command("ls foo.bar").await?;
let lsout = target.run_shell_command("ls foo.bar").await?.expect_ok()?;
assert_eq!(lsout, "foo.bar");
}

Expand Down Expand Up @@ -63,27 +66,34 @@ async fn load_test(ctx: &Framework) {
)
.as_str(),
)
.await?;
.await?
.expect_ok()?;
assert!(ddout.contains(format!("{}+0 records in", block_count).as_str()));

// Compute the data's hash.
let sha256sum_out = source.run_shell_command("sha256sum rand.txt").await?;
let sha256sum_out =
source.run_shell_command("sha256sum rand.txt").await?.expect_ok()?;
let checksum = sha256sum_out.split_whitespace().next().unwrap();
info!("Generated SHA256 checksum: {}", checksum);

// Start copying the generated file into a second file, then start a
// migration while that copy is in progress.
source.run_shell_command("dd if=./rand.txt of=./rand_new.txt &").await?;
source
.run_shell_command("dd if=./rand.txt of=./rand_new.txt &")
.await?
.expect_ok()?;
target
.migrate_from(&source, Uuid::new_v4(), MigrationTimeout::default())
.await?;

// Wait for the background command to finish running, then compute the
// hash of the copied file. If all went well this will match the hash of
// the source file.
target.run_shell_command("wait $!").await?;
let sha256sum_target =
target.run_shell_command("sha256sum rand_new.txt").await?;
target.run_shell_command("wait $!").await?.expect_ok()?;
let sha256sum_target = target
.run_shell_command("sha256sum rand_new.txt")
.await?
.expect_ok()?;
let checksum_target = sha256sum_target.split_whitespace().next().unwrap();
assert_eq!(checksum, checksum_target);
}
11 changes: 7 additions & 4 deletions phd-tests/tests/src/crucible/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async fn guest_reboot_test(ctx: &Framework) {
vm.launch().await?;
vm.wait_to_boot().await?;

// XXX: use graceful_reboot() now.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(got a followup patch for this)

// Don't use `run_shell_command` because the guest won't echo another prompt
// after this.
vm.send_serial_str("reboot\n").await?;
Expand Down Expand Up @@ -64,10 +65,11 @@ async fn shutdown_persistence_test(ctx: &Framework) {

// Verify that the test file doesn't exist yet, then touch it, flush it, and
// shut down the VM.
let lsout = vm.run_shell_command("ls foo.bar 2> /dev/null").await?;
let lsout =
vm.run_shell_command("ls foo.bar 2> /dev/null").await?.expect_err()?;
assert_eq!(lsout, "");
vm.run_shell_command("touch ./foo.bar").await?;
vm.run_shell_command("sync ./foo.bar").await?;
vm.run_shell_command("touch ./foo.bar").await?.expect_ok()?;
vm.run_shell_command("sync ./foo.bar").await?.expect_ok()?;
vm.stop().await?;
vm.wait_for_state(InstanceState::Destroyed, Duration::from_secs(60))
.await?;
Expand All @@ -82,6 +84,7 @@ async fn shutdown_persistence_test(ctx: &Framework) {
vm.wait_to_boot().await?;

// The touched file from the previous VM should be present in the new one.
let lsout = vm.run_shell_command("ls foo.bar 2> /dev/null").await?;
let lsout =
vm.run_shell_command("ls foo.bar 2> /dev/null").await?.expect_ok()?;
assert_eq!(lsout, "foo.bar");
}
17 changes: 11 additions & 6 deletions phd-tests/tests/src/disk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ async fn in_memory_backend_smoke_test(ctx: &Framework) {
// try to check that the disk is located there and fail the test early if
// it's not. If the by-path directory is missing, put up a warning and hope
// for the best.
let dev_disk = vm.run_shell_command("ls /dev/disk").await?;
let dev_disk = vm.run_shell_command("ls /dev/disk").await?.ignore_status();
if dev_disk.contains("by-path") {
let ls = vm.run_shell_command("ls -la /dev/disk/by-path").await?;
let ls = vm
.run_shell_command("ls -la /dev/disk/by-path")
.await?
.expect_ok()?;
info!(%ls, "guest disk device paths");
assert!(ls.contains("virtio-pci-0000:00:18.0 -> ../../vda"));
} else {
Expand All @@ -49,17 +52,19 @@ async fn in_memory_backend_smoke_test(ctx: &Framework) {
);
}

vm.run_shell_command("mkdir /phd").await?;
vm.run_shell_command("mkdir /phd").await?.expect_ok()?;

// The disk is read-only, so pass the `ro` option to `mount` so that it
// doesn't complain about not being able to mount for writing.
let mount = vm.run_shell_command("mount -o ro /dev/vda /phd").await?;
let mount =
vm.run_shell_command("mount -o ro /dev/vda /phd").await?.expect_ok()?;
assert_eq!(mount, "");

// The file should be there and have the expected contents.
let ls = vm.run_shell_command("ls /phd").await?;
let ls = vm.run_shell_command("ls /phd").await?.expect_ok()?;
assert_eq!(ls, "hello_oxide.txt");

let cat = vm.run_shell_command("cat /phd/hello_oxide.txt").await?;
let cat =
vm.run_shell_command("cat /phd/hello_oxide.txt").await?.expect_ok()?;
assert_eq!(cat, HELLO_MSG);
}
3 changes: 2 additions & 1 deletion phd-tests/tests/src/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ async fn multiline_serial_test(ctx: &Framework) {
vm.launch().await?;
vm.wait_to_boot().await?;

let out = vm.run_shell_command("echo \\\nhello \\\nworld").await?;
let out =
vm.run_shell_command("echo \\\nhello \\\nworld").await?.expect_ok()?;
assert_eq!(out, "hello world");
}
16 changes: 12 additions & 4 deletions phd-tests/tests/src/hw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,23 @@ async fn lspci_lifecycle_test(ctx: &Framework) {
vm.launch().await?;
vm.wait_to_boot().await?;

let lspci = vm.run_shell_command(LSPCI).await?;
let lshw = vm.run_shell_command(LSHW).await?;
// XXX: do not `ignore_status()` on these commands! They fail for any number
// of reasons on different guests:
// * sudo may not exist (some Alpine)
// * lshw may not exist (Debian)
// * we may not input a sudo password (Ubuntu)

let lspci = vm.run_shell_command(LSPCI).await?.ignore_status();
let lshw = vm.run_shell_command(LSHW).await?.ignore_status();
ctx.lifecycle_test(vm, &[Action::StopAndStart], move |vm| {
let lspci = lspci.clone();
let lshw = lshw.clone();
Box::pin(async move {
let new_lspci = vm.run_shell_command(LSPCI).await.unwrap();
let new_lspci =
vm.run_shell_command(LSPCI).await.unwrap().ignore_status();
assert_eq!(new_lspci, lspci);
let new_lshw = vm.run_shell_command(LSHW).await.unwrap();
let new_lshw =
vm.run_shell_command(LSHW).await.unwrap().ignore_status();
assert_eq!(new_lshw, lshw);
})
})
Expand Down
Loading
Loading