xref: /aosp_15_r20/build/bazel/ci/rbc_dashboard.py (revision 7594170e27e0732bc44b93d1440d87a54b6ffe7c)
1#!/usr/bin/env python3
2"""Generates a dashboard for the current RBC product/board config conversion status."""
3# pylint: disable=line-too-long
4
5import argparse
6import asyncio
7import dataclasses
8import datetime
9import itertools
10import os
11import re
12import shutil
13import socket
14import subprocess
15import sys
16import time
17from typing import List, Tuple
18import xml.etree.ElementTree as ET
19
20_PRODUCT_REGEX = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)(?:(?:-(trunk|trunk_staging|next))?-(user|userdebug|eng))?')
21
22_ALREADY_FAILING_PRODUCTS = [
23  "aosp_cf_x86_64_tv",
24  "aosp_cf_x86_tv",
25  "aosp_husky_61_pgagnostic",
26  "aosp_shiba_61_pgagnostic",
27]
28
29@dataclasses.dataclass(frozen=True)
30class Product:
31  """Represents a TARGET_PRODUCT and TARGET_BUILD_VARIANT."""
32  product: str
33  release: str
34  variant: str
35
36  def __post_init__(self):
37    if not _PRODUCT_REGEX.match(str(self)):
38      raise ValueError(f'Invalid product name: {self}')
39
40  def __str__(self):
41    return self.product + '-' + self.release + '-' + self.variant
42
43
44@dataclasses.dataclass(frozen=True)
45class ProductResult:
46  product: Product
47  baseline_success: bool
48  product_success: bool
49  product_has_diffs: bool
50
51  def success(self) -> bool:
52    return not self.baseline_success or (
53        self.product_success
54        and not self.product_has_diffs)
55
56
57@dataclasses.dataclass(frozen=True)
58class Directories:
59  out: str
60  out_baseline: str
61  out_product: str
62  results: str
63
64
65def get_top() -> str:
66  path = '.'
67  while not os.path.isfile(os.path.join(path, 'build/soong/soong_ui.bash')):
68    if os.path.abspath(path) == '/':
69      sys.exit('Could not find android source tree root.')
70    path = os.path.join(path, '..')
71  return os.path.abspath(path)
72
73
74def get_build_var(variable, product: Product) -> str:
75  """Returns the result of the shell command get_build_var."""
76  env = {
77      **os.environ,
78      'TARGET_PRODUCT': product.product,
79      'TARGET_RELEASE': product.release,
80      'TARGET_BUILD_VARIANT': product.variant,
81  }
82  return subprocess.check_output([
83      'build/soong/soong_ui.bash',
84      '--dumpvar-mode',
85      variable
86  ], env=env, text=True).strip()
87
88
89async def run_jailed_command(args: List[str], out_dir: str, env=None) -> bool:
90  """Runs a command, saves its output to out_dir/build.log, and returns if it succeeded."""
91  with open(os.path.join(out_dir, 'build.log'), 'wb') as f:
92    result = await asyncio.create_subprocess_exec(
93        'prebuilts/build-tools/linux-x86/bin/nsjail',
94        '-q',
95        '--cwd',
96        os.getcwd(),
97        '-e',
98        '-B',
99        '/',
100        '-B',
101        f'{os.path.abspath(out_dir)}:{os.path.abspath("out")}',
102        '--time_limit',
103        '0',
104        '--skip_setsid',
105        '--keep_caps',
106        '--disable_clone_newcgroup',
107        '--disable_clone_newnet',
108        '--rlimit_as',
109        'soft',
110        '--rlimit_core',
111        'soft',
112        '--rlimit_cpu',
113        'soft',
114        '--rlimit_fsize',
115        'soft',
116        '--rlimit_nofile',
117        'soft',
118        '--proc_rw',
119        '--hostname',
120        socket.gethostname(),
121        '--',
122        *args, stdout=f, stderr=subprocess.STDOUT, env=env)
123    return await result.wait() == 0
124
125
126async def run_build(flags: List[str], out_dir: str) -> bool:
127  return await run_jailed_command([
128      'build/soong/soong_ui.bash',
129      '--make-mode',
130      *flags,
131      '--skip-ninja',
132      'nothing'
133  ], out_dir)
134
135
136async def run_config(product: Product, rbc_product: bool, out_dir: str) -> bool:
137  """Runs config.mk and saves results to out/rbc_variable_dump.txt."""
138  env = {
139      'OUT_DIR': 'out',
140      'TMPDIR': 'tmp',
141      'BUILD_DATETIME_FILE': 'out/build_date.txt',
142      'CALLED_FROM_SETUP': 'true',
143      'TARGET_PRODUCT': product.product,
144      'TARGET_BUILD_VARIANT': product.variant,
145      'TARGET_RELEASE': product.release,
146      'RBC_PRODUCT_CONFIG': 'true' if rbc_product else '',
147      'RBC_DUMP_CONFIG_FILE': 'out/rbc_variable_dump.txt',
148  }
149  return await run_jailed_command([
150      'prebuilts/build-tools/linux-x86/bin/ckati',
151      '-f',
152      'build/make/core/config.mk'
153  ], out_dir, env=env)
154
155
156async def has_diffs(success: bool, file_pairs: List[Tuple[str]], results_folder: str) -> bool:
157  """Returns true if the two out folders provided have differing ninja files."""
158  if not success:
159    return False
160  results = []
161  for pair in file_pairs:
162    name = 'soong_build.ninja' if re.search('soong/build\.[^.]+\.ninja$', pair[0]) else os.path.basename(pair[0])
163    with open(os.path.join(results_folder, name)+'.diff', 'wb') as f:
164      results.append((await asyncio.create_subprocess_exec(
165          'diff',
166          pair[0],
167          pair[1],
168          stdout=f, stderr=subprocess.STDOUT)).wait())
169
170  for return_code in await asyncio.gather(*results):
171    if return_code != 0:
172      return True
173  return False
174
175
176def generate_html_row(num: int, results: ProductResult):
177  def generate_status_cell(success: bool, diffs: bool) -> str:
178    message = 'Success'
179    if diffs:
180      message = 'Results differed'
181    if not success:
182      message = 'Build failed'
183    return f'<td style="background-color: {"lightgreen" if success and not diffs else "salmon"}">{message}</td>'
184
185  product = results.product
186  return f'''
187  <tr>
188    <td>{num}</td>
189    <td>{product if results.success() and results.baseline_success else f'<a href="{product}/">{product}</a>'}</td>
190    {generate_status_cell(results.baseline_success, False)}
191    {generate_status_cell(results.product_success, results.product_has_diffs)}
192  </tr>
193  '''
194
195
196def get_branch() -> str:
197  try:
198    tree = ET.parse('.repo/manifests/default.xml')
199    default_tag = tree.getroot().find('default')
200    return default_tag.get('remote') + '/' + default_tag.get('revision')
201  except Exception as e:  # pylint: disable=broad-except
202    # Most likely happens due to .repo not existing on CI
203    return 'Unknown'
204
205
206def cleanup_empty_files(path):
207  if os.path.isfile(path):
208    if os.path.getsize(path) == 0:
209      os.remove(path)
210  elif os.path.isdir(path):
211    for subfile in os.listdir(path):
212      cleanup_empty_files(os.path.join(path, subfile))
213    if not os.listdir(path):
214      os.rmdir(path)
215
216
217def dump_files_to_stderr(path):
218  if os.path.isfile(path):
219    with open(path, 'r') as f:
220      print(f'{path}:', file=sys.stderr)
221      for line in itertools.islice(f, 200):
222        print(line.rstrip('\r\n'), file=sys.stderr)
223      if next(f, None) != None:
224        print('... Remaining lines skipped ...', file=sys.stderr)
225  elif os.path.isdir(path):
226    for subfile in os.listdir(path):
227      dump_files_to_stderr(os.path.join(path, subfile))
228
229
230async def test_one_product(product: Product, dirs: Directories) -> ProductResult:
231  """Runs the builds and tests for differences for a single product."""
232  baseline_success, product_success = await asyncio.gather(
233      run_build([
234          f'TARGET_PRODUCT={product.product}',
235          f'TARGET_RELEASE={product.release}',
236          f'TARGET_BUILD_VARIANT={product.variant}',
237      ], dirs.out_baseline),
238      run_build([
239          f'TARGET_PRODUCT={product.product}',
240          f'TARGET_RELEASE={product.release}',
241          f'TARGET_BUILD_VARIANT={product.variant}',
242          'RBC_PRODUCT_CONFIG=1',
243      ], dirs.out_product),
244  )
245
246  product_dashboard_folder = os.path.join(dirs.results, str(product))
247  os.mkdir(product_dashboard_folder)
248  os.mkdir(product_dashboard_folder+'/baseline')
249  os.mkdir(product_dashboard_folder+'/product')
250
251  if not baseline_success:
252    shutil.copy2(os.path.join(dirs.out_baseline, 'build.log'),
253                 f'{product_dashboard_folder}/baseline/build.log')
254  if not product_success:
255    shutil.copy2(os.path.join(dirs.out_product, 'build.log'),
256                 f'{product_dashboard_folder}/product/build.log')
257    add_message = False
258    with open(f'{product_dashboard_folder}/product/build.log', 'r') as f:
259      if '/out/rbc/' in f.read():
260        add_message = True
261    if add_message:
262      with open(f'{product_dashboard_folder}/product/build.log', 'a') as f:
263        f.write(f'\nPaths involving out/rbc are actually under {dirs.out_product}\n')
264
265  files = [f'build-{product.product}.ninja', f'build-{product.product}-package.ninja', f'soong/build.{product.product}.ninja']
266  product_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_product, x)) for x in files]
267  product_has_diffs = await has_diffs(baseline_success and product_success, product_files, product_dashboard_folder+'/product')
268
269  # delete files that contain the product name in them to save space,
270  # otherwise the ninja files end up filling up the whole harddrive
271  for out_folder in [dirs.out_baseline, dirs.out_product]:
272    for subfolder in ['', 'soong']:
273      folder = os.path.join(out_folder, subfolder)
274      for file in os.listdir(folder):
275        if os.path.isfile(os.path.join(folder, file)) and product.product in file:
276          os.remove(os.path.join(folder, file))
277
278  cleanup_empty_files(product_dashboard_folder)
279
280  return ProductResult(product, baseline_success, product_success, product_has_diffs)
281
282
283async def test_one_product_quick(product: Product, dirs: Directories) -> ProductResult:
284  """Runs the builds and tests for differences for a single product."""
285  baseline_success, product_success = await asyncio.gather(
286      run_config(
287          product,
288          False,
289          dirs.out_baseline),
290      run_config(
291          product,
292          True,
293          dirs.out_product),
294  )
295
296  product_dashboard_folder = os.path.join(dirs.results, str(product))
297  os.mkdir(product_dashboard_folder)
298  os.mkdir(product_dashboard_folder+'/baseline')
299  os.mkdir(product_dashboard_folder+'/product')
300
301  if not baseline_success:
302    shutil.copy2(os.path.join(dirs.out_baseline, 'build.log'),
303                 f'{product_dashboard_folder}/baseline/build.log')
304  if not product_success:
305    shutil.copy2(os.path.join(dirs.out_product, 'build.log'),
306                 f'{product_dashboard_folder}/product/build.log')
307    add_message = False
308    with open(f'{product_dashboard_folder}/product/build.log', 'r') as f:
309      if '/out/rbc/' in f.read():
310        add_message = True
311    if add_message:
312      with open(f'{product_dashboard_folder}/product/build.log', 'a') as f:
313        f.write(f'\nPaths involving out/rbc are actually under {dirs.out_product}\n')
314
315  files = ['rbc_variable_dump.txt']
316  product_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_product, x)) for x in files]
317  product_has_diffs = await has_diffs(baseline_success and product_success, product_files, product_dashboard_folder+'/product')
318
319  cleanup_empty_files(product_dashboard_folder)
320
321  return ProductResult(product, baseline_success, product_success, product_has_diffs)
322
323
324async def main():
325  parser = argparse.ArgumentParser(
326      description='Generates a dashboard of the starlark product configuration conversion.')
327  parser.add_argument('products', nargs='*',
328                      help='list of products to test. If not given, all '
329                      + 'products will be tested. '
330                      + 'Example: aosp_arm64-userdebug')
331  parser.add_argument('--quick', action='store_true',
332                      help='Run a quick test. This will only run config.mk and '
333                      + 'diff the make variables at the end of it, instead of '
334                      + 'diffing the full ninja files.')
335  parser.add_argument('--exclude', nargs='+', default=[],
336                      help='Exclude these producs from the build. Useful if not '
337                      + 'supplying a list of products manually.')
338  parser.add_argument('--results-directory',
339                      help='Directory to store results in. Defaults to $(OUT_DIR)/rbc_dashboard. '
340                      + 'Warning: will be cleared!')
341  parser.add_argument('--failure-message',
342                      help='Additional message to append to stderr on failure.')
343  args = parser.parse_args()
344
345  if args.results_directory:
346    args.results_directory = os.path.abspath(args.results_directory)
347
348  os.chdir(get_top())
349
350  def str_to_product(p: str) -> Product:
351    match = _PRODUCT_REGEX.fullmatch(p)
352    if not match:
353      sys.exit(f'Invalid product name: {p}. Example: aosp_arm64-trunk_staging-userdebug')
354    return Product(
355        match.group(1),
356        match.group(2) if match.group(2) else 'trunk_staging',
357        match.group(3) if match.group(3) else 'userdebug',
358    )
359
360  products = [str_to_product(p) for p in args.products]
361
362  if not products:
363    products = list(map(lambda x: Product(x, 'trunk_staging', 'userdebug'), get_build_var(
364        'all_named_products', Product('aosp_arm64', 'trunk_staging', 'userdebug')).split()))
365
366  excluded = [str_to_product(p) for p in args.exclude]
367  products = [p for p in products if p not in excluded]
368
369  for i, product in enumerate(products):
370    for j, product2 in enumerate(products):
371      if i != j and product.product == product2.product:
372        sys.exit(f'Product {product.product} cannot be repeated.')
373
374  out_dir = get_build_var('OUT_DIR', Product('aosp_arm64', 'trunk_staging', 'userdebug'))
375
376  dirs = Directories(
377      out=out_dir,
378      out_baseline=os.path.join(out_dir, 'rbc_out_baseline'),
379      out_product=os.path.join(out_dir, 'rbc_out_product'),
380      results=args.results_directory if args.results_directory else os.path.join(out_dir, 'rbc_dashboard'))
381
382  for folder in [dirs.out_baseline, dirs.out_product, dirs.results]:
383    # delete and recreate the out directories. You can't reuse them for
384    # a particular product, because after we delete some product-specific
385    # files inside the out dir to save space, the build will fail if you
386    # try to build the same product again.
387    shutil.rmtree(folder, ignore_errors=True)
388    os.makedirs(folder)
389
390  # When running in quick mode, we still need to build
391  # mk2rbc/rbcrun/AndroidProducts.mk.list, so run a get_build_var command to do
392  # that in each folder.
393  if args.quick:
394    commands = []
395    folders = [dirs.out_baseline, dirs.out_product]
396    for folder in folders:
397      commands.append(run_jailed_command([
398          'build/soong/soong_ui.bash',
399          '--dumpvar-mode',
400          'TARGET_PRODUCT',
401      ], folder, env = {
402          **os.environ,
403          'TARGET_PRODUCT': 'aosp_arm64',
404          'TARGET_RELEASE': 'trunk_staging',
405          'TARGET_BUILD_VARIANT': 'userdebug',
406      }))
407    for i, success in enumerate(await asyncio.gather(*commands)):
408      if not success:
409        dump_files_to_stderr(os.path.join(folders[i], 'build.log'))
410        sys.exit('Failed to setup output directories')
411
412  with open(os.path.join(dirs.results, 'index.html'), 'w') as f:
413    f.write(f'''
414      <body>
415        <h2>RBC Product/Board conversion status</h2>
416        Generated on {datetime.date.today()} for branch {get_branch()}
417        <table>
418          <tr>
419            <th>#</th>
420            <th>product</th>
421            <th>baseline</th>
422            <th>RBC product config</th>
423          </tr>\n''')
424    f.flush()
425
426    all_results = []
427    start_time = time.time()
428    print(f'{"Current product":31.31} | {"Time Elapsed":>16} | {"Per each":>8} | {"ETA":>16} | Status')
429    print('-' * 91)
430    for i, product in enumerate(products):
431      if i > 0:
432        elapsed_time = time.time() - start_time
433        time_per_product = elapsed_time / i
434        eta = time_per_product * (len(products) - i)
435        elapsed_time_str = str(datetime.timedelta(seconds=int(elapsed_time)))
436        time_per_product_str = str(datetime.timedelta(seconds=int(time_per_product)))
437        eta_str = str(datetime.timedelta(seconds=int(eta)))
438        print(f'{f"{i+1}/{len(products)} {product}":31.31} | {elapsed_time_str:>16} | {time_per_product_str:>8} | {eta_str:>16} | ', end='', flush=True)
439      else:
440        print(f'{f"{i+1}/{len(products)} {product}":31.31} | {"":>16} | {"":>8} | {"":>16} | ', end='', flush=True)
441
442      if not args.quick:
443        result = await test_one_product(product, dirs)
444      else:
445        result = await test_one_product_quick(product, dirs)
446
447      all_results.append(result)
448
449      if result.success():
450        print('Success')
451      else:
452        print('Failure')
453
454      f.write(generate_html_row(i+1, result))
455      f.flush()
456
457    baseline_successes = len([x for x in all_results if x.baseline_success])
458    product_successes = len([x for x in all_results if x.product_success and not x.product_has_diffs])
459    f.write(f'''
460          <tr>
461            <td></td>
462            <td># Successful</td>
463            <td>{baseline_successes}</td>
464            <td>{product_successes}</td>
465          </tr>
466          <tr>
467            <td></td>
468            <td># Failed</td>
469            <td>N/A</td>
470            <td>{baseline_successes - product_successes}</td>
471          </tr>
472        </table>
473        Finished running successfully.
474      </body>\n''')
475
476  print('Success!')
477  print('file://'+os.path.abspath(os.path.join(dirs.results, 'index.html')))
478
479  for result in all_results:
480    if not result.success():
481      print('There were one or more failing products. First failure:', file=sys.stderr)
482      dump_files_to_stderr(os.path.join(dirs.results, str(result.product)))
483      if args.failure_message:
484        print(args.failure_message, file=sys.stderr)
485      sys.exit(1)
486
487  baseline_failures = []
488  for result in all_results:
489    if result.product.product not in _ALREADY_FAILING_PRODUCTS and not result.baseline_success:
490      baseline_failures.append(result)
491  if baseline_failures:
492    product_str = "\n  ".join([f"{x.product}" for x in baseline_failures])
493    print(f"These products fail to run (Make-based) product config:\n  {product_str}\nFirst failure:", file=sys.stderr)
494    result = baseline_failures[0]
495    dump_files_to_stderr(os.path.join(dirs.results, str(result.product), 'baseline'))
496    if args.failure_message:
497      print(args.failure_message, file=sys.stderr)
498    sys.exit(1)
499
500
501if __name__ == '__main__':
502  asyncio.run(main())
503