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