blob: 4b8b453bb7469be0529d2c6c7a5e50e83ca9e78e [file] [log] [blame]
Luca Farsi040fabe2024-05-22 17:21:47 -07001#
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
Luca Farsi70a53bd2024-08-07 17:29:16 -070017import argparse
18import functools
Luca Farsib9c54642024-08-13 17:16:33 -070019import json
20import logging
21import os
Luca Farsi64598e82024-08-28 13:39:25 -070022import pathlib
23import subprocess
Luca Farsib9c54642024-08-13 17:16:33 -070024
Luca Farsi64598e82024-08-28 13:39:25 -070025from build_context import BuildContext
Luca Farsib9c54642024-08-13 17:16:33 -070026import test_mapping_module_retriever
Luca Farsi040fabe2024-05-22 17:21:47 -070027
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
Luca Farsi64598e82024-08-28 13:39:25 -070037 _SOONG_UI_BASH_PATH = 'build/soong/soong_ui.bash'
38 _PREBUILT_SOONG_ZIP_PATH = 'prebuilts/build-tools/linux-x86/bin/soong_zip'
39
Luca Farsi70a53bd2024-08-07 17:29:16 -070040 def __init__(
41 self,
42 target: str,
Luca Farsib130e792024-08-22 12:04:41 -070043 build_context: BuildContext,
Luca Farsi70a53bd2024-08-07 17:29:16 -070044 args: argparse.Namespace,
Luca Farsi6a346012025-02-11 00:57:53 +000045 test_infos
Luca Farsi70a53bd2024-08-07 17:29:16 -070046 ):
47 self.target = target
Luca Farsi040fabe2024-05-22 17:21:47 -070048 self.build_context = build_context
49 self.args = args
Luca Farsi6a346012025-02-11 00:57:53 +000050 self.test_infos = test_infos
Luca Farsi040fabe2024-05-22 17:21:47 -070051
Luca Farsi70a53bd2024-08-07 17:29:16 -070052 def get_build_targets(self) -> set[str]:
Luca Farsib130e792024-08-22 12:04:41 -070053 features = self.build_context.enabled_build_features
Luca Farsi70a53bd2024-08-07 17:29:16 -070054 if self.get_enabled_flag() in features:
Luca Farsib9c54642024-08-13 17:16:33 -070055 self.modules_to_build = self.get_build_targets_impl()
56 return self.modules_to_build
57
58 self.modules_to_build = {self.target}
Luca Farsi70a53bd2024-08-07 17:29:16 -070059 return {self.target}
Luca Farsi040fabe2024-05-22 17:21:47 -070060
Luca Farsid4e4b642024-09-10 16:37:51 -070061 def get_package_outputs_commands(self) -> list[list[str]]:
Luca Farsib130e792024-08-22 12:04:41 -070062 features = self.build_context.enabled_build_features
Luca Farsi70a53bd2024-08-07 17:29:16 -070063 if self.get_enabled_flag() in features:
Luca Farsid4e4b642024-09-10 16:37:51 -070064 return self.get_package_outputs_commands_impl()
Luca Farsi70a53bd2024-08-07 17:29:16 -070065
Luca Farsid4e4b642024-09-10 16:37:51 -070066 return []
67
68 def get_package_outputs_commands_impl(self) -> list[list[str]]:
Luca Farsi70a53bd2024-08-07 17:29:16 -070069 raise NotImplementedError(
Luca Farsid4e4b642024-09-10 16:37:51 -070070 'get_package_outputs_commands_impl not implemented in'
71 f' {type(self).__name__}'
Luca Farsi70a53bd2024-08-07 17:29:16 -070072 )
73
74 def get_enabled_flag(self):
75 raise NotImplementedError(
76 f'get_enabled_flag not implemented in {type(self).__name__}'
77 )
78
79 def get_build_targets_impl(self) -> set[str]:
80 raise NotImplementedError(
81 f'get_build_targets_impl not implemented in {type(self).__name__}'
82 )
Luca Farsi040fabe2024-05-22 17:21:47 -070083
Luca Farsi64598e82024-08-28 13:39:25 -070084 def _generate_zip_options_for_items(
85 self,
86 prefix: str = '',
87 relative_root: str = '',
88 list_files: list[str] | None = None,
89 files: list[str] | None = None,
90 directories: list[str] | None = None,
91 ) -> list[str]:
92 if not list_files and not files and not directories:
93 raise RuntimeError(
94 f'No items specified to be added to zip! Prefix: {prefix}, Relative'
95 f' root: {relative_root}'
96 )
97 command_segment = []
98 # These are all soong_zip options so consult soong_zip --help for specifics.
99 if prefix:
100 command_segment.append('-P')
101 command_segment.append(prefix)
102 if relative_root:
103 command_segment.append('-C')
104 command_segment.append(relative_root)
105 if list_files:
106 for list_file in list_files:
107 command_segment.append('-l')
108 command_segment.append(list_file)
109 if files:
110 for file in files:
111 command_segment.append('-f')
112 command_segment.append(file)
113 if directories:
114 for directory in directories:
115 command_segment.append('-D')
116 command_segment.append(directory)
117
118 return command_segment
119
120 def _query_soong_vars(
121 self, src_top: pathlib.Path, soong_vars: list[str]
122 ) -> dict[str, str]:
123 process_result = subprocess.run(
124 args=[
125 f'{src_top / self._SOONG_UI_BASH_PATH}',
Luca Farsi8ea67422024-09-17 15:48:11 -0700126 '--dumpvars-mode',
127 f'--abs-vars={" ".join(soong_vars)}',
Luca Farsi64598e82024-08-28 13:39:25 -0700128 ],
129 env=os.environ,
130 check=False,
131 capture_output=True,
Luca Farsi8ea67422024-09-17 15:48:11 -0700132 text=True,
Luca Farsi64598e82024-08-28 13:39:25 -0700133 )
134 if not process_result.returncode == 0:
135 logging.error('soong dumpvars command failed! stderr:')
136 logging.error(process_result.stderr)
137 raise RuntimeError('Soong dumpvars failed! See log for stderr.')
138
139 if not process_result.stdout:
140 raise RuntimeError(
141 'Necessary soong variables ' + soong_vars + ' not found.'
142 )
143
144 try:
145 return {
146 line.split('=')[0]: line.split('=')[1].strip("'")
Luca Farsi8ea67422024-09-17 15:48:11 -0700147 for line in process_result.stdout.strip().split('\n')
Luca Farsi64598e82024-08-28 13:39:25 -0700148 }
149 except IndexError as e:
150 raise RuntimeError(
151 'Error parsing soong dumpvars output! See output here:'
152 f' {process_result.stdout}',
153 e,
154 )
155
156 def _base_zip_command(
157 self, src_top: pathlib.Path, dist_dir: pathlib.Path, name: str
158 ) -> list[str]:
159 return [
160 f'{src_top / self._PREBUILT_SOONG_ZIP_PATH }',
161 '-d',
162 '-o',
163 f'{dist_dir / name}',
164 ]
165
Luca Farsi040fabe2024-05-22 17:21:47 -0700166
167class NullOptimizer(OptimizedBuildTarget):
168 """No-op target optimizer.
169
170 This will simply build the same target it was given and do nothing for the
171 packaging step.
172 """
173
174 def __init__(self, target):
175 self.target = target
176
177 def get_build_targets(self):
178 return {self.target}
179
Luca Farsid4e4b642024-09-10 16:37:51 -0700180 def get_package_outputs_commands(self):
181 return []
Luca Farsi040fabe2024-05-22 17:21:47 -0700182
183
Luca Farsib9c54642024-08-13 17:16:33 -0700184class ChangeInfo:
185
186 def __init__(self, change_info_file_path):
187 try:
188 with open(change_info_file_path) as change_info_file:
189 change_info_contents = json.load(change_info_file)
190 except json.decoder.JSONDecodeError:
191 logging.error(f'Failed to load CHANGE_INFO: {change_info_file_path}')
192 raise
193
194 self._change_info_contents = change_info_contents
195
196 def find_changed_files(self) -> set[str]:
197 changed_files = set()
198
199 for change in self._change_info_contents['changes']:
200 project_path = change.get('projectPath') + '/'
201
202 for revision in change.get('revisions'):
203 for file_info in revision.get('fileInfos'):
204 changed_files.add(project_path + file_info.get('path'))
205
206 return changed_files
207
Luca Farsid4e4b642024-09-10 16:37:51 -0700208
Luca Farsi70a53bd2024-08-07 17:29:16 -0700209class GeneralTestsOptimizer(OptimizedBuildTarget):
210 """general-tests optimizer
Luca Farsi040fabe2024-05-22 17:21:47 -0700211
Luca Farsi70a53bd2024-08-07 17:29:16 -0700212 This optimizer reads in the list of changed files from the file located in
213 env[CHANGE_INFO] and uses this list alongside the normal TEST MAPPING logic to
214 determine what test mapping modules will run for the given changes. It then
215 builds those modules and packages them in the same way general-tests.zip is
216 normally built.
217 """
218
Luca Farsi8ea67422024-09-17 15:48:11 -0700219 # List of modules that are built alongside general-tests as dependencies.
220 _REQUIRED_MODULES = frozenset([
221 'cts-tradefed',
222 'vts-tradefed',
223 'compatibility-host-util',
224 'general-tests-shared-libs',
225 ])
Luca Farsib9c54642024-08-13 17:16:33 -0700226
227 def get_build_targets_impl(self) -> set[str]:
228 change_info_file_path = os.environ.get('CHANGE_INFO')
229 if not change_info_file_path:
230 logging.info(
231 'No CHANGE_INFO env var found, general-tests optimization disabled.'
232 )
233 return {'general-tests'}
234
235 test_infos = self.build_context.test_infos
236 test_mapping_test_groups = set()
237 for test_info in test_infos:
238 is_test_mapping = test_info.is_test_mapping
239 current_test_mapping_test_groups = test_info.test_mapping_test_groups
240 uses_general_tests = test_info.build_target_used('general-tests')
241
242 if uses_general_tests and not is_test_mapping:
243 logging.info(
244 'Test uses general-tests.zip but is not test-mapping, general-tests'
245 ' optimization disabled.'
246 )
247 return {'general-tests'}
248
249 if is_test_mapping:
250 test_mapping_test_groups.update(current_test_mapping_test_groups)
251
252 change_info = ChangeInfo(change_info_file_path)
253 changed_files = change_info.find_changed_files()
254
255 test_mappings = test_mapping_module_retriever.GetTestMappings(
256 changed_files, set()
257 )
258
259 modules_to_build = set(self._REQUIRED_MODULES)
260
261 modules_to_build.update(
262 test_mapping_module_retriever.FindAffectedModules(
263 test_mappings, changed_files, test_mapping_test_groups
264 )
265 )
266
267 return modules_to_build
268
Luca Farsi64598e82024-08-28 13:39:25 -0700269 def get_package_outputs_commands_impl(self):
270 src_top = pathlib.Path(os.environ.get('TOP', os.getcwd()))
271 dist_dir = pathlib.Path(os.environ.get('DIST_DIR'))
272
273 soong_vars = self._query_soong_vars(
274 src_top,
275 [
276 'HOST_OUT_TESTCASES',
277 'TARGET_OUT_TESTCASES',
278 'PRODUCT_OUT',
279 'SOONG_HOST_OUT',
280 'HOST_OUT',
281 ],
282 )
283 host_out_testcases = pathlib.Path(soong_vars.get('HOST_OUT_TESTCASES'))
284 target_out_testcases = pathlib.Path(soong_vars.get('TARGET_OUT_TESTCASES'))
285 product_out = pathlib.Path(soong_vars.get('PRODUCT_OUT'))
286 soong_host_out = pathlib.Path(soong_vars.get('SOONG_HOST_OUT'))
287 host_out = pathlib.Path(soong_vars.get('HOST_OUT'))
288
289 host_paths = []
290 target_paths = []
291 host_config_files = []
292 target_config_files = []
293 for module in self.modules_to_build:
Luca Farsi8ea67422024-09-17 15:48:11 -0700294 # The required modules are handled separately, no need to package.
295 if module in self._REQUIRED_MODULES:
296 continue
297
Luca Farsi64598e82024-08-28 13:39:25 -0700298 host_path = host_out_testcases / module
299 if os.path.exists(host_path):
300 host_paths.append(host_path)
301 self._collect_config_files(src_top, host_path, host_config_files)
302
303 target_path = target_out_testcases / module
304 if os.path.exists(target_path):
305 target_paths.append(target_path)
306 self._collect_config_files(src_top, target_path, target_config_files)
307
308 if not os.path.exists(host_path) and not os.path.exists(target_path):
309 logging.info(f'No host or target build outputs found for {module}.')
310
311 zip_commands = []
312
313 zip_commands.extend(
314 self._get_zip_test_configs_zips_commands(
Luca Farsi8ea67422024-09-17 15:48:11 -0700315 src_top,
Luca Farsi64598e82024-08-28 13:39:25 -0700316 dist_dir,
317 host_out,
318 product_out,
319 host_config_files,
320 target_config_files,
321 )
322 )
323
Luca Farsi8ea67422024-09-17 15:48:11 -0700324 zip_command = self._base_zip_command(src_top, dist_dir, 'general-tests.zip')
Luca Farsi64598e82024-08-28 13:39:25 -0700325
326 # Add host testcases.
Luca Farsi8ea67422024-09-17 15:48:11 -0700327 if host_paths:
328 zip_command.extend(
329 self._generate_zip_options_for_items(
330 prefix='host',
331 relative_root=f'{src_top / soong_host_out}',
332 directories=host_paths,
333 )
334 )
Luca Farsi64598e82024-08-28 13:39:25 -0700335
336 # Add target testcases.
Luca Farsi8ea67422024-09-17 15:48:11 -0700337 if target_paths:
338 zip_command.extend(
339 self._generate_zip_options_for_items(
340 prefix='target',
341 relative_root=f'{src_top / product_out}',
342 directories=target_paths,
343 )
344 )
Luca Farsi64598e82024-08-28 13:39:25 -0700345
346 # TODO(lucafarsi): Push this logic into a general-tests-minimal build command
347 # Add necessary tools. These are also hardcoded in general-tests.mk.
348 framework_path = soong_host_out / 'framework'
349
350 zip_command.extend(
351 self._generate_zip_options_for_items(
352 prefix='host/tools',
353 relative_root=str(framework_path),
354 files=[
355 f"{framework_path / 'cts-tradefed.jar'}",
356 f"{framework_path / 'compatibility-host-util.jar'}",
357 f"{framework_path / 'vts-tradefed.jar'}",
358 ],
359 )
360 )
361
362 zip_commands.append(zip_command)
363 return zip_commands
364
365 def _collect_config_files(
366 self,
367 src_top: pathlib.Path,
368 root_dir: pathlib.Path,
369 config_files: list[str],
370 ):
371 for root, dirs, files in os.walk(src_top / root_dir):
372 for file in files:
373 if file.endswith('.config'):
374 config_files.append(root_dir / file)
375
376 def _get_zip_test_configs_zips_commands(
377 self,
Luca Farsi8ea67422024-09-17 15:48:11 -0700378 src_top: pathlib.Path,
Luca Farsi64598e82024-08-28 13:39:25 -0700379 dist_dir: pathlib.Path,
380 host_out: pathlib.Path,
381 product_out: pathlib.Path,
382 host_config_files: list[str],
383 target_config_files: list[str],
384 ) -> tuple[list[str], list[str]]:
385 """Generate general-tests_configs.zip and general-tests_list.zip.
386
387 general-tests_configs.zip contains all of the .config files that were
388 built and general-tests_list.zip contains a text file which lists
389 all of the .config files that are in general-tests_configs.zip.
390
391 general-tests_configs.zip is organized as follows:
392 /
393 host/
394 testcases/
395 test_1.config
396 test_2.config
397 ...
398 target/
399 testcases/
400 test_1.config
401 test_2.config
402 ...
403
404 So the process is we write out the paths to all the host config files into
405 one
406 file and all the paths to the target config files in another. We also write
407 the paths to all the config files into a third file to use for
408 general-tests_list.zip.
409
410 Args:
411 dist_dir: dist directory.
412 host_out: host out directory.
413 product_out: product out directory.
414 host_config_files: list of all host config files.
415 target_config_files: list of all target config files.
416
417 Returns:
418 The commands to generate general-tests_configs.zip and
419 general-tests_list.zip
420 """
421 with open(
422 f"{host_out / 'host_general-tests_list'}", 'w'
423 ) as host_list_file, open(
424 f"{product_out / 'target_general-tests_list'}", 'w'
425 ) as target_list_file, open(
426 f"{host_out / 'general-tests_list'}", 'w'
427 ) as list_file:
428
429 for config_file in host_config_files:
430 host_list_file.write(f'{config_file}' + '\n')
431 list_file.write('host/' + os.path.relpath(config_file, host_out) + '\n')
432
433 for config_file in target_config_files:
434 target_list_file.write(f'{config_file}' + '\n')
435 list_file.write(
436 'target/' + os.path.relpath(config_file, product_out) + '\n'
437 )
438
439 zip_commands = []
440
441 tests_config_zip_command = self._base_zip_command(
Luca Farsi8ea67422024-09-17 15:48:11 -0700442 src_top, dist_dir, 'general-tests_configs.zip'
Luca Farsi64598e82024-08-28 13:39:25 -0700443 )
444 tests_config_zip_command.extend(
445 self._generate_zip_options_for_items(
446 prefix='host',
447 relative_root=str(host_out),
448 list_files=[f"{host_out / 'host_general-tests_list'}"],
449 )
450 )
451
452 tests_config_zip_command.extend(
453 self._generate_zip_options_for_items(
454 prefix='target',
455 relative_root=str(product_out),
Luca Farsi8ea67422024-09-17 15:48:11 -0700456 list_files=[f"{product_out / 'target_general-tests_list'}"],
Luca Farsi64598e82024-08-28 13:39:25 -0700457 ),
458 )
459
460 zip_commands.append(tests_config_zip_command)
461
462 tests_list_zip_command = self._base_zip_command(
Luca Farsi8ea67422024-09-17 15:48:11 -0700463 src_top, dist_dir, 'general-tests_list.zip'
Luca Farsi64598e82024-08-28 13:39:25 -0700464 )
465 tests_list_zip_command.extend(
466 self._generate_zip_options_for_items(
467 relative_root=str(host_out),
468 files=[f"{host_out / 'general-tests_list'}"],
469 )
470 )
471 zip_commands.append(tests_list_zip_command)
472
473 return zip_commands
474
Luca Farsi70a53bd2024-08-07 17:29:16 -0700475 def get_enabled_flag(self):
Luca Farsib9c54642024-08-13 17:16:33 -0700476 return 'general_tests_optimized'
Luca Farsi70a53bd2024-08-07 17:29:16 -0700477
478 @classmethod
479 def get_optimized_targets(cls) -> dict[str, OptimizedBuildTarget]:
480 return {'general-tests': functools.partial(cls)}
Luca Farsi040fabe2024-05-22 17:21:47 -0700481
482
Luca Farsi70a53bd2024-08-07 17:29:16 -0700483OPTIMIZED_BUILD_TARGETS = {}
484OPTIMIZED_BUILD_TARGETS.update(GeneralTestsOptimizer.get_optimized_targets())