Skip to content

Commit 780bde7

Browse files
committed
Intruduce an E2E testing framework
Add a tests directory containing an E2E testing framework. The tests are E2E in the sense that they use the public API of libkrun to start a VM and run a test program in the guest. Note that currently this is very limited, because there are no other userspace executables or libraries (not even libc) in the guest apart from `guest-agent`. `guest-agent` is a statically linked Rust executable, that executes the guest part of each test. In the future this can be extended to run tests with specific images which would allow the use of system libraries by the test program. This also introduces 2 tests: test_vm_config - asserts the VM is constructed with the correct number of CPUs and amount of memory. This also serves as an example how the tests can be parameterized. test_vsock_guest_connect - This tests the guest connecting to a vsock port created by the `krun_add_vsock_port` API. Signed-off-by: Matej Hrica <[email protected]>
1 parent 0cdbef2 commit 780bde7

File tree

16 files changed

+1207
-0
lines changed

16 files changed

+1207
-0
lines changed

tests/Cargo.lock

+569
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[workspace]
2+
members = ["runner", "guest-agent", "macros", "test_cases"]
3+
resolver = "2"

tests/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# End-to-end tests
2+
The testing framework here allows you to write code to configure libkrun (using the public API) and run some specific code in the guest.
3+
4+
## Running the tests:
5+
The tests can be ran using `make test` (from the main libkrun directory).
6+
You can also run `./run.sh` inside the `test` directory. When using the `./run.sh` script you probably want specify the `PKG_CONFIG_PATH` enviroment variable, otherwise you will be testing the system wide installation of libkrun.
7+
8+
## Adding tests
9+
To add a test you need to add a new rust module in the `test_cases` directory, implement the required host and guest side methods (see existing tests) and register the test in the `test_cases/src/lib.rs` to be ran.

tests/guest-agent/Cargo.toml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "guest-agent"
3+
edition = "2021"
4+
5+
[dependencies]
6+
test_cases = { path = "../test_cases", features = ["guest"] }
7+
anyhow = "1.0.95"

tests/guest-agent/src/main.rs

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use anyhow::Context;
2+
use std::env::args;
3+
use test_cases::{test_cases, TestCase};
4+
5+
fn run_guest_agent(test_name: &str) -> anyhow::Result<()> {
6+
let tests = test_cases();
7+
let test_case = tests
8+
.into_iter()
9+
.find(|t| t.name() == test_name)
10+
.context("No such test!")?;
11+
let TestCase { test, name: _ } = test_case;
12+
test.in_guest();
13+
Ok(())
14+
}
15+
16+
fn main() -> anyhow::Result<()> {
17+
let mut cli_args = args();
18+
let _exec_name = cli_args.next();
19+
let test_name = cli_args.next().context("Missing test name argument")?;
20+
run_guest_agent(&test_name)?;
21+
Ok(())
22+
}

tests/macros/Cargo.toml

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "macros"
3+
edition = "2021"
4+
5+
[lib]
6+
proc-macro = true
7+
8+
[dependencies]
9+
syn = "2.0.96"
10+
quote = "1.0.38"

tests/macros/src/lib.rs

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
extern crate proc_macro;
2+
extern crate quote;
3+
extern crate syn;
4+
5+
use proc_macro::TokenStream;
6+
use quote::quote;
7+
8+
#[proc_macro_attribute]
9+
pub fn guest(_args: TokenStream, input: TokenStream) -> TokenStream {
10+
let mut prefix: TokenStream = quote! {
11+
#[cfg(feature = "guest")]
12+
}
13+
.into();
14+
15+
prefix.extend(input);
16+
prefix.into()
17+
}
18+
19+
#[proc_macro_attribute]
20+
pub fn host(_args: TokenStream, input: TokenStream) -> TokenStream {
21+
let mut prefix: TokenStream = quote! {
22+
#[cfg(feature = "host")]
23+
}
24+
.into();
25+
26+
prefix.extend(input);
27+
prefix.into()
28+
}

tests/run.sh

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#/bin/sh
2+
3+
# This script has to be run with the working directory being "test"
4+
# This runs the tests on the libkrun instance found by pkg-config.
5+
# Specify PKG_CONFIG_PATH env variable to test a non-system installation of libkurn.
6+
7+
set -e
8+
9+
# Run the unit tests first (this tests the testing framework itself not libkrun)
10+
cargo test -p test_cases --features guest
11+
12+
GUEST_TARGET_ARCH="$(uname -m)-unknown-linux-musl"
13+
14+
cargo build --target=$GUEST_TARGET_ARCH -p guest-agent
15+
KRUN_TEST_GUEST_AGENT_PATH="target/$GUEST_TARGET_ARCH/debug/guest-agent" cargo run -p runner "$@"

