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