• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2# Copyright 2023 The ChromiumOS Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import json
8from multiprocessing.pool import ThreadPool
9import shlex
10import shutil
11from fnmatch import fnmatch
12from pathlib import Path
13from typing import Any, List, Tuple
14from impl.common import (
15    CROSVM_ROOT,
16    all_tracked_files,
17    chdir,
18    cmd,
19    cwd_context,
20    parallel,
21    print_timing_info,
22    quoted,
23    record_time,
24)
25
26# List of globs matching files in the source tree required by tests at runtime.
27# This is hard-coded specifically for crosvm tests.
28TEST_DATA_FILES = [
29    # Requried by nextest to obtain metadata
30    "*.toml",
31    # Configured by .cargo/config.toml to execute tests with the right emulator
32    ".cargo/runner.py",
33    # Requried by plugin tests
34    "crosvm_plugin/crosvm.h",
35    "tests/plugin.policy",
36]
37
38TEST_DATA_EXCLUDE = [
39    # config.toml is configured for x86 hosts. We cannot use that for remote tests.
40    ".cargo/config.toml",
41]
42
43cargo = cmd("cargo")
44tar = cmd("tar")
45rust_strip = cmd("rust-strip")
46
47
48def collect_rust_libs():
49    "Collect rust shared libraries required by the tests at runtime."
50    lib_dir = Path(cmd("rustc --print=sysroot").stdout()) / "lib"
51    for lib_file in lib_dir.glob("libstd-*"):
52        yield (lib_file, Path("debug/deps") / lib_file.name)
53    for lib_file in lib_dir.glob("libtest-*"):
54        yield (lib_file, Path("debug/deps") / lib_file.name)
55
56
57def collect_test_binaries(metadata: Any, strip: bool):
58    "Collect all test binaries that are needed to run the tests."
59    target_dir = Path(metadata["rust-build-meta"]["target-directory"])
60    test_binaries = [
61        Path(suite["binary-path"]).relative_to(target_dir)
62        for suite in metadata["rust-binaries"].values()
63    ]
64
65    non_test_binaries = [
66        Path(binary["path"])
67        for crate in metadata["rust-build-meta"]["non-test-binaries"].values()
68        for binary in crate
69    ]
70
71    def process_binary(binary_path: Path):
72        source_path = target_dir / binary_path
73        destination_path = binary_path
74        if strip:
75            stripped_path = source_path.with_suffix(".stripped")
76            if (
77                not stripped_path.exists()
78                or source_path.stat().st_ctime > stripped_path.stat().st_ctime
79            ):
80                rust_strip(f"--strip-all {source_path} -o {stripped_path}").fg()
81            return (stripped_path, destination_path)
82        else:
83            return (source_path, destination_path)
84
85    # Parallelize rust_strip calls.
86    pool = ThreadPool()
87    return pool.map(process_binary, test_binaries + non_test_binaries)
88
89
90def collect_test_data_files():
91    "List additional files from the source tree that are required by tests at runtime."
92    for file in all_tracked_files():
93        for glob in TEST_DATA_FILES:
94            if fnmatch(str(file), glob):
95                if str(file) not in TEST_DATA_EXCLUDE:
96                    yield (file, file)
97                break
98
99
100def collect_files(metadata: Any, output_directory: Path, strip_binaries: bool):
101    # List all files we need as (source path, path in output_directory) tuples
102    manifest: List[Tuple[Path, Path]] = [
103        *collect_test_binaries(metadata, strip=strip_binaries),
104        *collect_rust_libs(),
105        *collect_test_data_files(),
106    ]
107
108    # Create all target directories
109    for folder in set((output_directory / d).parent.resolve() for _, d in manifest):
110        folder.mkdir(exist_ok=True, parents=True)
111
112    # Use multiple processes to copy the files (and only if they are newer than existing ones)
113    parallel(
114        *(cmd("cp -u", source, output_directory / destination) for source, destination in manifest)
115    ).fg()
116
117
118def generate_run_script(metadata: Any, output_directory: Path):
119    # Generate metadata files for nextest
120    binares_metadata_file = "binaries-metadata.json"
121    (output_directory / binares_metadata_file).write_text(json.dumps(metadata))
122    cargo_metadata_file = "cargo-metadata.json"
123    cargo("metadata --format-version 1").write_to(output_directory / cargo_metadata_file)
124
125    # Put together command line to run nextest
126    run_cmd = [
127        "cargo-nextest",
128        "nextest",
129        "run",
130        f"--binaries-metadata={binares_metadata_file}",
131        f"--cargo-metadata={cargo_metadata_file}",
132        "--target-dir-remap=.",
133        "--workspace-remap=.",
134    ]
135    command_line = [
136        "#!/usr/bin/env bash",
137        'cd "$(dirname "${BASH_SOURCE[0]}")" || die',
138        f'{shlex.join(run_cmd)} "$@"',
139    ]
140
141    # Write command to a unix shell script
142    shell_script = output_directory / "run.sh"
143    shell_script.write_text("\n".join(command_line))
144    shell_script.chmod(0o755)
145
146    # TODO(denniskempin): Add an equivalent windows bash script
147
148
149def generate_archive(output_directory: Path, output_archive: Path):
150    with cwd_context(output_directory.parent):
151        tar("-ca", output_directory.name, "-f", output_archive).fg()
152
153
154def main():
155    """
156    Builds a package to execute tests remotely.
157
158    ## Basic usage
159
160    ```
161    $ tools/nextest_package -o foo.tar.zst ... nextest args
162    ```
163
164    The archive will contain all necessary test binaries, required shared libraries and test data
165    files required at runtime.
166    A cargo nextest binary is included along with a `run.sh` script to invoke it with the required
167    arguments. THe archive can be copied anywhere and executed:
168
169    ```
170    $ tar xaf foo.tar.zst && cd foo.tar.d && ./run.sh
171    ```
172
173    ## Nextest Arguments
174
175    All additional arguments will be passed to `nextest list`. Additional arguments to `nextest run`
176    can be passed to the `run.sh` invocation.
177
178    For example:
179
180    ```
181    $ tools/nextest_package -d foo --tests
182    $ cd foo && ./run.sh --test-threads=1
183    ```
184
185    Will only list and package integration tests (--tests) and run them with --test-threads=1.
186
187    ## Stripping Symbols
188
189    Debug symbols are stripped by default to reduce the package size. This can be disabled via
190    the `--no-strip` argument.
191
192    """
193    parser = argparse.ArgumentParser()
194    parser.add_argument("--no-strip", action="store_true")
195    parser.add_argument("--output-directory", "-d")
196    parser.add_argument("--output-archive", "-o")
197    parser.add_argument("--clean", action="store_true")
198    parser.add_argument("--timing-info", action="store_true")
199    (args, nextest_list_args) = parser.parse_known_args()
200    chdir(CROSVM_ROOT)
201
202    # Determine output archive / directory
203    output_directory = Path(args.output_directory).resolve() if args.output_directory else None
204    output_archive = Path(args.output_archive).resolve() if args.output_archive else None
205    if not output_directory and output_archive:
206        output_directory = output_archive.with_suffix(".d")
207    if not output_directory:
208        print("Must specify either --output-directory or --output-archive")
209        return
210
211    if args.clean and output_directory.exists():
212        shutil.rmtree(output_directory)
213
214    with record_time("Listing tests"):
215        cargo(
216            "nextest list",
217            *(quoted(a) for a in nextest_list_args),
218        ).fg()
219    with record_time("Listing tests metadata"):
220        metadata = cargo(
221            "nextest list --list-type binaries-only --message-format json",
222            *(quoted(a) for a in nextest_list_args),
223        ).json()
224
225    with record_time("Collecting files"):
226        collect_files(metadata, output_directory, strip_binaries=not args.no_strip)
227        generate_run_script(metadata, output_directory)
228
229    if output_archive:
230        with record_time("Generating archive"):
231            generate_archive(output_directory, output_archive)
232
233    if args.timing_info:
234        print_timing_info()
235
236
237if __name__ == "__main__":
238    main()
239