tests/runner/Cargo.toml

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "runner"
3+
edition = "2021"
4+
5+
[dependencies]
6+
test_cases = { path = "../test_cases", features = ["host"] }
7+
anyhow = "1.0.95"
8+
nix = { version = "0.29.0", features = ["resource"] }
9+
macros = { path = "../macros" }
10+
clap = { version = "4.5.27", features = ["derive"] }
11+
tempdir = "0.3.7"

tests/runner/src/main.rs

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use anyhow::Context;
2+
use clap::Parser;
3+
use nix::sys::resource::{getrlimit, setrlimit, Resource};
4+
use std::panic::catch_unwind;
5+
use std::path::PathBuf;
6+
use std::process::{Command, Stdio};
7+
use std::{env, mem};
8+
use tempdir::TempDir;
9+
use test_cases::{test_cases, Test, TestCase, TestSetup};
10+
11+
fn get_test(name: &str) -> anyhow::Result<Box<dyn Test>> {
12+
let tests = test_cases();
13+
tests
14+
.into_iter()
15+
.find(|t| t.name() == name)
16+
.with_context(|| format!("No such test: {name}"))
17+
.map(|t| t.test)
18+
}
19+
20+
fn start_vm(test_setup: TestSetup) -> anyhow::Result<()> {
21+
// Raise soft fd limit up to the hard limit
22+
let (_soft_limit, hard_limit) =
23+
getrlimit(Resource::RLIMIT_NOFILE).context("getrlimit RLIMIT_NOFILE")?;
24+
setrlimit(Resource::RLIMIT_NOFILE, hard_limit, hard_limit)
25+
.context("setrlimit RLIMIT_NOFILE")?;
26+
27+
let test = get_test(&test_setup.test_case)?;
28+
test.start_vm(test_setup.clone())
29+
.with_context(|| format!("testcase: {test_setup:?}"))?;
30+
Ok(())
31+
}
32+
33+
fn run_single_test(test_case: &str) -> anyhow::Result<bool> {
34+
let executable = env::current_exe().context("Failed to detect current executable")?;
35+
let tmp_dir =
36+
TempDir::new(&format!("krun-test-{test_case}")).context("Failed to create tmp dir")?;
37+
38+
let child = Command::new(&executable)
39+
.arg("start-vm")
40+
.arg("--test-case")
41+
.arg(test_case)
42+
.arg("--tmp-dir")
43+
.arg(tmp_dir.path())
44+
.stdin(Stdio::piped())
45+
.stdout(Stdio::piped())
46+
.stderr(Stdio::piped())
47+
.spawn()
48+
.context("Failed to start subprocess for test")?;
49+
50+
let _ = get_test(test_case)?;
51+
let result = catch_unwind(|| {
52+
let test = get_test(test_case).unwrap();
53+
test.check(child);
54+
});
55+
56+
match result {
57+
Ok(()) => {
58+
println!("[{test_case}]: OK");
59+
Ok(true)
60+
}
61+
Err(_e) => {
62+
println!("[{test_case}]: FAIL (dir {:?} kept)", tmp_dir.path());
63+
mem::forget(tmp_dir);
64+
Ok(false)
65+
}
66+
}
67+
}
68+
69+
fn run_tests(test_case: &str) -> anyhow::Result<()> {
70+
let mut num_tests = 1;
71+
let mut num_ok: usize = 0;
72+
73+
if test_case == "all" {
74+
let test_cases = test_cases();
75+
num_tests = test_cases.len();
76+
77+
for TestCase { name, test: _ } in test_cases {
78+
num_ok += run_single_test(name).context(name)? as usize;
79+
}
80+
} else {
81+
num_ok += run_single_test(test_case).context(test_case.to_string())? as usize;
82+
}
83+
84+
let num_failures = num_tests - num_ok;
85+
if num_failures > 0 {
86+
println!("\nFAIL (PASSED {num_ok}/{num_tests})");
87+
anyhow::bail!("")
88+
} else {
89+
println!("\nOK (PASSED {num_ok}/{num_tests})");
90+
}
91+
92+
Ok(())
93+
}
94+
95+
#[derive(clap::Subcommand, Clone, Debug)]
96+
enum CliCommand {
97+
Test {
98+
/// Specify which test to run or "all"
99+
#[arg(long, default_value = "all")]
100+
test_case: String,
101+
},
102+
StartVm {
103+
#[arg(long)]
104+
test_case: String,
105+
#[arg(long)]
106+
tmp_dir: PathBuf,
107+
},
108+
}
109+
110+
impl Default for CliCommand {
111+
fn default() -> Self {
112+
Self::Test {
113+
test_case: "all".to_string(),
114+
}
115+
}
116+
}
117+
118+
#[derive(clap::Parser)]
119+
struct Cli {
120+
#[command(subcommand)]
121+
command: Option<CliCommand>,
122+
}
123+
124+
fn main() -> anyhow::Result<()> {
125+
let cli = Cli::parse();
126+
let command = cli.command.unwrap_or_default();
127+
128+
match command {
129+
CliCommand::StartVm { test_case, tmp_dir } => start_vm(TestSetup { test_case, tmp_dir }),
130+
CliCommand::Test { test_case } => run_tests(&test_case),
131+
}
132+
}

