blob: 688bdd837033680b2170230b34e097e28c33e423 [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,
45 ):
46 self.target = target
Luca Farsi040fabe2024-05-22 17:21:47 -070047 self.build_context = build_context
48 self.args = args
49
Luca Farsi70a53bd2024-08-07 17:29:16 -070050 def get_build_targets(self) -> set[str]:
Luca Farsib130e792024-08-22 12:04:41 -070051 features = self.build_context.enabled_build_features
Luca Farsi70a53bd2024-08-07 17:29:16 -070052 if self.get_enabled_flag() in features:
Luca Farsib9c54642024-08-13 17:16:33 -070053 self.modules_to_build = self.get_build_targets_impl()
54 return self.modules_to_build
55
56 self.modules_to_build = {self.target}
Luca Farsi70a53bd2024-08-07 17:29:16 -070057 return {self.target}
Luca Farsi040fabe2024-05-22 17:21:47 -070058
Luca Farsid4e4b642024-09-10 16:37:51 -070059 def get_package_outputs_commands(self) -> list[list[str]]:
Luca Farsib130e792024-08-22 12:04:41 -070060 features = self.build_context.enabled_build_features
Luca Farsi70a53bd2024-08-07 17:29:16 -070061 if self.get_enabled_flag() in features:
Luca Farsid4e4b642024-09-10 16:37:51 -070062 return self.get_package_outputs_commands_impl()
Luca Farsi70a53bd2024-08-07 17:29:16 -070063
Luca Farsid4e4b642024-09-10 16:37:51 -070064 return []
65
66 def get_package_outputs_commands_impl(self) -> list[list[str]]:
Luca Farsi70a53bd2024-08-07 17:29:16 -070067 raise NotImplementedError(
Luca Farsid4e4b642024-09-10 16:37:51 -070068 'get_package_outputs_commands_impl not implemented in'
69 f' {type(self).__name__}'
Luca Farsi70a53bd2024-08-07 17:29:16 -070070 )
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 )
Luca Farsi040fabe2024-05-22 17:21:47 -070081
Luca Farsi64598e82024-08-28 13:39:25 -070082 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}',
Luca Farsi8ea67422024-09-17 15:48:11 -0700124 '--dumpvars-mode',
125 f'--abs-vars={" ".join(soong_vars)}',
Luca Farsi64598e82024-08-28 13:39:25 -0700126 ],
127 env=os.environ,
128 check=False,
129 capture_output=True,
Luca Farsi8ea67422024-09-17 15:48:11 -0700130 text=True,
Luca Farsi64598e82024-08-28 13:39:25 -0700131 )
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("'")
Luca Farsi8ea67422024-09-17 15:48:11 -0700145 for line in process_result.stdout.strip().split('\n')
Luca Farsi64598e82024-08-28 13:39:25 -0700146 }
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
Luca Farsi040fabe2024-05-22 17:21:47 -0700164
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
Luca Farsid4e4b642024-09-10 16:37:51 -0700178 def get_package_outputs_commands(self):
179 return []
Luca Farsi040fabe2024-05-22 17:21:47 -0700180
181
Luca Farsib9c54642024-08-13 17:16:33 -0700182class 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
Luca Farsid4e4b642024-09-10 16:37:51 -0700206
Luca Farsi70a53bd2024-08-07 17:29:16 -0700207class GeneralTestsOptimizer(OptimizedBuildTarget):
208 """general-tests optimizer
Luca Farsi040fabe2024-05-22 17:21:47 -0700209
Luca Farsi70a53bd2024-08-07 17:29:16 -0700210 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
Luca Farsi8ea67422024-09-17 15:48:11 -0700217 # 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 ])
Luca Farsib9c54642024-08-13 17:16:33 -0700224
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
Luca Farsi64598e82024-08-28 13:39:25 -0700267 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:
Luca Farsi8ea67422024-09-17 15:48:11 -0700292 # The required modules are handled separately, no need to package.
293 if module in self._REQUIRED_MODULES:
294 continue
295
Luca Farsi64598e82024-08-28 13:39:25 -0700296 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(
Luca Farsi8ea67422024-09-17 15:48:11 -0700313 src_top,
Luca Farsi64598e82024-08-28 13:39:25 -0700314 dist_dir,
315 host_out,
316 product_out,
317 host_config_files,
318 target_config_files,
319 )
320 )
321
Luca Farsi8ea67422024-09-17 15:48:11 -0700322 zip_command = self._base_zip_command(src_top, dist_dir, 'general-tests.zip')
Luca Farsi64598e82024-08-28 13:39:25 -0700323
324 # Add host testcases.
Luca Farsi8ea67422024-09-17 15:48:11 -0700325 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 )
Luca Farsi64598e82024-08-28 13:39:25 -0700333
334 # Add target testcases.
Luca Farsi8ea67422024-09-17 15:48:11 -0700335 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 )
Luca Farsi64598e82024-08-28 13:39:25 -0700343
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,
Luca Farsi8ea67422024-09-17 15:48:11 -0700376 src_top: pathlib.Path,
Luca Farsi64598e82024-08-28 13:39:25 -0700377 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(
Luca Farsi8ea67422024-09-17 15:48:11 -0700440 src_top, dist_dir, 'general-tests_configs.zip'
Luca Farsi64598e82024-08-28 13:39:25 -0700441 )
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),
Luca Farsi8ea67422024-09-17 15:48:11 -0700454 list_files=[f"{product_out / 'target_general-tests_list'}"],
Luca Farsi64598e82024-08-28 13:39:25 -0700455 ),
456 )
457
458 zip_commands.append(tests_config_zip_command)
459
460 tests_list_zip_command = self._base_zip_command(
Luca Farsi8ea67422024-09-17 15:48:11 -0700461 src_top, dist_dir, 'general-tests_list.zip'
Luca Farsi64598e82024-08-28 13:39:25 -0700462 )
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
Luca Farsi70a53bd2024-08-07 17:29:16 -0700473 def get_enabled_flag(self):
Luca Farsib9c54642024-08-13 17:16:33 -0700474 return 'general_tests_optimized'
Luca Farsi70a53bd2024-08-07 17:29:16 -0700475
476 @classmethod
477 def get_optimized_targets(cls) -> dict[str, OptimizedBuildTarget]:
478 return {'general-tests': functools.partial(cls)}
Luca Farsi040fabe2024-05-22 17:21:47 -0700479
480
Luca Farsi70a53bd2024-08-07 17:29:16 -0700481OPTIMIZED_BUILD_TARGETS = {}
482OPTIMIZED_BUILD_TARGETS.update(GeneralTestsOptimizer.get_optimized_targets())