blob: 4bee40156909ec1794b8f63c8c3efc49473595ee [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}',
124 '--dumpvar-mode',
125 '--abs',
126 soong_vars,
127 ],
128 env=os.environ,
129 check=False,
130 capture_output=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.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
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 Farsib9c54642024-08-13 17:16:33 -0700217 # List of modules that are always required to be in general-tests.zip.
218 _REQUIRED_MODULES = frozenset(
219 ['cts-tradefed', 'vts-tradefed', 'compatibility-host-util']
220 )
221
222 def get_build_targets_impl(self) -> set[str]:
223 change_info_file_path = os.environ.get('CHANGE_INFO')
224 if not change_info_file_path:
225 logging.info(
226 'No CHANGE_INFO env var found, general-tests optimization disabled.'
227 )
228 return {'general-tests'}
229
230 test_infos = self.build_context.test_infos
231 test_mapping_test_groups = set()
232 for test_info in test_infos:
233 is_test_mapping = test_info.is_test_mapping
234 current_test_mapping_test_groups = test_info.test_mapping_test_groups
235 uses_general_tests = test_info.build_target_used('general-tests')
236
237 if uses_general_tests and not is_test_mapping:
238 logging.info(
239 'Test uses general-tests.zip but is not test-mapping, general-tests'
240 ' optimization disabled.'
241 )
242 return {'general-tests'}
243
244 if is_test_mapping:
245 test_mapping_test_groups.update(current_test_mapping_test_groups)
246
247 change_info = ChangeInfo(change_info_file_path)
248 changed_files = change_info.find_changed_files()
249
250 test_mappings = test_mapping_module_retriever.GetTestMappings(
251 changed_files, set()
252 )
253
254 modules_to_build = set(self._REQUIRED_MODULES)
255
256 modules_to_build.update(
257 test_mapping_module_retriever.FindAffectedModules(
258 test_mappings, changed_files, test_mapping_test_groups
259 )
260 )
261
262 return modules_to_build
263
Luca Farsi64598e82024-08-28 13:39:25 -0700264 def get_package_outputs_commands_impl(self):
265 src_top = pathlib.Path(os.environ.get('TOP', os.getcwd()))
266 dist_dir = pathlib.Path(os.environ.get('DIST_DIR'))
267
268 soong_vars = self._query_soong_vars(
269 src_top,
270 [
271 'HOST_OUT_TESTCASES',
272 'TARGET_OUT_TESTCASES',
273 'PRODUCT_OUT',
274 'SOONG_HOST_OUT',
275 'HOST_OUT',
276 ],
277 )
278 host_out_testcases = pathlib.Path(soong_vars.get('HOST_OUT_TESTCASES'))
279 target_out_testcases = pathlib.Path(soong_vars.get('TARGET_OUT_TESTCASES'))
280 product_out = pathlib.Path(soong_vars.get('PRODUCT_OUT'))
281 soong_host_out = pathlib.Path(soong_vars.get('SOONG_HOST_OUT'))
282 host_out = pathlib.Path(soong_vars.get('HOST_OUT'))
283
284 host_paths = []
285 target_paths = []
286 host_config_files = []
287 target_config_files = []
288 for module in self.modules_to_build:
289 host_path = host_out_testcases / module
290 if os.path.exists(host_path):
291 host_paths.append(host_path)
292 self._collect_config_files(src_top, host_path, host_config_files)
293
294 target_path = target_out_testcases / module
295 if os.path.exists(target_path):
296 target_paths.append(target_path)
297 self._collect_config_files(src_top, target_path, target_config_files)
298
299 if not os.path.exists(host_path) and not os.path.exists(target_path):
300 logging.info(f'No host or target build outputs found for {module}.')
301
302 zip_commands = []
303
304 zip_commands.extend(
305 self._get_zip_test_configs_zips_commands(
306 dist_dir,
307 host_out,
308 product_out,
309 host_config_files,
310 target_config_files,
311 )
312 )
313
314 zip_command = self._base_zip_command(
315 host_out, dist_dir, 'general-tests.zip'
316 )
317
318 # Add host testcases.
319 zip_command.extend(
320 self._generate_zip_options_for_items(
321 prefix='host',
322 relative_root=f'{src_top / soong_host_out}',
323 directories=host_paths,
324 )
325 )
326
327 # Add target testcases.
328 zip_command.extend(
329 self._generate_zip_options_for_items(
330 prefix='target',
331 relative_root=f'{src_top / product_out}',
332 directories=target_paths,
333 )
334 )
335
336 # TODO(lucafarsi): Push this logic into a general-tests-minimal build command
337 # Add necessary tools. These are also hardcoded in general-tests.mk.
338 framework_path = soong_host_out / 'framework'
339
340 zip_command.extend(
341 self._generate_zip_options_for_items(
342 prefix='host/tools',
343 relative_root=str(framework_path),
344 files=[
345 f"{framework_path / 'cts-tradefed.jar'}",
346 f"{framework_path / 'compatibility-host-util.jar'}",
347 f"{framework_path / 'vts-tradefed.jar'}",
348 ],
349 )
350 )
351
352 zip_commands.append(zip_command)
353 return zip_commands
354
355 def _collect_config_files(
356 self,
357 src_top: pathlib.Path,
358 root_dir: pathlib.Path,
359 config_files: list[str],
360 ):
361 for root, dirs, files in os.walk(src_top / root_dir):
362 for file in files:
363 if file.endswith('.config'):
364 config_files.append(root_dir / file)
365
366 def _get_zip_test_configs_zips_commands(
367 self,
368 dist_dir: pathlib.Path,
369 host_out: pathlib.Path,
370 product_out: pathlib.Path,
371 host_config_files: list[str],
372 target_config_files: list[str],
373 ) -> tuple[list[str], list[str]]:
374 """Generate general-tests_configs.zip and general-tests_list.zip.
375
376 general-tests_configs.zip contains all of the .config files that were
377 built and general-tests_list.zip contains a text file which lists
378 all of the .config files that are in general-tests_configs.zip.
379
380 general-tests_configs.zip is organized as follows:
381 /
382 host/
383 testcases/
384 test_1.config
385 test_2.config
386 ...
387 target/
388 testcases/
389 test_1.config
390 test_2.config
391 ...
392
393 So the process is we write out the paths to all the host config files into
394 one
395 file and all the paths to the target config files in another. We also write
396 the paths to all the config files into a third file to use for
397 general-tests_list.zip.
398
399 Args:
400 dist_dir: dist directory.
401 host_out: host out directory.
402 product_out: product out directory.
403 host_config_files: list of all host config files.
404 target_config_files: list of all target config files.
405
406 Returns:
407 The commands to generate general-tests_configs.zip and
408 general-tests_list.zip
409 """
410 with open(
411 f"{host_out / 'host_general-tests_list'}", 'w'
412 ) as host_list_file, open(
413 f"{product_out / 'target_general-tests_list'}", 'w'
414 ) as target_list_file, open(
415 f"{host_out / 'general-tests_list'}", 'w'
416 ) as list_file:
417
418 for config_file in host_config_files:
419 host_list_file.write(f'{config_file}' + '\n')
420 list_file.write('host/' + os.path.relpath(config_file, host_out) + '\n')
421
422 for config_file in target_config_files:
423 target_list_file.write(f'{config_file}' + '\n')
424 list_file.write(
425 'target/' + os.path.relpath(config_file, product_out) + '\n'
426 )
427
428 zip_commands = []
429
430 tests_config_zip_command = self._base_zip_command(
431 host_out, dist_dir, 'general-tests_configs.zip'
432 )
433 tests_config_zip_command.extend(
434 self._generate_zip_options_for_items(
435 prefix='host',
436 relative_root=str(host_out),
437 list_files=[f"{host_out / 'host_general-tests_list'}"],
438 )
439 )
440
441 tests_config_zip_command.extend(
442 self._generate_zip_options_for_items(
443 prefix='target',
444 relative_root=str(product_out),
445 list_files=[
446 f"{product_out / 'target_general-tests_list'}"
447 ],
448 ),
449 )
450
451 zip_commands.append(tests_config_zip_command)
452
453 tests_list_zip_command = self._base_zip_command(
454 host_out, dist_dir, 'general-tests_list.zip'
455 )
456 tests_list_zip_command.extend(
457 self._generate_zip_options_for_items(
458 relative_root=str(host_out),
459 files=[f"{host_out / 'general-tests_list'}"],
460 )
461 )
462 zip_commands.append(tests_list_zip_command)
463
464 return zip_commands
465
Luca Farsi70a53bd2024-08-07 17:29:16 -0700466 def get_enabled_flag(self):
Luca Farsib9c54642024-08-13 17:16:33 -0700467 return 'general_tests_optimized'
Luca Farsi70a53bd2024-08-07 17:29:16 -0700468
469 @classmethod
470 def get_optimized_targets(cls) -> dict[str, OptimizedBuildTarget]:
471 return {'general-tests': functools.partial(cls)}
Luca Farsi040fabe2024-05-22 17:21:47 -0700472
473
Luca Farsi70a53bd2024-08-07 17:29:16 -0700474OPTIMIZED_BUILD_TARGETS = {}
475OPTIMIZED_BUILD_TARGETS.update(GeneralTestsOptimizer.get_optimized_targets())