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