1# 2# Copyright 2024, The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16from abc import ABC 17import argparse 18import functools 19import json 20import logging 21import os 22import pathlib 23import subprocess 24 25from build_context import BuildContext 26import metrics_agent 27import test_mapping_module_retriever 28import test_discovery_agent 29 30 31class OptimizedBuildTarget(ABC): 32 """A representation of an optimized build target. 33 34 This class will determine what targets to build given a given build_cotext and 35 will have a packaging function to generate any necessary output zips for the 36 build. 37 """ 38 39 _SOONG_UI_BASH_PATH = 'build/soong/soong_ui.bash' 40 _PREBUILT_SOONG_ZIP_PATH = 'prebuilts/build-tools/linux-x86/bin/soong_zip' 41 42 def __init__( 43 self, 44 target: str, 45 build_context: BuildContext, 46 args: argparse.Namespace, 47 test_infos, 48 ): 49 self.target = target 50 self.build_context = build_context 51 self.args = args 52 self.test_infos = test_infos 53 54 def get_build_targets(self) -> set[str]: 55 features = self.build_context.enabled_build_features 56 if self.get_enabled_flag() in features: 57 self.modules_to_build = self.get_build_targets_impl() 58 return self.modules_to_build 59 60 if self.target == 'general-tests': 61 self._report_info_metrics_silently('general-tests.zip') 62 self.modules_to_build = {self.target} 63 return {self.target} 64 65 def get_package_outputs_commands(self) -> list[list[str]]: 66 features = self.build_context.enabled_build_features 67 if self.get_enabled_flag() in features: 68 return self.get_package_outputs_commands_impl() 69 70 return [] 71 72 def get_package_outputs_commands_impl(self) -> list[list[str]]: 73 raise NotImplementedError( 74 'get_package_outputs_commands_impl not implemented in' 75 f' {type(self).__name__}' 76 ) 77 78 def get_enabled_flag(self): 79 raise NotImplementedError( 80 f'get_enabled_flag not implemented in {type(self).__name__}' 81 ) 82 83 def get_build_targets_impl(self) -> set[str]: 84 raise NotImplementedError( 85 f'get_build_targets_impl not implemented in {type(self).__name__}' 86 ) 87 88 def _generate_zip_options_for_items( 89 self, 90 prefix: str = '', 91 relative_root: str = '', 92 list_files: list[str] | None = None, 93 files: list[str] | None = None, 94 directories: list[str] | None = None, 95 ) -> list[str]: 96 if not list_files and not files and not directories: 97 raise RuntimeError( 98 f'No items specified to be added to zip! Prefix: {prefix}, Relative' 99 f' root: {relative_root}' 100 ) 101 command_segment = [] 102 # These are all soong_zip options so consult soong_zip --help for specifics. 103 if prefix: 104 command_segment.append('-P') 105 command_segment.append(prefix) 106 if relative_root: 107 command_segment.append('-C') 108 command_segment.append(relative_root) 109 if list_files: 110 for list_file in list_files: 111 command_segment.append('-l') 112 command_segment.append(list_file) 113 if files: 114 for file in files: 115 command_segment.append('-f') 116 command_segment.append(file) 117 if directories: 118 for directory in directories: 119 command_segment.append('-D') 120 command_segment.append(directory) 121 122 return command_segment 123 124 def _query_soong_vars( 125 self, src_top: pathlib.Path, soong_vars: list[str] 126 ) -> dict[str, str]: 127 process_result = subprocess.run( 128 args=[ 129 f'{src_top / self._SOONG_UI_BASH_PATH}', 130 '--dumpvars-mode', 131 f'--abs-vars={" ".join(soong_vars)}', 132 ], 133 env=os.environ, 134 check=False, 135 capture_output=True, 136 text=True, 137 ) 138 if not process_result.returncode == 0: 139 logging.error('soong dumpvars command failed! stderr:') 140 logging.error(process_result.stderr) 141 raise RuntimeError('Soong dumpvars failed! See log for stderr.') 142 143 if not process_result.stdout: 144 raise RuntimeError( 145 'Necessary soong variables ' + soong_vars + ' not found.' 146 ) 147 148 try: 149 return { 150 line.split('=')[0]: line.split('=')[1].strip("'") 151 for line in process_result.stdout.strip().split('\n') 152 } 153 except IndexError as e: 154 raise RuntimeError( 155 'Error parsing soong dumpvars output! See output here:' 156 f' {process_result.stdout}', 157 e, 158 ) 159 160 def _base_zip_command( 161 self, src_top: pathlib.Path, dist_dir: pathlib.Path, name: str 162 ) -> list[str]: 163 return [ 164 f'{src_top / self._PREBUILT_SOONG_ZIP_PATH }', 165 '-d', 166 '-o', 167 f'{dist_dir / name}', 168 ] 169 170 def _report_info_metrics_silently(self, artifact_name): 171 try: 172 metrics_agent_instance = metrics_agent.MetricsAgent.instance() 173 targets = self.get_build_targets_impl() 174 metrics_agent_instance.report_optimized_target(self.target) 175 metrics_agent_instance.add_target_artifact(self.target, artifact_name, 0, targets) 176 except Exception as e: 177 logging.error(f'error while silently reporting metrics: {e}') 178 179 180 181class NullOptimizer(OptimizedBuildTarget): 182 """No-op target optimizer. 183 184 This will simply build the same target it was given and do nothing for the 185 packaging step. 186 """ 187 188 def __init__(self, target): 189 self.target = target 190 191 def get_build_targets(self): 192 return {self.target} 193 194 def get_package_outputs_commands(self): 195 return [] 196 197 198class ChangeInfo: 199 200 def __init__(self, change_info_file_path): 201 try: 202 with open(change_info_file_path) as change_info_file: 203 change_info_contents = json.load(change_info_file) 204 except json.decoder.JSONDecodeError: 205 logging.error(f'Failed to load CHANGE_INFO: {change_info_file_path}') 206 raise 207 208 self._change_info_contents = change_info_contents 209 210 def get_changed_paths(self) -> set[str]: 211 changed_paths = set() 212 for change in self._change_info_contents['changes']: 213 project_path = change.get('projectPath') + '/' 214 215 for revision in change.get('revisions'): 216 for file_info in revision.get('fileInfos'): 217 file_path = file_info.get('path') 218 dir_path = os.path.dirname(file_path) 219 changed_paths.add(project_path + dir_path) 220 221 return changed_paths 222 223 def find_changed_files(self) -> set[str]: 224 changed_files = set() 225 226 for change in self._change_info_contents['changes']: 227 project_path = change.get('projectPath') + '/' 228 229 for revision in change.get('revisions'): 230 for file_info in revision.get('fileInfos'): 231 changed_files.add(project_path + file_info.get('path')) 232 233 return changed_files 234 235 236class GeneralTestsOptimizer(OptimizedBuildTarget): 237 """general-tests optimizer 238 239 This optimizer uses test discovery to build a list of modules that are needed by all tests configured for the build. These modules are then build and packaged by the optimizer in the same way as they are in a normal build. 240 """ 241 242 # List of modules that are built alongside general-tests as dependencies. 243 _REQUIRED_MODULES = frozenset([ 244 'cts-tradefed', 245 'vts-tradefed', 246 'compatibility-host-util', 247 ]) 248 249 def get_build_targets_impl(self) -> set[str]: 250 self._general_tests_outputs = self._get_general_tests_outputs() 251 test_modules = self._get_test_discovery_modules() 252 253 modules_to_build = set(self._REQUIRED_MODULES) 254 self._build_outputs = [] 255 for module in test_modules: 256 module_outputs = [output for output in self._general_tests_outputs if module in output] 257 if module_outputs: 258 modules_to_build.add(module) 259 self._build_outputs.extend(module_outputs) 260 261 return modules_to_build 262 263 def _get_general_tests_outputs(self) -> list[str]: 264 src_top = pathlib.Path(os.environ.get('TOP', os.getcwd())) 265 soong_vars = self._query_soong_vars( 266 src_top, 267 [ 268 'PRODUCT_OUT', 269 ], 270 ) 271 product_out = pathlib.Path(soong_vars.get('PRODUCT_OUT')) 272 with open(f'{product_out / "general-tests_files"}') as general_tests_list_file: 273 general_tests_list = general_tests_list_file.readlines() 274 with open(f'{product_out / "general-tests_host_files"}') as general_tests_list_file: 275 self._general_tests_host_outputs = general_tests_list_file.readlines() 276 with open(f'{product_out / "general-tests_target_files"}') as general_tests_list_file: 277 self._general_tests_target_outputs = general_tests_list_file.readlines() 278 return general_tests_list 279 280 281 def _get_test_discovery_modules(self) -> set[str]: 282 change_info = ChangeInfo(os.environ.get('CHANGE_INFO')) 283 change_paths = change_info.get_changed_paths() 284 test_modules = set() 285 for test_info in self.test_infos: 286 tf_command = self._build_tf_command(test_info, change_paths) 287 discovery_agent = test_discovery_agent.TestDiscoveryAgent(tradefed_args=tf_command, test_mapping_zip_path=os.environ.get('DIST_DIR')+'/test_mappings.zip') 288 modules, dependencies = discovery_agent.discover_test_mapping_test_modules() 289 for regex in modules: 290 test_modules.add(regex) 291 return test_modules 292 293 294 def _build_tf_command(self, test_info, change_paths) -> list[str]: 295 command = [test_info.command] 296 for extra_option in test_info.extra_options: 297 if not extra_option.get('key'): 298 continue 299 arg_key = '--' + extra_option.get('key') 300 if arg_key == '--build-id': 301 command.append(arg_key) 302 command.append(os.environ.get('BUILD_NUMBER')) 303 continue 304 if extra_option.get('values'): 305 for value in extra_option.get('values'): 306 command.append(arg_key) 307 command.append(value) 308 else: 309 command.append(arg_key) 310 if test_info.is_test_mapping: 311 for change_path in change_paths: 312 command.append('--test-mapping-path') 313 command.append(change_path) 314 315 return command 316 317 def get_package_outputs_commands_impl(self): 318 src_top = pathlib.Path(os.environ.get('TOP', os.getcwd())) 319 dist_dir = pathlib.Path(os.environ.get('DIST_DIR')) 320 tmp_dir = pathlib.Path(os.environ.get('TMPDIR')) 321 print(f'modules: {self.modules_to_build}') 322 323 host_outputs = [str(src_top) + '/' + file for file in self._general_tests_host_outputs if any('/'+module+'/' in file for module in self.modules_to_build)] 324 target_outputs = [str(src_top) + '/' + file for file in self._general_tests_target_outputs if any('/'+module+'/' in file for module in self.modules_to_build)] 325 host_config_files = [file for file in host_outputs if file.endswith('.config\n')] 326 target_config_files = [file for file in target_outputs if file.endswith('.config\n')] 327 logging.info(host_outputs) 328 logging.info(target_outputs) 329 with open(f"{tmp_dir / 'host.list'}", 'w') as host_list_file: 330 for output in host_outputs: 331 host_list_file.write(output) 332 with open(f"{tmp_dir / 'target.list'}", 'w') as target_list_file: 333 for output in target_outputs: 334 target_list_file.write(output) 335 soong_vars = self._query_soong_vars( 336 src_top, 337 [ 338 'PRODUCT_OUT', 339 'SOONG_HOST_OUT', 340 'HOST_OUT', 341 ], 342 ) 343 product_out = pathlib.Path(soong_vars.get('PRODUCT_OUT')) 344 soong_host_out = pathlib.Path(soong_vars.get('SOONG_HOST_OUT')) 345 host_out = pathlib.Path(soong_vars.get('HOST_OUT')) 346 zip_commands = [] 347 348 zip_commands.extend( 349 self._get_zip_test_configs_zips_commands( 350 src_top, 351 dist_dir, 352 host_out, 353 product_out, 354 host_config_files, 355 target_config_files, 356 ) 357 ) 358 359 zip_command = self._base_zip_command(src_top, dist_dir, 'general-tests.zip') 360 # Add host testcases. 361 if host_outputs: 362 zip_command.extend( 363 self._generate_zip_options_for_items( 364 prefix='host', 365 relative_root=str(host_out), 366 list_files=[f"{tmp_dir / 'host.list'}"], 367 ) 368 ) 369 370 # Add target testcases. 371 if target_outputs: 372 zip_command.extend( 373 self._generate_zip_options_for_items( 374 prefix='target', 375 relative_root=str(product_out), 376 list_files=[f"{tmp_dir / 'target.list'}"], 377 ) 378 ) 379 380 # TODO(lucafarsi): Push this logic into a general-tests-minimal build command 381 # Add necessary tools. These are also hardcoded in general-tests.mk. 382 framework_path = soong_host_out / 'framework' 383 384 zip_command.extend( 385 self._generate_zip_options_for_items( 386 prefix='host/tools', 387 relative_root=str(framework_path), 388 files=[ 389 f"{framework_path / 'cts-tradefed.jar'}", 390 f"{framework_path / 'compatibility-host-util.jar'}", 391 f"{framework_path / 'vts-tradefed.jar'}", 392 ], 393 ) 394 ) 395 396 zip_command.append('-sha256') 397 398 zip_commands.append(zip_command) 399 return zip_commands 400 401 def _get_zip_test_configs_zips_commands( 402 self, 403 src_top: pathlib.Path, 404 dist_dir: pathlib.Path, 405 host_out: pathlib.Path, 406 product_out: pathlib.Path, 407 host_config_files: list[str], 408 target_config_files: list[str], 409 ) -> tuple[list[str], list[str]]: 410 """Generate general-tests_configs.zip and general-tests_list.zip. 411 412 general-tests_configs.zip contains all of the .config files that were 413 built and general-tests_list.zip contains a text file which lists 414 all of the .config files that are in general-tests_configs.zip. 415 416 general-tests_configs.zip is organized as follows: 417 / 418 host/ 419 testcases/ 420 test_1.config 421 test_2.config 422 ... 423 target/ 424 testcases/ 425 test_1.config 426 test_2.config 427 ... 428 429 So the process is we write out the paths to all the host config files into 430 one 431 file and all the paths to the target config files in another. We also write 432 the paths to all the config files into a third file to use for 433 general-tests_list.zip. 434 435 Args: 436 dist_dir: dist directory. 437 host_out: host out directory. 438 product_out: product out directory. 439 host_config_files: list of all host config files. 440 target_config_files: list of all target config files. 441 442 Returns: 443 The commands to generate general-tests_configs.zip and 444 general-tests_list.zip 445 """ 446 with open( 447 f"{host_out / 'host_general-tests_list'}", 'w' 448 ) as host_list_file, open( 449 f"{product_out / 'target_general-tests_list'}", 'w' 450 ) as target_list_file, open( 451 f"{host_out / 'general-tests_list'}", 'w' 452 ) as list_file: 453 454 for config_file in host_config_files: 455 host_list_file.write(f'{config_file}' + '\n') 456 list_file.write('host/' + os.path.relpath(config_file, host_out) + '\n') 457 458 for config_file in target_config_files: 459 target_list_file.write(f'{config_file}' + '\n') 460 list_file.write( 461 'target/' + os.path.relpath(config_file, product_out) + '\n' 462 ) 463 464 zip_commands = [] 465 466 tests_config_zip_command = self._base_zip_command( 467 src_top, dist_dir, 'general-tests_configs.zip' 468 ) 469 tests_config_zip_command.extend( 470 self._generate_zip_options_for_items( 471 prefix='host', 472 relative_root=str(host_out), 473 list_files=[f"{host_out / 'host_general-tests_list'}"], 474 ) 475 ) 476 477 tests_config_zip_command.extend( 478 self._generate_zip_options_for_items( 479 prefix='target', 480 relative_root=str(product_out), 481 list_files=[f"{product_out / 'target_general-tests_list'}"], 482 ), 483 ) 484 485 zip_commands.append(tests_config_zip_command) 486 487 tests_list_zip_command = self._base_zip_command( 488 src_top, dist_dir, 'general-tests_list.zip' 489 ) 490 tests_list_zip_command.extend( 491 self._generate_zip_options_for_items( 492 relative_root=str(host_out), 493 files=[f"{host_out / 'general-tests_list'}"], 494 ) 495 ) 496 zip_commands.append(tests_list_zip_command) 497 498 return zip_commands 499 500 def get_enabled_flag(self): 501 return 'general_tests_optimized' 502 503 @classmethod 504 def get_optimized_targets(cls) -> dict[str, OptimizedBuildTarget]: 505 return {'general-tests': functools.partial(cls)} 506 507 508OPTIMIZED_BUILD_TARGETS = {} 509OPTIMIZED_BUILD_TARGETS.update(GeneralTestsOptimizer.get_optimized_targets()) 510