tests/test_cases/Cargo.toml

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "test_cases"
3+
edition = "2021"
4+
5+
[features]
6+
host = ["krun-sys"]
7+
guest = []
8+
9+
[lib]
10+
name = "test_cases"
11+
12+
[dependencies]
13+
krun-sys = { path = "../../krun-sys", optional = true }
14+
macros = { path = "../macros" }
15+
nix = { version = "0.29.0", features = ["socket"] }
16+
anyhow = "1.0.95"
17+
tempdir = "0.3.7"

tests/test_cases/src/common.rs

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//! Common utilities used by multiple test
2+
3+
use anyhow::Context;
4+
use std::ffi::CString;
5+
use std::fs;
6+
use std::fs::create_dir;
7+
use std::os::unix::ffi::OsStrExt;
8+
use std::path::Path;
9+
use std::ptr::null;
10+
11+
use crate::{krun_call, TestSetup};
12+
use krun_sys::*;
13+
14+
fn copy_guest_agent(dir: &Path) -> anyhow::Result<()> {
15+
let path = std::env::var_os("KRUN_TEST_GUEST_AGENT_PATH")
16+
.context("KRUN_TEST_GUEST_AGENT_PATH env variable not set")?;
17+
18+
let output_path = dir.join("guest-agent");
19+
fs::copy(path, output_path).context("Failed to copy executable into vm")?;
20+
Ok(())
21+
}
22+
23+
/// Common part of most test. This setups an empty root filesystem, copies the guest agent there
24+
/// and runs the guest agent in the VM.
25+
/// Note that some tests might want to use a different root file system (perhaps a qcow image),
26+
/// in which case the test can implement the equivalent functionality itself, or better if there
27+
/// are more test doing that, add another utility method in this file.
28+
///
29+
/// The returned object is used for deleting the temporary files.
30+
pub fn setup_fs_and_enter(ctx: u32, test_setup: TestSetup) -> anyhow::Result<()> {
31+
let root_dir = test_setup.tmp_dir.join("root");
32+
create_dir(&root_dir).context("Failed to create root directory")?;
33+
34+
let path_str = CString::new(root_dir.as_os_str().as_bytes()).context("CString::new")?;
35+
copy_guest_agent(&root_dir)?;
36+
unsafe {
37+
krun_call!(krun_set_root(ctx, path_str.as_ptr()))?;
38+
krun_call!(krun_set_workdir(ctx, c"/".as_ptr()))?;
39+
let test_case_cstr = CString::new(test_setup.test_case).context("CString::new")?;
40+
let argv = [test_case_cstr.as_ptr(), null()];
41+
//let envp = [c"RUST_BACKTRACE=1".as_ptr(), null()];
42+
let envp = [null()];
43+
krun_call!(krun_set_exec(
44+
ctx,
45+
c"/guest-agent".as_ptr(),
46+
argv.as_ptr(),
47+
envp.as_ptr(),
48+
))?;
49+
krun_call!(krun_start_enter(ctx))?;
50+
}
51+
unreachable!()
52+
}

tests/test_cases/src/krun.rs

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#[macro_export]
2+
macro_rules! krun_call {
3+
($func_call:expr) => {{
4+
let result = $func_call;
5+
if result < 0 {
6+
let err = std::io::Error::from_raw_os_error(-result);
7+
Err(anyhow::anyhow!("`{}`: {}", stringify!($func_call), err))
8+
} else {
9+
Ok::<(), anyhow::Error>(())
10+
}
11+
}};
12+
}
13+
14+
#[macro_export]
15+
macro_rules! krun_call_u32 {
16+
($func_call:expr) => {{
17+
let result = $func_call;
18+
if result < 0 {
19+
let err = std::io::Error::from_raw_os_error(-result);
20+
Err(anyhow::anyhow!("`{}`: {}", stringify!($func_call), err))
21+
} else {
22+
Ok::<u32, anyhow::Error>(result as u32)
23+
}
24+
}};
25+
}

0 commit comments

Comments
 (0)