xref: /aosp_15_r20/build/make/ci/build_test_suites.py (revision 9e94795a3d4ef5c1d47486f9a02bb378756cea8a)
1# Copyright 2024, The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Build script for the CI `test_suites` target."""
16
17import argparse
18from dataclasses import dataclass
19import json
20import logging
21import os
22import pathlib
23import re
24import subprocess
25import sys
26from typing import Callable
27from build_context import BuildContext
28import optimized_targets
29import metrics_agent
30import test_discovery_agent
31
32
33REQUIRED_ENV_VARS = frozenset(['TARGET_PRODUCT', 'TARGET_RELEASE', 'TOP', 'DIST_DIR'])
34SOONG_UI_EXE_REL_PATH = 'build/soong/soong_ui.bash'
35LOG_PATH = 'logs/build_test_suites.log'
36REQUIRED_BUILD_TARGETS = frozenset(['dist'])
37
38
39class Error(Exception):
40
41  def __init__(self, message):
42    super().__init__(message)
43
44
45class BuildFailureError(Error):
46
47  def __init__(self, return_code):
48    super().__init__(f'Build command failed with return code: f{return_code}')
49    self.return_code = return_code
50
51
52class BuildPlanner:
53  """Class in charge of determining how to optimize build targets.
54
55  Given the build context and targets to build it will determine a final list of
56  targets to build along with getting a set of packaging functions to package up
57  any output zip files needed by the build.
58  """
59
60  def __init__(
61      self,
62      build_context: BuildContext,
63      args: argparse.Namespace,
64      target_optimizations: dict[str, optimized_targets.OptimizedBuildTarget],
65  ):
66    self.build_context = build_context
67    self.args = args
68    self.target_optimizations = target_optimizations
69
70  def create_build_plan(self):
71
72    if 'optimized_build' not in self.build_context.enabled_build_features:
73      return BuildPlan(set(self.args.extra_targets), set())
74
75    build_targets = set()
76    packaging_commands_getters = []
77    # In order to roll optimizations out differently between test suites and
78    # device builds, we have separate flags.
79    if (
80        'test_suites_zip_test_discovery'
81        in self.build_context.enabled_build_features
82        and not self.args.device_build
83    ) or (
84        'device_zip_test_discovery'
85        in self.build_context.enabled_build_features
86        and self.args.device_build
87    ):
88      preliminary_build_targets = self._collect_preliminary_build_targets()
89    else:
90      preliminary_build_targets = self._legacy_collect_preliminary_build_targets()
91
92      # Keep reporting metrics when test discovery is disabled.
93      # To be removed once test discovery is fully rolled out.
94      optimization_rationale = ''
95      test_discovery_zip_regexes = set()
96      try:
97        test_discovery_zip_regexes = self._get_test_discovery_zip_regexes()
98        logging.info(f'Discovered test discovery regexes: {test_discovery_zip_regexes}')
99      except test_discovery_agent.TestDiscoveryError as e:
100        optimization_rationale = e.message
101        logging.warning(f'Unable to perform test discovery: {optimization_rationale}')
102
103      for target in self.args.extra_targets:
104        if optimization_rationale:
105          get_metrics_agent().report_unoptimized_target(target, optimization_rationale)
106          continue
107        try:
108          regex = r'\b(%s.*)\b' % re.escape(target)
109          if any(re.search(regex, opt) for opt in test_discovery_zip_regexes):
110            get_metrics_agent().report_unoptimized_target(target, 'Test artifact used.')
111            continue
112          get_metrics_agent().report_optimized_target(target)
113        except Exception as e:
114          logging.error(f'unable to parse test discovery output: {repr(e)}')
115
116    for target in preliminary_build_targets:
117      target_optimizer_getter = self.target_optimizations.get(target, None)
118      if not target_optimizer_getter:
119        build_targets.add(target)
120        continue
121
122      target_optimizer = target_optimizer_getter(
123          target, self.build_context, self.args
124      )
125      build_targets.update(target_optimizer.get_build_targets())
126      packaging_commands_getters.append(
127          target_optimizer.get_package_outputs_commands
128      )
129
130    return BuildPlan(build_targets, packaging_commands_getters)
131
132  def _collect_preliminary_build_targets(self):
133    build_targets = set()
134    try:
135      test_discovery_zip_regexes = self._get_test_discovery_zip_regexes()
136      logging.info(f'Discovered test discovery regexes: {test_discovery_zip_regexes}')
137    except test_discovery_agent.TestDiscoveryError as e:
138      optimization_rationale = e.message
139      logging.warning(f'Unable to perform test discovery: {optimization_rationale}')
140
141      for target in self.args.extra_targets:
142        get_metrics_agent().report_unoptimized_target(target, optimization_rationale)
143      return self._legacy_collect_preliminary_build_targets()
144
145    for target in self.args.extra_targets:
146      if target in REQUIRED_BUILD_TARGETS:
147        build_targets.add(target)
148        continue
149
150      regex = r'\b(%s.*)\b' % re.escape(target)
151      for opt in test_discovery_zip_regexes:
152        try:
153          if re.search(regex, opt):
154            get_metrics_agent().report_unoptimized_target(target, 'Test artifact used.')
155            build_targets.add(target)
156            continue
157          get_metrics_agent().report_optimized_target(target)
158        except Exception as e:
159          # In case of exception report as unoptimized
160          build_targets.add(target)
161          get_metrics_agent().report_unoptimized_target(target, f'Error in parsing test discovery output for {target}: {repr(e)}')
162          logging.error(f'unable to parse test discovery output: {repr(e)}')
163
164    return build_targets
165
166  def _legacy_collect_preliminary_build_targets(self):
167    build_targets = set()
168    for target in self.args.extra_targets:
169      if self._unused_target_exclusion_enabled(
170          target
171      ) and not self.build_context.build_target_used(target):
172        continue
173
174      build_targets.add(target)
175    return build_targets
176
177  def _unused_target_exclusion_enabled(self, target: str) -> bool:
178    return (
179        f'{target}_unused_exclusion'
180        in self.build_context.enabled_build_features
181    )
182
183  def _get_test_discovery_zip_regexes(self) -> set[str]:
184    build_target_regexes = set()
185    for test_info in self.build_context.test_infos:
186      tf_command = self._build_tf_command(test_info)
187      discovery_agent = test_discovery_agent.TestDiscoveryAgent(tradefed_args=tf_command)
188      for regex in discovery_agent.discover_test_zip_regexes():
189        build_target_regexes.add(regex)
190    return build_target_regexes
191
192
193  def _build_tf_command(self, test_info) -> list[str]:
194    command = [test_info.command]
195    for extra_option in test_info.extra_options:
196      if not extra_option.get('key'):
197        continue
198      arg_key = '--' + extra_option.get('key')
199      if arg_key == '--build-id':
200        command.append(arg_key)
201        command.append(os.environ.get('BUILD_NUMBER'))
202        continue
203      if extra_option.get('values'):
204        for value in extra_option.get('values'):
205          command.append(arg_key)
206          command.append(value)
207      else:
208        command.append(arg_key)
209
210    return command
211
212@dataclass(frozen=True)
213class BuildPlan:
214  build_targets: set[str]
215  packaging_commands_getters: list[Callable[[], list[list[str]]]]
216
217
218def build_test_suites(argv: list[str]) -> int:
219  """Builds all test suites passed in, optimizing based on the build_context content.
220
221  Args:
222    argv: The command line arguments passed in.
223
224  Returns:
225    The exit code of the build.
226  """
227  get_metrics_agent().analysis_start()
228  try:
229    args = parse_args(argv)
230    check_required_env()
231    build_context = BuildContext(load_build_context())
232    build_planner = BuildPlanner(
233        build_context, args, optimized_targets.OPTIMIZED_BUILD_TARGETS
234    )
235    build_plan = build_planner.create_build_plan()
236  except:
237    raise
238  finally:
239    get_metrics_agent().analysis_end()
240
241  try:
242    execute_build_plan(build_plan)
243  except BuildFailureError as e:
244    logging.error('Build command failed! Check build_log for details.')
245    return e.return_code
246  finally:
247    get_metrics_agent().end_reporting()
248
249  return 0
250
251
252def parse_args(argv: list[str]) -> argparse.Namespace:
253  argparser = argparse.ArgumentParser()
254
255  argparser.add_argument(
256      'extra_targets', nargs='*', help='Extra test suites to build.'
257  )
258  argparser.add_argument(
259      '--device-build',
260      action='store_true',
261      help='Flag to indicate running a device build.',
262  )
263
264  return argparser.parse_args(argv)
265
266
267def check_required_env():
268  """Check for required env vars.
269
270  Raises:
271    RuntimeError: If any required env vars are not found.
272  """
273  missing_env_vars = sorted(v for v in REQUIRED_ENV_VARS if v not in os.environ)
274
275  if not missing_env_vars:
276    return
277
278  t = ','.join(missing_env_vars)
279  raise Error(f'Missing required environment variables: {t}')
280
281
282def load_build_context():
283  build_context_path = pathlib.Path(os.environ.get('BUILD_CONTEXT', ''))
284  if build_context_path.is_file():
285    try:
286      with open(build_context_path, 'r') as f:
287        return json.load(f)
288    except json.decoder.JSONDecodeError as e:
289      raise Error(f'Failed to load JSON file: {build_context_path}')
290
291  logging.info('No BUILD_CONTEXT found, skipping optimizations.')
292  return empty_build_context()
293
294
295def empty_build_context():
296  return {'enabledBuildFeatures': []}
297
298
299def execute_build_plan(build_plan: BuildPlan):
300  build_command = []
301  build_command.append(get_top().joinpath(SOONG_UI_EXE_REL_PATH))
302  build_command.append('--make-mode')
303  build_command.extend(build_plan.build_targets)
304
305  try:
306    run_command(build_command)
307  except subprocess.CalledProcessError as e:
308    raise BuildFailureError(e.returncode) from e
309
310  get_metrics_agent().packaging_start()
311  try:
312    for packaging_commands_getter in build_plan.packaging_commands_getters:
313      for packaging_command in packaging_commands_getter():
314        run_command(packaging_command)
315  except subprocess.CalledProcessError as e:
316    raise BuildFailureError(e.returncode) from e
317  finally:
318    get_metrics_agent().packaging_end()
319
320
321def get_top() -> pathlib.Path:
322  return pathlib.Path(os.environ['TOP'])
323
324
325def run_command(args: list[str], stdout=None):
326  subprocess.run(args=args, check=True, stdout=stdout)
327
328
329def get_metrics_agent():
330  return metrics_agent.MetricsAgent.instance()
331
332
333def main(argv):
334  dist_dir = os.environ.get('DIST_DIR')
335  if dist_dir:
336    log_file = pathlib.Path(dist_dir) / LOG_PATH
337    logging.basicConfig(
338        level=logging.DEBUG,
339        format='%(asctime)s %(levelname)s %(message)s',
340        filename=log_file,
341    )
342  sys.exit(build_test_suites(argv))
343
344
345if __name__ == '__main__':
346  main(sys.argv[1:])
347