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 test_mapping_module_retriever 27 28 29class OptimizedBuildTarget(ABC): 30 """A representation of an optimized build target. 31 32 This class will determine what targets to build given a given build_cotext and 33 will have a packaging function to generate any necessary output zips for the 34 build. 35 """ 36 37 _SOONG_UI_BASH_PATH = 'build/soong/soong_ui.bash' 38 _PREBUILT_SOONG_ZIP_PATH = 'prebuilts/build-tools/linux-x86/bin/soong_zip' 39 40 def __init__( 41 self, 42 target: str, 43 build_context: BuildContext, 44 args: argparse.Namespace, 45 ): 46 self.target = target 47 self.build_context = build_context 48 self.args = args 49 50 def get_build_targets(self) -> set[str]: 51 features = self.build_context.enabled_build_features 52 if self.get_enabled_flag() in features: 53 self.modules_to_build = self.get_build_targets_impl() 54 return self.modules_to_build 55 56 self.modules_to_build = {self.target} 57 return {self.target} 58 59 def get_package_outputs_commands(self) -> list[list[str]]: 60 features = self.build_context.enabled_build_features 61 if self.get_enabled_flag() in features: 62 return self.get_package_outputs_commands_impl() 63 64 return [] 65 66 def get_package_outputs_commands_impl(self) -> list[list[str]]: 67 raise NotImplementedError( 68 'get_package_outputs_commands_impl not implemented in' 69 f' {type(self).__name__}' 70 ) 71 72 def get_enabled_flag(self): 73 raise NotImplementedError( 74 f'get_enabled_flag not implemented in {type(self).__name__}' 75 ) 76 77 def get_build_targets_impl(self) -> set[str]: 78 raise NotImplementedError( 79 f'get_build_targets_impl not implemented in {type(self).__name__}' 80 ) 81 82 def _generate_zip_options_for_items( 83 self, 84 prefix: str = '', 85 relative_root: str = '', 86 list_files: list[str] | None = None, 87 files: list[str] | None = None, 88 directories: list[str] | None = None, 89 ) -> list[str]: 90 if not list_files and not files and not directories: 91 raise RuntimeError( 92 f'No items specified to be added to zip! Prefix: {prefix}, Relative' 93 f' root: {relative_root}' 94 ) 95 command_segment = [] 96 # These are all soong_zip options so consult soong_zip --help for specifics. 97 if prefix: 98 command_segment.append('-P') 99 command_segment.append(prefix) 100 if relative_root: 101 command_segment.append('-C') 102 command_segment.append(relative_root) 103 if list_files: 104 for list_file in list_files: 105 command_segment.append('-l') 106 command_segment.append(list_file) 107 if files: 108 for file in files: 109 command_segment.append('-f') 110 command_segment.append(file) 111 if directories: 112 for directory in directories: 113 command_segment.append('-D') 114 command_segment.append(directory) 115 116 return command_segment 117 118 def _query_soong_vars( 119 self, src_top: pathlib.Path, soong_vars: list[str] 120 ) -> dict[str, str]: 121 process_result = subprocess.run( 122 args=[ 123 f'{src_top / self._SOONG_UI_BASH_PATH}', 124 '--dumpvars-mode', 125 f'--abs-vars={" ".join(soong_vars)}', 126 ], 127 env=os.environ, 128 check=False, 129 capture_output=True, 130 text=True, 131 ) 132 if not process_result.returncode == 0: 133 logging.error('soong dumpvars command failed! stderr:') 134 logging.error(process_result.stderr) 135 raise RuntimeError('Soong dumpvars failed! See log for stderr.') 136 137 if not process_result.stdout: 138 raise RuntimeError( 139 'Necessary soong variables ' + soong_vars + ' not found.' 140 ) 141 142 try: 143 return { 144 line.split('=')[0]: line.split('=')[1].strip("'") 145 for line in process_result.stdout.strip().split('\n') 146 } 147 except IndexError as e: 148 raise RuntimeError( 149 'Error parsing soong dumpvars output! See output here:' 150 f' {process_result.stdout}', 151 e, 152 ) 153 154 def _base_zip_command( 155 self, src_top: pathlib.Path, dist_dir: pathlib.Path, name: str 156 ) -> list[str]: 157 return [ 158 f'{src_top / self._PREBUILT_SOONG_ZIP_PATH }', 159 '-d', 160 '-o', 161 f'{dist_dir / name}', 162 ] 163 164 165class NullOptimizer(OptimizedBuildTarget): 166 """No-op target optimizer. 167 168 This will simply build the same target it was given and do nothing for the 169 packaging step. 170 """ 171 172 def __init__(self, target): 173 self.target = target 174 175 def get_build_targets(self): 176 return {self.target} 177 178 def get_package_outputs_commands(self): 179 return [] 180 181 182class ChangeInfo: 183 184 def __init__(self, change_info_file_path): 185 try: 186 with open(change_info_file_path) as change_info_file: 187 change_info_contents = json.load(change_info_file) 188 except json.decoder.JSONDecodeError: 189 logging.error(f'Failed to load CHANGE_INFO: {change_info_file_path}') 190 raise 191 192 self._change_info_contents = change_info_contents 193 194 def find_changed_files(self) -> set[str]: 195 changed_files = set() 196 197 for change in self._change_info_contents['changes']: 198 project_path = change.get('projectPath') + '/' 199 200 for revision in change.get('revisions'): 201 for file_info in revision.get('fileInfos'): 202 changed_files.add(project_path + file_info.get('path')) 203 204 return changed_files 205 206 207class GeneralTestsOptimizer(OptimizedBuildTarget): 208 """general-tests optimizer 209 210 This optimizer reads in the list of changed files from the file located in 211 env[CHANGE_INFO] and uses this list alongside the normal TEST MAPPING logic to 212 determine what test mapping modules will run for the given changes. It then 213 builds those modules and packages them in the same way general-tests.zip is 214 normally built. 215 """ 216 217 # List of modules that are built alongside general-tests as dependencies. 218 _REQUIRED_MODULES = frozenset([ 219 'cts-tradefed', 220 'vts-tradefed', 221 'compatibility-host-util', 222 'general-tests-shared-libs', 223 ]) 224 225 def get_build_targets_impl(self) -> set[str]: 226 change_info_file_path = os.environ.get('CHANGE_INFO') 227 if not change_info_file_path: 228 logging.info( 229 'No CHANGE_INFO env var found, general-tests optimization disabled.' 230 ) 231 return {'general-tests'} 232 233 test_infos = self.build_context.test_infos 234 test_mapping_test_groups = set() 235 for test_info in test_infos: 236 is_test_mapping = test_info.is_test_mapping 237 current_test_mapping_test_groups = test_info.test_mapping_test_groups 238 uses_general_tests = test_info.build_target_used('general-tests') 239 240 if uses_general_tests and not is_test_mapping: 241 logging.info( 242 'Test uses general-tests.zip but is not test-mapping, general-tests' 243 ' optimization disabled.' 244 ) 245 return {'general-tests'} 246 247 if is_test_mapping: 248 test_mapping_test_groups.update(current_test_mapping_test_groups) 249 250 change_info = ChangeInfo(change_info_file_path) 251 changed_files = change_info.find_changed_files() 252 253 test_mappings = test_mapping_module_retriever.GetTestMappings( 254 changed_files, set() 255 ) 256 257 modules_to_build = set(self._REQUIRED_MODULES) 258 259 modules_to_build.update( 260 test_mapping_module_retriever.FindAffectedModules( 261 test_mappings, changed_files, test_mapping_test_groups 262 ) 263 ) 264 265 return modules_to_build 266 267 def get_package_outputs_commands_impl(self): 268 src_top = pathlib.Path(os.environ.get('TOP', os.getcwd())) 269 dist_dir = pathlib.Path(os.environ.get('DIST_DIR')) 270 271 soong_vars = self._query_soong_vars( 272 src_top, 273 [ 274 'HOST_OUT_TESTCASES', 275 'TARGET_OUT_TESTCASES', 276 'PRODUCT_OUT', 277 'SOONG_HOST_OUT', 278 'HOST_OUT', 279 ], 280 ) 281 host_out_testcases = pathlib.Path(soong_vars.get('HOST_OUT_TESTCASES')) 282 target_out_testcases = pathlib.Path(soong_vars.get('TARGET_OUT_TESTCASES')) 283 product_out = pathlib.Path(soong_vars.get('PRODUCT_OUT')) 284 soong_host_out = pathlib.Path(soong_vars.get('SOONG_HOST_OUT')) 285 host_out = pathlib.Path(soong_vars.get('HOST_OUT')) 286 287 host_paths = [] 288 target_paths = [] 289 host_config_files = [] 290 target_config_files = [] 291 for module in self.modules_to_build: 292 # The required modules are handled separately, no need to package. 293 if module in self._REQUIRED_MODULES: 294 continue 295 296 host_path = host_out_testcases / module 297 if os.path.exists(host_path): 298 host_paths.append(host_path) 299 self._collect_config_files(src_top, host_path, host_config_files) 300 301 target_path = target_out_testcases / module 302 if os.path.exists(target_path): 303 target_paths.append(target_path) 304 self._collect_config_files(src_top, target_path, target_config_files) 305 306 if not os.path.exists(host_path) and not os.path.exists(target_path): 307 logging.info(f'No host or target build outputs found for {module}.') 308 309 zip_commands = [] 310 311 zip_commands.extend( 312 self._get_zip_test_configs_zips_commands( 313 src_top, 314 dist_dir, 315 host_out, 316 product_out, 317 host_config_files, 318 target_config_files, 319 ) 320 ) 321 322 zip_command = self._base_zip_command(src_top, dist_dir, 'general-tests.zip') 323 324 # Add host testcases. 325 if host_paths: 326 zip_command.extend( 327 self._generate_zip_options_for_items( 328 prefix='host', 329 relative_root=f'{src_top / soong_host_out}', 330 directories=host_paths, 331 ) 332 ) 333 334 # Add target testcases. 335 if target_paths: 336 zip_command.extend( 337 self._generate_zip_options_for_items( 338 prefix='target', 339 relative_root=f'{src_top / product_out}', 340 directories=target_paths, 341 ) 342 ) 343 344 # TODO(lucafarsi): Push this logic into a general-tests-minimal build command 345 # Add necessary tools. These are also hardcoded in general-tests.mk. 346 framework_path = soong_host_out / 'framework' 347 348 zip_command.extend( 349 self._generate_zip_options_for_items( 350 prefix='host/tools', 351 relative_root=str(framework_path), 352 files=[ 353 f"{framework_path / 'cts-tradefed.jar'}", 354 f"{framework_path / 'compatibility-host-util.jar'}", 355 f"{framework_path / 'vts-tradefed.jar'}", 356 ], 357 ) 358 ) 359 360 zip_commands.append(zip_command) 361 return zip_commands 362 363 def _collect_config_files( 364 self, 365 src_top: pathlib.Path, 366 root_dir: pathlib.Path, 367 config_files: list[str], 368 ): 369 for root, dirs, files in os.walk(src_top / root_dir): 370 for file in files: 371 if file.endswith('.config'): 372 config_files.append(root_dir / file) 373 374 def _get_zip_test_configs_zips_commands( 375 self, 376 src_top: pathlib.Path, 377 dist_dir: pathlib.Path, 378 host_out: pathlib.Path, 379 product_out: pathlib.Path, 380 host_config_files: list[str], 381 target_config_files: list[str], 382 ) -> tuple[list[str], list[str]]: 383 """Generate general-tests_configs.zip and general-tests_list.zip. 384 385 general-tests_configs.zip contains all of the .config files that were 386 built and general-tests_list.zip contains a text file which lists 387 all of the .config files that are in general-tests_configs.zip. 388 389 general-tests_configs.zip is organized as follows: 390 / 391 host/ 392 testcases/ 393 test_1.config 394 test_2.config 395 ... 396 target/ 397 testcases/ 398 test_1.config 399 test_2.config 400 ... 401 402 So the process is we write out the paths to all the host config files into 403 one 404 file and all the paths to the target config files in another. We also write 405 the paths to all the config files into a third file to use for 406 general-tests_list.zip. 407 408 Args: 409 dist_dir: dist directory. 410 host_out: host out directory. 411 product_out: product out directory. 412 host_config_files: list of all host config files. 413 target_config_files: list of all target config files. 414 415 Returns: 416 The commands to generate general-tests_configs.zip and 417 general-tests_list.zip 418 """ 419 with open( 420 f"{host_out / 'host_general-tests_list'}", 'w' 421 ) as host_list_file, open( 422 f"{product_out / 'target_general-tests_list'}", 'w' 423 ) as target_list_file, open( 424 f"{host_out / 'general-tests_list'}", 'w' 425 ) as list_file: 426 427 for config_file in host_config_files: 428 host_list_file.write(f'{config_file}' + '\n') 429 list_file.write('host/' + os.path.relpath(config_file, host_out) + '\n') 430 431 for config_file in target_config_files: 432 target_list_file.write(f'{config_file}' + '\n') 433 list_file.write( 434 'target/' + os.path.relpath(config_file, product_out) + '\n' 435 ) 436 437 zip_commands = [] 438 439 tests_config_zip_command = self._base_zip_command( 440 src_top, dist_dir, 'general-tests_configs.zip' 441 ) 442 tests_config_zip_command.extend( 443 self._generate_zip_options_for_items( 444 prefix='host', 445 relative_root=str(host_out), 446 list_files=[f"{host_out / 'host_general-tests_list'}"], 447 ) 448 ) 449 450 tests_config_zip_command.extend( 451 self._generate_zip_options_for_items( 452 prefix='target', 453 relative_root=str(product_out), 454 list_files=[f"{product_out / 'target_general-tests_list'}"], 455 ), 456 ) 457 458 zip_commands.append(tests_config_zip_command) 459 460 tests_list_zip_command = self._base_zip_command( 461 src_top, dist_dir, 'general-tests_list.zip' 462 ) 463 tests_list_zip_command.extend( 464 self._generate_zip_options_for_items( 465 relative_root=str(host_out), 466 files=[f"{host_out / 'general-tests_list'}"], 467 ) 468 ) 469 zip_commands.append(tests_list_zip_command) 470 471 return zip_commands 472 473 def get_enabled_flag(self): 474 return 'general_tests_optimized' 475 476 @classmethod 477 def get_optimized_targets(cls) -> dict[str, OptimizedBuildTarget]: 478 return {'general-tests': functools.partial(cls)} 479 480 481OPTIMIZED_BUILD_TARGETS = {} 482OPTIMIZED_BUILD_TARGETS.update(GeneralTestsOptimizer.get_optimized_targets()) 483