xref: /aosp_15_r20/build/make/ci/optimized_targets.py (revision 9e94795a3d4ef5c1d47486f9a02bb378756cea8a)
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