blob: eb7ec87128824a8f3b5ef5e570ca9b98aae9c367 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2021 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
# Test runner for crosvm:
# - Selects which tests to run based on local environment
# - Can run some tests single-threaded
# - Can run some tests using the VM provided by the builders.
# - Can generate junit xml files for integration with sponge
#
# The crates and feature to test are configured in ./run_tests
from typing import Iterable, List, Dict, Set, Optional, Union
import argparse
import enum
import os
import platform
import subprocess
import sys
import re
import xml.etree.ElementTree as ET
import pathlib
# Print debug info. Overriden by -v or -vv
VERBOSE = False
VERY_VERBOSE = False
# Runs tests using the exec_file wrapper, which will run the test inside the
# builders built-in VM.
VM_TEST_RUNNER = "/workspace/vm/exec_file --no-sync"
# Runs tests using QEMU user-space emulation.
QEMU_TEST_RUNNER = (
"qemu-aarch64-static -E LD_LIBRARY_PATH=/workspace/scratch/lib"
)
# Kill a test after 5 minutes to prevent frozen tests from running too long.
TEST_TIMEOUT_SECS = 300
class Requirements(enum.Enum):
# Test can only be built for aarch64.
AARCH64 = "aarch64"
# Test can only be built for x86_64.
X86_64 = "x86_64"
# Requires ChromeOS build environment.
CROS_BUILD = "cros_build"
# Test is disabled explicitly.
DISABLED = "disabled"
# Test needs to be executed with expanded privileges for device access.
PRIVILEGED = "privileged"
# Test needs to run single-threaded
SINGLE_THREADED = "single_threaded"
BUILD_TIME_REQUIREMENTS = [
Requirements.AARCH64,
Requirements.X86_64,
Requirements.CROS_BUILD,
Requirements.DISABLED,
]
class CrateInfo(object):
"""Informaton about whether a crate can be built or run on this host."""
def __init__(
self,
name: str,
requirements: Set[Requirements],
capabilities: Set[Requirements],
):
self.name = name
self.requirements = requirements
self.single_threaded = Requirements.SINGLE_THREADED in requirements
self.needs_privilege = Requirements.PRIVILEGED in requirements
build_reqs = requirements.intersection(BUILD_TIME_REQUIREMENTS)
self.can_build = all(req in capabilities for req in build_reqs)
self.can_run = self.can_build and (
not self.needs_privilege or Requirements.PRIVILEGED in capabilities
)
def __repr__(self):
return f"{self.name} {self.requirements}"
def target_arch():
"""Returns architecture cargo is set up to build for."""
if "CARGO_BUILD_TARGET" in os.environ:
target = os.environ["CARGO_BUILD_TARGET"]
return target.split("-")[0]
else:
return platform.machine()
def get_test_runner_env(use_vm: bool):
"""Sets the target.*.runner cargo setting to use the correct test runner."""
env = os.environ.copy()
key = f"CARGO_TARGET_{target_arch().upper()}_UNKNOWN_LINUX_GNU_RUNNER"
if use_vm:
env[key] = VM_TEST_RUNNER
else:
if target_arch() == "aarch64":
env[key] = QEMU_TEST_RUNNER
else:
if key in env:
del env[key]
return env
class TestResult(enum.Enum):
PASS = "Pass"
FAIL = "Fail"
SKIP = "Skip"
UNKNOWN = "Unknown"
class CrateResults(object):
"""Container for results of a single cargo test call."""
def __init__(self, crate_name: str, success: bool, cargo_test_log: str):
self.crate_name = crate_name
self.success = success
self.cargo_test_log = cargo_test_log
# Parse "test test_name... ok|ignored|FAILED" messages from cargo log.
test_regex = re.compile(r"^test ([\w\/_\-\.:() ]+) \.\.\. (\w+)$")
self.tests: Dict[str, TestResult] = {}
for line in cargo_test_log.split(os.linesep):
match = test_regex.match(line)
if match:
name = match.group(1)
result = match.group(2)
if result == "ok":
self.tests[name] = TestResult.PASS
elif result == "ignored":
self.tests[name] = TestResult.SKIP
elif result == "FAILED":
self.tests[name] = TestResult.FAIL
else:
self.tests[name] = TestResult.UNKNOWN
def total(self):
return len(self.tests)
def count(self, result: TestResult):
return sum(r == result for r in self.tests.values())
def to_junit(self):
testsuite = ET.Element(
"testsuite",
{
"name": self.crate_name,
"tests": str(self.total()),
"failures": str(self.count(TestResult.FAIL)),
},
)
for (test, result) in self.tests.items():
testcase = ET.SubElement(
testsuite, "testcase", {"name": f"{self.crate_name} - ${test}"}
)
if result == TestResult.SKIP:
ET.SubElement(
testcase, "skipped", {"message": "Disabled in rust code."}
)
else:
testcase.set("status", "run")
if result == TestResult.FAIL:
failure = ET.SubElement(
testcase, "failure", {"message": "Test failed."}
)
failure.text = self.cargo_test_log
return testsuite
class RunResults(object):
"""Container for results of the whole test run."""
def __init__(self, crate_results: Iterable[CrateResults]):
self.crate_results = list(crate_results)
self.success: bool = (
len(self.crate_results) > 0 and self.count(TestResult.FAIL) == 0
)
def total(self):
return sum(r.total() for r in self.crate_results)
def count(self, result: TestResult):
return sum(r.count(result) for r in self.crate_results)
def to_junit(self):
testsuites = ET.Element("testsuites", {"name": "Cargo Tests"})
for crate_result in self.crate_results:
testsuites.append(crate_result.to_junit())
return testsuites
def results_summary(results: Union[RunResults, CrateResults]):
"""Returns a concise 'N passed, M failed' summary of `results`"""
num_pass = results.count(TestResult.PASS)
num_skip = results.count(TestResult.SKIP)
num_fail = results.count(TestResult.FAIL)
msg = []
if num_pass:
msg.append(f"{num_pass} passed")
if num_skip:
msg.append(f"{num_skip} skipped")
if num_fail:
msg.append(f"{num_fail} failed")
return ", ".join(msg)
def cargo_test_process(
crates: List[CrateInfo],
features: Set[str],
run: bool = True,
single_threaded: bool = False,
use_vm: bool = False,
timeout: Optional[int] = None,
):
"""Creates the subprocess to run `cargo test`."""
cmd = ["cargo", "test", "--color=never"]
if not run:
cmd += ["--no-run"]
if features:
cmd += ["--no-default-features", "--features", ",".join(features)]
for crate in sorted(crate.name for crate in crates):
cmd += ["-p", crate]
cmd += ["--", "--color=never"]
if single_threaded:
cmd += ["--test-threads=1"]
env = get_test_runner_env(use_vm)
if VERY_VERBOSE:
print("ENV", env)
print("CMD", " ".join(cmd))
process = subprocess.run(
cmd,
env=env,
timeout=timeout,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if process.returncode != 0 or VERBOSE:
print()
print(process.stdout)
return process
def cargo_build_tests(crates: List[CrateInfo], features: Set[str]):
"""Runs cargo test --no-run to build all listed `crates`."""
print("Building: ", ", ".join(crate.name for crate in crates))
process = cargo_test_process(crates, features, run=False)
return process.returncode == 0
def cargo_test(
crates: List[CrateInfo],
features: Set[str],
single_threaded: bool = False,
use_vm: bool = False,
) -> Iterable[CrateResults]:
"""Runs cargo test for all listed `crates`."""
for crate in crates:
msg = ["Testing crate", crate.name]
if use_vm:
msg.append("in vm")
if single_threaded:
msg.append("(single-threaded)")
sys.stdout.write(f"{' '.join(msg)}... ")
sys.stdout.flush()
process = cargo_test_process(
[crate],
features,
run=True,
single_threaded=single_threaded,
use_vm=use_vm,
timeout=TEST_TIMEOUT_SECS,
)
results = CrateResults(
crate.name, process.returncode == 0, process.stdout
)
print(results_summary(results))
yield results
def execute_batched_by_parallelism(
crates: List[CrateInfo], features: Set[str], use_vm: bool
) -> Iterable[CrateResults]:
"""Batches tests by single-threaded and parallel, then executes them."""
run_single = [crate for crate in crates if crate.single_threaded]
yield from cargo_test(
run_single, features, single_threaded=True, use_vm=use_vm
)
run_parallel = [crate for crate in crates if not crate.single_threaded]
yield from cargo_test(run_parallel, features, use_vm=use_vm)
def execute_batched_by_privilege(
crates: List[CrateInfo], features: Set[str], use_vm: bool
) -> Iterable[CrateResults]:
"""
Batches tests by whether or not a test needs privileged access to run.
Non-privileged tests are run first. Privileged tests are executed in
a VM if use_vm is set.
"""
build_crates = [crate for crate in crates if crate.can_build]
if not cargo_build_tests(build_crates, features):
return []
simple_crates = [
crate for crate in crates if crate.can_run and not crate.needs_privilege
]
yield from execute_batched_by_parallelism(
simple_crates, features, use_vm=False
)
privileged_crates = [
crate for crate in crates if crate.can_run and crate.needs_privilege
]
if privileged_crates:
if use_vm:
subprocess.run("/workspace/vm/sync_so", check=True)
yield from execute_batched_by_parallelism(
privileged_crates, features, use_vm=True
)
else:
yield from execute_batched_by_parallelism(
privileged_crates, features, use_vm=False
)
def results_report(
feature_requirements: Dict[str, List[Requirements]],
crates: List[CrateInfo],
features: Set[str],
run_results: RunResults,
):
"""Prints a summary report of all test results."""
print()
if len(run_results.crate_results) == 0:
print("Could not build tests.")
return
crates_not_built = [crate.name for crate in crates if not crate.can_build]
print(f"Crates not built: {', '.join(crates_not_built)}")
crates_not_run = [
crate.name for crate in crates if crate.can_build and not crate.can_run
]
print(f"Crates not tested: {', '.join(crates_not_run)}")
disabled_features: Set[str] = set(feature_requirements.keys()).difference(
features
)
print(f"Disabled features: {', '.join(disabled_features)}")
print()
if not run_results.success:
for crate_results in run_results.crate_results:
if crate_results.success:
continue
print(f"Test failures in {crate_results.crate_name}:")
for (test, result) in crate_results.tests.items():
if result == TestResult.FAIL:
print(f" {test}")
print()
print("Some tests failed:", results_summary(run_results))
else:
print("All tests passed:", results_summary(run_results))
def execute_tests(
crate_requirements: Dict[str, List[Requirements]],
feature_requirements: Dict[str, List[Requirements]],
capabilities: Set[Requirements],
use_vm: bool,
junit_file: Optional[str] = None,
):
print("Capabilities:", ", ".join(cap.value for cap in capabilities))
# Select all features where capabilities meet the requirements
features = set(
feature
for (feature, requirements) in feature_requirements.items()
if all(r in capabilities for r in requirements)
)
# Disable sandboxing for tests until our builders are set up to run with
# sandboxing.
features.add("default-no-sandbox")
print("Features:", ", ".join(features))
crates = [
CrateInfo(crate, set(requirements), capabilities)
for (crate, requirements) in crate_requirements.items()
]
run_results = RunResults(
execute_batched_by_privilege(crates, features, use_vm)
)
if junit_file:
pathlib.Path(junit_file).parent.mkdir(parents=True, exist_ok=True)
ET.ElementTree(run_results.to_junit()).write(junit_file)
results_report(feature_requirements, crates, features, run_results)
if not run_results.success:
exit(-1)
DESCRIPTION = """\
Runs tests for crosvm based on the capabilities of the local host.
This script can be run directly on a worksation to run a limited number of tests
that can be built and run on a standard debian system.
It can also be run via the CI builder: `./ci/builder --vm ./run_tests`. This
will build all tests and runs tests that require special privileges inside the
virtual machine provided by the builder.
"""
def main(
crate_requirements: Dict[str, List[Requirements]],
feature_requirements: Dict[str, List[Requirements]],
):
parser = argparse.ArgumentParser(description=DESCRIPTION)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
default=False,
help="Print all test output.",
)
parser.add_argument(
"--very-verbose",
"-vv",
action="store_true",
default=False,
help="Print debug information and commands executed.",
)
parser.add_argument(
"--run-privileged",
action="store_true",
default=False,
help="Enable tests that requires privileged access to the system.",
)
parser.add_argument(
"--cros-build",
action="store_true",
default=False,
help=(
"Enables tests that require a ChromeOS build environment. "
"Can also be set by CROSVM_CROS_BUILD"
),
)
parser.add_argument(
"--use-vm",
action="store_true",
default=False,
help=(
"Enables privileged tests to run in a VM. "
"Can also be set by CROSVM_USE_VM"
),
)
parser.add_argument(
"--require-all",
action="store_true",
default=False,
help="Requires all tests to run, fail if tests would be disabled.",
)
parser.add_argument(
"--junit-file",
default=None,
help="Path to file where to store junit xml results",
)
args = parser.parse_args()
VERBOSE = args.verbose or args.very_verbose # type: ignore
VERY_VERBOSE = args.very_verbose # type: ignore
use_vm = os.environ.get("CROSVM_USE_VM") != None or args.use_vm
cros_build = os.environ.get("CROSVM_CROS_BUILD") != None or args.cros_build
capabilities = set()
if target_arch() == "aarch64":
capabilities.add(Requirements.AARCH64)
elif target_arch() == "x86_64":
capabilities.add(Requirements.X86_64)
if cros_build:
capabilities.add(Requirements.CROS_BUILD)
if use_vm:
if not os.path.exists("/workspace/vm"):
print("--use-vm can only be used within the ./ci/builder's.")
exit(1)
capabilities.add(Requirements.PRIVILEGED)
if args.run_privileged:
capabilities.add(Requirements.PRIVILEGED)
if args.require_all and not Requirements.PRIVILEGED in capabilities:
print("--require-all needs to be run with --use-vm or --run-privileged")
exit(1)
execute_tests(
crate_requirements,
feature_requirements,
capabilities,
use_vm,
args.junit_file,
)