1#!/usr/bin/env python3 2# Copyright 2021 The Chromium OS Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5# 6# Test runner for crosvm: 7# - Selects which tests to run based on local environment 8# - Can run some tests single-threaded 9# - Can run some tests using the VM provided by the builders. 10# - Can generate junit xml files for integration with sponge 11# 12# The crates and feature to test are configured in ./run_tests 13 14from typing import Iterable, List, Dict, Set, Optional, Union 15import argparse 16import enum 17import os 18import platform 19import subprocess 20import sys 21import re 22import xml.etree.ElementTree as ET 23import pathlib 24 25# Print debug info. Overriden by -v or -vv 26VERBOSE = False 27VERY_VERBOSE = False 28 29# Runs tests using the exec_file wrapper, which will run the test inside the 30# builders built-in VM. 31VM_TEST_RUNNER = ( 32 os.path.abspath("./ci/vm_tools/exec_binary_in_vm") + " --no-sync" 33) 34 35# Runs tests using QEMU user-space emulation. 36QEMU_TEST_RUNNER = ( 37 "qemu-aarch64-static -E LD_LIBRARY_PATH=/workspace/scratch/lib" 38) 39 40# Kill a test after 5 minutes to prevent frozen tests from running too long. 41TEST_TIMEOUT_SECS = 300 42 43 44class Requirements(enum.Enum): 45 # Test can only be built for aarch64. 46 AARCH64 = "aarch64" 47 48 # Test can only be built for x86_64. 49 X86_64 = "x86_64" 50 51 # Requires ChromeOS build environment. 52 CROS_BUILD = "cros_build" 53 54 # Test is disabled explicitly. 55 DISABLED = "disabled" 56 57 # Test needs to be executed with expanded privileges for device access and 58 # will be run inside a VM. 59 PRIVILEGED = "privileged" 60 61 # Test needs to run single-threaded 62 SINGLE_THREADED = "single_threaded" 63 64 # Separate workspaces that have dev-dependencies cannot be built from the 65 # crosvm workspace and need to be built separately. 66 # Note: Separate workspaces are built with no features enabled. 67 SEPARATE_WORKSPACE = "separate_workspace" 68 69 # Build, but do not run. 70 DO_NOT_RUN = "do_not_run" 71 72 73BUILD_TIME_REQUIREMENTS = [ 74 Requirements.AARCH64, 75 Requirements.X86_64, 76 Requirements.CROS_BUILD, 77 Requirements.DISABLED, 78] 79 80 81class CrateInfo(object): 82 """Informaton about whether a crate can be built or run on this host.""" 83 84 def __init__( 85 self, 86 name: str, 87 requirements: Set[Requirements], 88 capabilities: Set[Requirements], 89 ): 90 self.name = name 91 self.requirements = requirements 92 self.single_threaded = Requirements.SINGLE_THREADED in requirements 93 self.needs_privilege = Requirements.PRIVILEGED in requirements 94 95 build_reqs = requirements.intersection(BUILD_TIME_REQUIREMENTS) 96 self.can_build = all(req in capabilities for req in build_reqs) 97 98 self.can_run = ( 99 self.can_build 100 and ( 101 not self.needs_privilege 102 or Requirements.PRIVILEGED in capabilities 103 ) 104 and not Requirements.DO_NOT_RUN in self.requirements 105 ) 106 107 def __repr__(self): 108 return f"{self.name} {self.requirements}" 109 110 111def target_arch(): 112 """Returns architecture cargo is set up to build for.""" 113 if "CARGO_BUILD_TARGET" in os.environ: 114 target = os.environ["CARGO_BUILD_TARGET"] 115 return target.split("-")[0] 116 else: 117 return platform.machine() 118 119 120def get_test_runner_env(use_vm: bool): 121 """Sets the target.*.runner cargo setting to use the correct test runner.""" 122 env = os.environ.copy() 123 key = f"CARGO_TARGET_{target_arch().upper()}_UNKNOWN_LINUX_GNU_RUNNER" 124 if use_vm: 125 env[key] = VM_TEST_RUNNER 126 else: 127 if target_arch() == "aarch64": 128 env[key] = QEMU_TEST_RUNNER 129 else: 130 if key in env: 131 del env[key] 132 return env 133 134 135class TestResult(enum.Enum): 136 PASS = "Pass" 137 FAIL = "Fail" 138 SKIP = "Skip" 139 UNKNOWN = "Unknown" 140 141 142class CrateResults(object): 143 """Container for results of a single cargo test call.""" 144 145 def __init__(self, crate_name: str, success: bool, cargo_test_log: str): 146 self.crate_name = crate_name 147 self.success = success 148 self.cargo_test_log = cargo_test_log 149 150 # Parse "test test_name... ok|ignored|FAILED" messages from cargo log. 151 test_regex = re.compile(r"^test ([\w\/_\-\.:() ]+) \.\.\. (\w+)$") 152 self.tests: Dict[str, TestResult] = {} 153 for line in cargo_test_log.split(os.linesep): 154 match = test_regex.match(line) 155 if match: 156 name = match.group(1) 157 result = match.group(2) 158 if result == "ok": 159 self.tests[name] = TestResult.PASS 160 elif result == "ignored": 161 self.tests[name] = TestResult.SKIP 162 elif result == "FAILED": 163 self.tests[name] = TestResult.FAIL 164 else: 165 self.tests[name] = TestResult.UNKNOWN 166 167 def total(self): 168 return len(self.tests) 169 170 def count(self, result: TestResult): 171 return sum(r == result for r in self.tests.values()) 172 173 def to_junit(self): 174 testsuite = ET.Element( 175 "testsuite", 176 { 177 "name": self.crate_name, 178 "tests": str(self.total()), 179 "failures": str(self.count(TestResult.FAIL)), 180 }, 181 ) 182 for (test, result) in self.tests.items(): 183 testcase = ET.SubElement( 184 testsuite, "testcase", {"name": f"{self.crate_name} - ${test}"} 185 ) 186 if result == TestResult.SKIP: 187 ET.SubElement( 188 testcase, "skipped", {"message": "Disabled in rust code."} 189 ) 190 else: 191 testcase.set("status", "run") 192 if result == TestResult.FAIL: 193 failure = ET.SubElement( 194 testcase, "failure", {"message": "Test failed."} 195 ) 196 failure.text = self.cargo_test_log 197 198 return testsuite 199 200 201class RunResults(object): 202 """Container for results of the whole test run.""" 203 204 def __init__(self, crate_results: Iterable[CrateResults]): 205 self.crate_results = list(crate_results) 206 self.success: bool = ( 207 len(self.crate_results) > 0 and self.count(TestResult.FAIL) == 0 208 ) 209 210 def total(self): 211 return sum(r.total() for r in self.crate_results) 212 213 def count(self, result: TestResult): 214 return sum(r.count(result) for r in self.crate_results) 215 216 def to_junit(self): 217 testsuites = ET.Element("testsuites", {"name": "Cargo Tests"}) 218 for crate_result in self.crate_results: 219 testsuites.append(crate_result.to_junit()) 220 return testsuites 221 222 223def results_summary(results: Union[RunResults, CrateResults]): 224 """Returns a concise 'N passed, M failed' summary of `results`""" 225 num_pass = results.count(TestResult.PASS) 226 num_skip = results.count(TestResult.SKIP) 227 num_fail = results.count(TestResult.FAIL) 228 msg: List[str] = [] 229 if num_pass: 230 msg.append(f"{num_pass} passed") 231 if num_skip: 232 msg.append(f"{num_skip} skipped") 233 if num_fail: 234 msg.append(f"{num_fail} failed") 235 return ", ".join(msg) 236 237 238def cargo_build_process( 239 cwd: str = ".", crates: List[CrateInfo] = [], features: Set[str] = set() 240): 241 """Builds the main crosvm crate.""" 242 cmd = [ 243 "cargo", 244 "build", 245 "--color=never", 246 "--no-default-features", 247 "--features", 248 ",".join(features), 249 ] 250 251 for crate in sorted(crate.name for crate in crates): 252 cmd += ["-p", crate] 253 254 if VERY_VERBOSE: 255 print("CMD", " ".join(cmd)) 256 257 process = subprocess.run( 258 cmd, 259 cwd=cwd, 260 stdout=subprocess.PIPE, 261 stderr=subprocess.STDOUT, 262 text=True, 263 ) 264 if process.returncode != 0 or VERBOSE: 265 print() 266 print(process.stdout) 267 return process 268 269 270def cargo_test_process( 271 cwd: str, 272 crates: List[CrateInfo] = [], 273 features: Set[str] = set(), 274 run: bool = True, 275 single_threaded: bool = False, 276 use_vm: bool = False, 277 timeout: Optional[int] = None, 278): 279 """Creates the subprocess to run `cargo test`.""" 280 cmd = ["cargo", "test", "--color=never"] 281 if not run: 282 cmd += ["--no-run"] 283 if features: 284 cmd += ["--no-default-features", "--features", ",".join(features)] 285 286 # Skip doc tests as these cannot be run in the VM. 287 if use_vm: 288 cmd += ["--bins", "--tests"] 289 290 for crate in sorted(crate.name for crate in crates): 291 cmd += ["-p", crate] 292 293 cmd += ["--", "--color=never"] 294 if single_threaded: 295 cmd += ["--test-threads=1"] 296 env = get_test_runner_env(use_vm) 297 298 if VERY_VERBOSE: 299 print("ENV", env) 300 print("CMD", " ".join(cmd)) 301 302 process = subprocess.run( 303 cmd, 304 cwd=cwd, 305 env=env, 306 timeout=timeout, 307 stdout=subprocess.PIPE, 308 stderr=subprocess.STDOUT, 309 text=True, 310 ) 311 if process.returncode != 0 or VERBOSE: 312 print() 313 print(process.stdout) 314 return process 315 316 317def cargo_build_tests(crates: List[CrateInfo], features: Set[str]): 318 """Runs cargo test --no-run to build all listed `crates`.""" 319 separate_workspace_crates = [ 320 crate 321 for crate in crates 322 if Requirements.SEPARATE_WORKSPACE in crate.requirements 323 ] 324 workspace_crates = [ 325 crate 326 for crate in crates 327 if Requirements.SEPARATE_WORKSPACE not in crate.requirements 328 ] 329 330 print( 331 "Building workspace: ", 332 ", ".join(crate.name for crate in workspace_crates), 333 ) 334 build_process = cargo_build_process( 335 cwd=".", crates=workspace_crates, features=features 336 ) 337 if build_process.returncode != 0: 338 return False 339 test_process = cargo_test_process( 340 cwd=".", crates=workspace_crates, features=features, run=False 341 ) 342 if test_process.returncode != 0: 343 return False 344 345 for crate in separate_workspace_crates: 346 print("Building crate:", crate.name) 347 build_process = cargo_build_process(cwd=crate.name) 348 if build_process.returncode != 0: 349 return False 350 test_process = cargo_test_process(cwd=crate.name, run=False) 351 if test_process.returncode != 0: 352 return False 353 return True 354 355 356def cargo_test( 357 crates: List[CrateInfo], 358 features: Set[str], 359 single_threaded: bool = False, 360 use_vm: bool = False, 361) -> Iterable[CrateResults]: 362 """Runs cargo test for all listed `crates`.""" 363 for crate in crates: 364 msg = ["Testing crate", crate.name] 365 if use_vm: 366 msg.append("in vm") 367 if single_threaded: 368 msg.append("(single-threaded)") 369 if Requirements.SEPARATE_WORKSPACE in crate.requirements: 370 msg.append("(separate workspace)") 371 sys.stdout.write(f"{' '.join(msg)}... ") 372 sys.stdout.flush() 373 374 if Requirements.SEPARATE_WORKSPACE in crate.requirements: 375 process = cargo_test_process( 376 cwd=crate.name, 377 run=True, 378 single_threaded=single_threaded, 379 use_vm=use_vm, 380 timeout=TEST_TIMEOUT_SECS, 381 ) 382 else: 383 process = cargo_test_process( 384 cwd=".", 385 crates=[crate], 386 features=features, 387 run=True, 388 single_threaded=single_threaded, 389 use_vm=use_vm, 390 timeout=TEST_TIMEOUT_SECS, 391 ) 392 results = CrateResults( 393 crate.name, process.returncode == 0, process.stdout 394 ) 395 print(results_summary(results)) 396 yield results 397 398 399def execute_batched_by_parallelism( 400 crates: List[CrateInfo], features: Set[str], use_vm: bool 401) -> Iterable[CrateResults]: 402 """Batches tests by single-threaded and parallel, then executes them.""" 403 run_single = [crate for crate in crates if crate.single_threaded] 404 yield from cargo_test( 405 run_single, features, single_threaded=True, use_vm=use_vm 406 ) 407 408 run_parallel = [crate for crate in crates if not crate.single_threaded] 409 yield from cargo_test(run_parallel, features, use_vm=use_vm) 410 411 412def execute_batched_by_privilege( 413 crates: List[CrateInfo], features: Set[str], use_vm: bool 414) -> Iterable[CrateResults]: 415 """ 416 Batches tests by whether or not a test needs privileged access to run. 417 418 Non-privileged tests are run first. Privileged tests are executed in 419 a VM if use_vm is set. 420 """ 421 build_crates = [crate for crate in crates if crate.can_build] 422 if not cargo_build_tests(build_crates, features): 423 return [] 424 425 simple_crates = [ 426 crate for crate in crates if crate.can_run and not crate.needs_privilege 427 ] 428 yield from execute_batched_by_parallelism( 429 simple_crates, features, use_vm=False 430 ) 431 432 privileged_crates = [ 433 crate for crate in crates if crate.can_run and crate.needs_privilege 434 ] 435 if privileged_crates: 436 if use_vm: 437 subprocess.run("./ci/vm_tools/sync_deps", check=True) 438 yield from execute_batched_by_parallelism( 439 privileged_crates, features, use_vm=True 440 ) 441 else: 442 yield from execute_batched_by_parallelism( 443 privileged_crates, features, use_vm=False 444 ) 445 446 447def results_report( 448 feature_requirements: Dict[str, List[Requirements]], 449 crates: List[CrateInfo], 450 features: Set[str], 451 run_results: RunResults, 452): 453 """Prints a summary report of all test results.""" 454 print() 455 456 if len(run_results.crate_results) == 0: 457 print("Could not build tests.") 458 return 459 460 crates_not_built = [crate.name for crate in crates if not crate.can_build] 461 print(f"Crates not built: {', '.join(crates_not_built)}") 462 463 crates_not_run = [ 464 crate.name for crate in crates if crate.can_build and not crate.can_run 465 ] 466 print(f"Crates not tested: {', '.join(crates_not_run)}") 467 468 disabled_features: Set[str] = set(feature_requirements.keys()).difference( 469 features 470 ) 471 print(f"Disabled features: {', '.join(disabled_features)}") 472 473 print() 474 if not run_results.success: 475 for crate_results in run_results.crate_results: 476 if crate_results.success: 477 continue 478 print(f"Test failures in {crate_results.crate_name}:") 479 for (test, result) in crate_results.tests.items(): 480 if result == TestResult.FAIL: 481 print(f" {test}") 482 print() 483 print("Some tests failed:", results_summary(run_results)) 484 else: 485 print("All tests passed:", results_summary(run_results)) 486 487 488def execute_tests( 489 crate_requirements: Dict[str, List[Requirements]], 490 feature_requirements: Dict[str, List[Requirements]], 491 capabilities: Set[Requirements], 492 use_vm: bool, 493 junit_file: Optional[str] = None, 494): 495 print("Capabilities:", ", ".join(cap.value for cap in capabilities)) 496 497 # Select all features where capabilities meet the requirements 498 features = set( 499 feature 500 for (feature, requirements) in feature_requirements.items() 501 if all(r in capabilities for r in requirements) 502 ) 503 504 # Disable sandboxing for tests until our builders are set up to run with 505 # sandboxing. 506 features.add("default-no-sandbox") 507 print("Features:", ", ".join(features)) 508 509 crates = [ 510 CrateInfo(crate, set(requirements), capabilities) 511 for (crate, requirements) in crate_requirements.items() 512 ] 513 run_results = RunResults( 514 execute_batched_by_privilege(crates, features, use_vm) 515 ) 516 517 if junit_file: 518 pathlib.Path(junit_file).parent.mkdir(parents=True, exist_ok=True) 519 ET.ElementTree(run_results.to_junit()).write(junit_file) 520 521 results_report(feature_requirements, crates, features, run_results) 522 if not run_results.success: 523 exit(-1) 524 525 526DESCRIPTION = """\ 527Runs tests for crosvm based on the capabilities of the local host. 528 529This script can be run directly on a worksation to run a limited number of tests 530that can be built and run on a standard debian system. 531 532It can also be run via the CI builder: `./ci/builder --vm ./run_tests`. This 533will build all tests and runs tests that require special privileges inside the 534virtual machine provided by the builder. 535""" 536 537 538def main( 539 crate_requirements: Dict[str, List[Requirements]], 540 feature_requirements: Dict[str, List[Requirements]], 541): 542 parser = argparse.ArgumentParser(description=DESCRIPTION) 543 parser.add_argument( 544 "--verbose", 545 "-v", 546 action="store_true", 547 default=False, 548 help="Print all test output.", 549 ) 550 parser.add_argument( 551 "--very-verbose", 552 "-vv", 553 action="store_true", 554 default=False, 555 help="Print debug information and commands executed.", 556 ) 557 parser.add_argument( 558 "--run-privileged", 559 action="store_true", 560 default=False, 561 help="Enable tests that requires privileged access to the system.", 562 ) 563 parser.add_argument( 564 "--cros-build", 565 action="store_true", 566 default=False, 567 help=( 568 "Enables tests that require a ChromeOS build environment. " 569 "Can also be set by CROSVM_CROS_BUILD" 570 ), 571 ) 572 parser.add_argument( 573 "--use-vm", 574 action="store_true", 575 default=False, 576 help=( 577 "Enables privileged tests to run in a VM. " 578 "Can also be set by CROSVM_USE_VM" 579 ), 580 ) 581 parser.add_argument( 582 "--require-all", 583 action="store_true", 584 default=False, 585 help="Requires all tests to run, fail if tests would be disabled.", 586 ) 587 parser.add_argument( 588 "--junit-file", 589 default=None, 590 help="Path to file where to store junit xml results", 591 ) 592 args = parser.parse_args() 593 594 global VERBOSE, VERY_VERBOSE 595 VERBOSE = args.verbose or args.very_verbose # type: ignore 596 VERY_VERBOSE = args.very_verbose # type: ignore 597 598 use_vm = os.environ.get("CROSVM_USE_VM") != None or args.use_vm 599 cros_build = os.environ.get("CROSVM_CROS_BUILD") != None or args.cros_build 600 601 capabilities = set() 602 if target_arch() == "aarch64": 603 capabilities.add(Requirements.AARCH64) 604 elif target_arch() == "x86_64": 605 capabilities.add(Requirements.X86_64) 606 607 if cros_build: 608 capabilities.add(Requirements.CROS_BUILD) 609 610 if use_vm: 611 if not os.path.exists("/workspace/vm"): 612 print("--use-vm can only be used within the ./ci/builder's.") 613 exit(1) 614 capabilities.add(Requirements.PRIVILEGED) 615 616 if args.run_privileged: 617 capabilities.add(Requirements.PRIVILEGED) 618 619 if args.require_all and not Requirements.PRIVILEGED in capabilities: 620 print("--require-all needs to be run with --use-vm or --run-privileged") 621 exit(1) 622 623 execute_tests( 624 crate_requirements, 625 feature_requirements, 626 capabilities, 627 use_vm, 628 args.junit_file, 629 ) 630