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