• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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