test: add --mode vm, the autopkgtest escape hatch
VM virtualization is the one backend pkh does not reimplement: the qemu runner carries the serial-console protocol, image overlay management and reboot support, and it is the only one satisfying isolation-machine/needs-reboot tests. pkh execs the installed autopkgtest with its qemu runner (--image required, --cpus/--ram-size passthrough) in its own process group so Ctrl+C reaches the testbed cleanup, parses the --summary file back into the pkh report model and maps autopkgtest's exit classes onto the 0/1/2 contract (12/14/16/20 are runtime errors; the raw code rides the JSON report). Pockets, PPAs and injection are refused in vm mode for now: they would need the keyring setup inside the image.
This commit is contained in:
+11
-1
@@ -391,7 +391,10 @@ fn main() {
|
||||
.arg(arg!(-p --pocket <pocket> "Resolve test dependencies from a specific distribution pocket (updates, security, proposed)").required(false))
|
||||
.arg(arg!(--ppa <ppa> "Add a PPA as test dependency source (can be specified multiple times)").required(false).action(clap::ArgAction::Append))
|
||||
.arg(arg!(--inject <package> "Inject a package into the testbed before the tests (.deb path or package name, can be specified multiple times)").required(false).action(clap::ArgAction::Append))
|
||||
.arg(arg!(--mode <mode> "Testbed mode: chroot (default, an ephemeral unshare chroot) or local (the current context, no isolation)").required(false))
|
||||
.arg(arg!(--mode <mode> "Testbed mode: chroot (default, an ephemeral unshare chroot), local (the current context, no isolation) or vm (a qemu VM through autopkgtest)").required(false))
|
||||
.arg(arg!(--image <file> "VM image for --mode vm (required there)").required(false))
|
||||
.arg(arg!(--cpus <n> "VM virtual CPUs (vm mode)").required(false).value_parser(clap::value_parser!(u32)))
|
||||
.arg(arg!(--"ram-size" <mib> "VM memory size in MiB (vm mode)").required(false).value_parser(clap::value_parser!(u32)))
|
||||
.arg(arg!(--debs <deb> "Explicit binary package to test, overriding the pkh deb output reuse (can be specified multiple times)").required(false).action(clap::ArgAction::Append))
|
||||
.arg(
|
||||
clap::Arg::new("no_build")
|
||||
@@ -1062,6 +1065,13 @@ fn main() {
|
||||
.map(|v| v.cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
mode,
|
||||
image: sub_matches
|
||||
.get_one::<String>("image")
|
||||
.map(std::path::PathBuf::from),
|
||||
cpus: sub_matches.get_one::<u32>("cpus").copied(),
|
||||
ram_size: sub_matches
|
||||
.get_one::<u32>("ram-size")
|
||||
.map(|v| v.to_string()),
|
||||
debs: sub_matches
|
||||
.get_many::<String>("debs")
|
||||
.map(|v| v.map(std::path::PathBuf::from).collect())
|
||||
|
||||
@@ -48,6 +48,8 @@ impl Bed {
|
||||
Ok(Bed::Chroot(guard))
|
||||
}
|
||||
TestMode::Local => Ok(Bed::Local(base_ctx)),
|
||||
// The vm mode never builds a Bed: run_vm execs autopkgtest.
|
||||
TestMode::Vm => unreachable!("vm mode does not prepare a Bed"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-4
@@ -17,6 +17,7 @@ pub mod expand;
|
||||
pub mod options;
|
||||
pub mod outcome;
|
||||
pub(crate) mod runner;
|
||||
pub(crate) mod vm;
|
||||
|
||||
pub use options::{TestMode, TestOptions};
|
||||
pub use outcome::TestRunReport;
|
||||
@@ -117,6 +118,38 @@ pub async fn run(opts: TestOptions<'_>) -> Result<TestRunReport, Box<dyn std::er
|
||||
});
|
||||
|
||||
let mode = opts.mode.unwrap_or_default();
|
||||
|
||||
// The vm escape hatch goes through the installed autopkgtest; it
|
||||
// owns the artifacts directory (passed as -o, which must not exist
|
||||
// yet), so the path is only computed, not created.
|
||||
if mode == TestMode::Vm {
|
||||
let run_dir = run_dir_path(&package, &series, &arch);
|
||||
let (outcomes, raw_code) = vm::run_vm(
|
||||
&tree,
|
||||
&binaries.paths,
|
||||
&tests_control.tests,
|
||||
&run_dir,
|
||||
opts.image.as_deref(),
|
||||
&opts,
|
||||
view,
|
||||
)?;
|
||||
return finish(
|
||||
TestRunReport {
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
arch,
|
||||
mode: mode.label().to_string(),
|
||||
binaries: binaries.paths,
|
||||
binaries_note: binaries.note,
|
||||
outcomes,
|
||||
artifacts_dir: run_dir,
|
||||
raw_exit_code: Some(raw_code),
|
||||
},
|
||||
view,
|
||||
);
|
||||
}
|
||||
|
||||
if mode == TestMode::Local {
|
||||
log::warn!(
|
||||
"Tests run directly on the current context: they install packages \
|
||||
@@ -217,7 +250,8 @@ pub async fn run(opts: TestOptions<'_>) -> Result<TestRunReport, Box<dyn std::er
|
||||
drop_shell(&bed, &staged_tree, &env, view)?;
|
||||
}
|
||||
|
||||
let report = TestRunReport {
|
||||
finish(
|
||||
TestRunReport {
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
@@ -226,13 +260,22 @@ pub async fn run(opts: TestOptions<'_>) -> Result<TestRunReport, Box<dyn std::er
|
||||
binaries: binaries.paths,
|
||||
binaries_note: binaries.note,
|
||||
outcomes,
|
||||
artifacts_dir: run_dir.clone(),
|
||||
artifacts_dir: run_dir,
|
||||
raw_exit_code: None,
|
||||
};
|
||||
},
|
||||
view,
|
||||
)
|
||||
}
|
||||
|
||||
/// Announce the outcome on the view and hand the report back.
|
||||
fn finish(
|
||||
report: TestRunReport,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<TestRunReport, Box<dyn std::error::Error>> {
|
||||
if report.has_failures() {
|
||||
view.finish_failure();
|
||||
} else {
|
||||
view.finish_success(&[run_dir]);
|
||||
view.finish_success(std::slice::from_ref(&report.artifacts_dir));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -535,6 +578,12 @@ fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result<Pa
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The run directory path for an identity, without creating it.
|
||||
fn run_dir_path(package: &str, series: &str, arch: &str) -> PathBuf {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S");
|
||||
runs_root().join(format!("{package}-{series}-{arch}-{timestamp}"))
|
||||
}
|
||||
|
||||
/// The test-run artifacts root: `$PKH_TESTS_DIR`, defaulting to
|
||||
/// `/var/tmp/pkh/tests`.
|
||||
pub fn runs_root() -> PathBuf {
|
||||
|
||||
+17
-2
@@ -17,6 +17,9 @@ pub enum TestMode {
|
||||
Chroot,
|
||||
/// The current context directly: no isolation, tests can rewrite it.
|
||||
Local,
|
||||
/// A qemu virtual machine, through the installed autopkgtest's qemu
|
||||
/// runner — the one backend pkh does not reimplement.
|
||||
Vm,
|
||||
}
|
||||
|
||||
impl TestMode {
|
||||
@@ -25,6 +28,7 @@ impl TestMode {
|
||||
match self {
|
||||
TestMode::Chroot => "chroot",
|
||||
TestMode::Local => "local",
|
||||
TestMode::Vm => "vm",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +37,7 @@ impl TestMode {
|
||||
match value {
|
||||
"chroot" => Some(TestMode::Chroot),
|
||||
"local" => Some(TestMode::Local),
|
||||
"vm" => Some(TestMode::Vm),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -58,6 +63,12 @@ pub struct TestOptions<'a> {
|
||||
pub inject: Vec<String>,
|
||||
/// Testbed mode; defaults to [`TestMode::Chroot`].
|
||||
pub mode: Option<TestMode>,
|
||||
/// VM image for [`TestMode::Vm`] (required there).
|
||||
pub image: Option<PathBuf>,
|
||||
/// VM virtual CPUs ([`TestMode::Vm`], passthrough).
|
||||
pub cpus: Option<u32>,
|
||||
/// VM memory size in MiB ([`TestMode::Vm`], passthrough).
|
||||
pub ram_size: Option<String>,
|
||||
/// Explicit binary packages to test; overrides the `pkh deb` output
|
||||
/// reuse.
|
||||
pub debs: Vec<PathBuf>,
|
||||
@@ -106,6 +117,9 @@ impl Default for TestOptions<'_> {
|
||||
ppa: Vec::new(),
|
||||
inject: Vec::new(),
|
||||
mode: None,
|
||||
image: None,
|
||||
cpus: None,
|
||||
ram_size: None,
|
||||
debs: Vec::new(),
|
||||
no_build: false,
|
||||
build: false,
|
||||
@@ -197,8 +211,9 @@ mod tests {
|
||||
fn mode_parsing() {
|
||||
assert_eq!(TestMode::parse("chroot"), Some(TestMode::Chroot));
|
||||
assert_eq!(TestMode::parse("local"), Some(TestMode::Local));
|
||||
assert_eq!(TestMode::parse("vm"), None);
|
||||
assert_eq!(TestMode::parse("vm"), Some(TestMode::Vm));
|
||||
assert_eq!(TestMode::parse("other"), None);
|
||||
assert_eq!(TestMode::default(), TestMode::Chroot);
|
||||
assert_eq!(TestMode::Local.label(), "local");
|
||||
assert_eq!(TestMode::Vm.label(), "vm");
|
||||
}
|
||||
}
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
//! The `--mode vm` escape hatch: exec the installed `autopkgtest` with
|
||||
//! its qemu runner.
|
||||
//!
|
||||
//! VM virtualization is the one backend pkh deliberately does not
|
||||
//! reimplement: autopkgtest-virt-qemu carries a serial-console control
|
||||
//! protocol, image overlay management and the reboot protocol, and it is
|
||||
//! the only runner satisfying `isolation-machine`/`needs-reboot` tests.
|
||||
//! Everything around it stays pkh's: binary resolution, the report
|
||||
//! model, the exit-code contract and the artifacts directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::debian::tests_control::Test;
|
||||
use crate::interrupt;
|
||||
use crate::report::BuildView;
|
||||
use crate::test::outcome::{TestOutcome, Verdict};
|
||||
|
||||
/// Run the tests through autopkgtest's qemu runner.
|
||||
///
|
||||
/// `tree` is the source tree, `binaries` the fresh binary packages under
|
||||
/// test (may be empty: autopkgtest then builds inside the VM),
|
||||
/// `run_dir` the artifacts directory (passed as `-o`; autopkgtest
|
||||
/// requires it to not exist yet) and `image` the VM image path. Returns
|
||||
/// the per-test outcomes and autopkgtest's raw exit code; the caller
|
||||
/// builds the full report around them.
|
||||
pub(crate) fn run_vm(
|
||||
tree: &Path,
|
||||
binaries: &[PathBuf],
|
||||
tests: &[Test],
|
||||
run_dir: &Path,
|
||||
image: Option<&Path>,
|
||||
opts: &crate::test::options::TestOptions<'_>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(Vec<TestOutcome>, i32), Box<dyn std::error::Error>> {
|
||||
let Some(image) = image else {
|
||||
return Err(
|
||||
"--mode vm needs a VM image: pass --image, or build one with \
|
||||
'autopkgtest-build-qemu <series> <image>' (Debian) or \
|
||||
'autopkgtest-buildvm-ubuntu-cloud -r <series> -a <arch>' (Ubuntu)"
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
if !image.exists() {
|
||||
return Err(format!(
|
||||
"VM image '{}' does not exist: pass --image, or build one with \
|
||||
'autopkgtest-build-qemu <series> <image>' (Debian) or \
|
||||
'autopkgtest-buildvm-ubuntu-cloud -r <series> -a <arch>' (Ubuntu)",
|
||||
image.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !opts.ppa.is_empty() || opts.pocket.is_some() || !opts.inject.is_empty() {
|
||||
return Err(
|
||||
"--pocket, --ppa and --inject are not supported with --mode vm yet: \
|
||||
configure them inside the image or use a chroot/local run"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
view.message("Running autopkgtest with the qemu runner");
|
||||
|
||||
let mut command = Command::new("autopkgtest");
|
||||
command.arg("-o").arg(run_dir);
|
||||
let summary_path = run_dir.join("autopkgtest-summary");
|
||||
command.arg("--summary").arg(&summary_path);
|
||||
if opts.apt_upgrade {
|
||||
command.arg("--apt-upgrade");
|
||||
}
|
||||
for name in &opts.test_names {
|
||||
command.arg("--test-name").arg(name);
|
||||
}
|
||||
for name in &opts.skip_tests {
|
||||
command.arg("--skip-test").arg(name);
|
||||
}
|
||||
for setup in &opts.setup_commands {
|
||||
command.arg("--setup-commands").arg(setup);
|
||||
}
|
||||
if opts.shell {
|
||||
command.arg("--shell");
|
||||
}
|
||||
if opts.shell_fail {
|
||||
command.arg("--shell-fail");
|
||||
}
|
||||
if let Some(secs) = opts.timeout {
|
||||
command.arg("--timeout").arg(secs.to_string());
|
||||
}
|
||||
if let Some(factor) = opts.timeout_factor {
|
||||
command.arg("--timeout-factor").arg(factor.to_string());
|
||||
}
|
||||
for deb in binaries {
|
||||
// Absolute paths: autopkgtest reads a bare name as an archive
|
||||
// source package.
|
||||
command.arg(deb.canonicalize()?);
|
||||
}
|
||||
command.arg(tree.canonicalize()?);
|
||||
command.arg("--");
|
||||
command.arg("qemu");
|
||||
if let Some(cpus) = opts.cpus {
|
||||
command.arg("--cpus").arg(cpus.to_string());
|
||||
}
|
||||
if let Some(ram) = &opts.ram_size {
|
||||
command.arg("--ram-size").arg(ram);
|
||||
}
|
||||
command.arg(image);
|
||||
|
||||
// The autopkgtest process must die with us: run it in its own
|
||||
// process group and SIGTERM the group from the interrupt-time
|
||||
// cleanup hook (autopkgtest cleans up its testbed on TERM).
|
||||
let mut child = command
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("cannot run autopkgtest (is it installed?): {e}"))?;
|
||||
let pid = child.id();
|
||||
// Best-effort: without its own group, the cleanup kill below would
|
||||
// only reach the autopkgtest front-end, not its virt server.
|
||||
unsafe { libc::setpgid(pid as libc::pid_t, pid as libc::pid_t) };
|
||||
let hook = interrupt::register_cleanup_hook(Box::new(move || {
|
||||
// SAFETY: kill(2) to the spawned autopkgtest process group
|
||||
unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
|
||||
}));
|
||||
|
||||
let status = child.wait()?;
|
||||
drop(hook);
|
||||
let raw_code = status.code().unwrap_or(16);
|
||||
|
||||
// autopkgtest's own failure classes are runtime errors here; the
|
||||
// pass/fail/skip classes become the report.
|
||||
if matches!(raw_code, 12 | 14 | 16 | 20) {
|
||||
return Err(format!(
|
||||
"autopkgtest failed (exit {raw_code}): check the artifacts in '{}'",
|
||||
run_dir.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let outcomes = parse_summary(&summary_path, tests)?;
|
||||
Ok((outcomes, raw_code))
|
||||
}
|
||||
|
||||
/// Parse autopkgtest's `--summary` file: one line per test, `<testname>
|
||||
/// PASS|FAIL|SKIP|FLAKY [reason...]`. Tolerant by design — the format is
|
||||
/// stable but not formally versioned; unparseable lines are skipped, and
|
||||
/// a missing file yields no outcomes (the raw exit code carries the
|
||||
/// verdict).
|
||||
pub(crate) fn parse_summary(
|
||||
summary_path: &Path,
|
||||
tests: &[Test],
|
||||
) -> Result<Vec<TestOutcome>, Box<dyn std::error::Error>> {
|
||||
let mut outcomes = Vec::new();
|
||||
let Ok(content) = std::fs::read_to_string(summary_path) else {
|
||||
return Ok(outcomes);
|
||||
};
|
||||
for line in content.lines() {
|
||||
let mut tokens = line.splitn(3, ' ');
|
||||
let Some(name) = tokens.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some(verdict) = tokens.next() else {
|
||||
continue;
|
||||
};
|
||||
// Summary names carry the argid prefix ("<argid>-<testname>"); a
|
||||
// declared test matching by suffix wins, else the token stands.
|
||||
let name = name.to_string();
|
||||
let name = match tests
|
||||
.iter()
|
||||
.find(|test| name.ends_with(&test.name) || test.name == name)
|
||||
{
|
||||
Some(test) => test.name.clone(),
|
||||
None => name,
|
||||
};
|
||||
let reason = tokens.next().map(str::to_string);
|
||||
let verdict = match verdict.trim() {
|
||||
"PASS" => Verdict::Pass,
|
||||
"FAIL" => Verdict::Fail,
|
||||
"SKIP" => Verdict::Skip,
|
||||
"FLAKY" => Verdict::Flaky,
|
||||
_ => continue,
|
||||
};
|
||||
outcomes.push(TestOutcome {
|
||||
name,
|
||||
verdict,
|
||||
reason,
|
||||
duration: std::time::Duration::ZERO,
|
||||
});
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::debian::tests_control::Test;
|
||||
|
||||
fn test(name: &str) -> Test {
|
||||
Test {
|
||||
name: name.to_string(),
|
||||
script: Some(name.to_string()),
|
||||
command: None,
|
||||
depends: None,
|
||||
restrictions: Vec::new(),
|
||||
tests_directory: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The summary parser maps the four verdicts, keeps reasons and
|
||||
/// resolves argid-prefixed names back to the declared tests.
|
||||
#[test]
|
||||
fn summary_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("summary");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"hello-test-demo-smoke PASS\n\
|
||||
hello-test-demo-command1 FAIL exit code 1\n\
|
||||
container SKIP required isolation not available\n\
|
||||
flaky-one FLAKY passed on retry\n",
|
||||
)
|
||||
.unwrap();
|
||||
let tests = [test("smoke"), test("command1")];
|
||||
let outcomes = parse_summary(&path, &tests).unwrap();
|
||||
assert_eq!(outcomes.len(), 4);
|
||||
assert_eq!(outcomes[0].name, "smoke");
|
||||
assert_eq!(outcomes[0].verdict, Verdict::Pass);
|
||||
assert_eq!(outcomes[1].verdict, Verdict::Fail);
|
||||
assert_eq!(outcomes[1].reason.as_deref(), Some("exit code 1"));
|
||||
assert_eq!(outcomes[2].name, "container");
|
||||
assert_eq!(outcomes[2].verdict, Verdict::Skip);
|
||||
assert_eq!(outcomes[3].verdict, Verdict::Flaky);
|
||||
}
|
||||
|
||||
/// A missing summary file parses to no outcomes: the raw exit code
|
||||
/// carries the verdict.
|
||||
#[test]
|
||||
fn missing_summary_yields_no_outcomes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcomes = parse_summary(&dir.path().join("absent"), &[]).unwrap();
|
||||
assert!(outcomes.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user