| #!/usr/bin/env python3 |
| """Generates a dashboard for the current RBC product/board config conversion status.""" |
| # pylint: disable=line-too-long |
| |
| import argparse |
| import asyncio |
| import dataclasses |
| import datetime |
| import os |
| import re |
| import shutil |
| import socket |
| import subprocess |
| import sys |
| import time |
| from typing import List, Tuple |
| import xml.etree.ElementTree as ET |
| |
| _PRODUCT_REGEX = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)(?:-(user|userdebug|eng))?') |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class Product: |
| """Represents a TARGET_PRODUCT and TARGET_BUILD_VARIANT.""" |
| product: str |
| variant: str |
| |
| def __post_init__(self): |
| if not _PRODUCT_REGEX.match(str(self)): |
| raise ValueError(f'Invalid product name: {self}') |
| |
| def __str__(self): |
| return self.product + '-' + self.variant |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class ProductResult: |
| baseline_success: bool |
| product_success: bool |
| board_success: bool |
| product_has_diffs: bool |
| board_has_diffs: bool |
| |
| def success(self) -> bool: |
| return not self.baseline_success or ( |
| self.product_success and self.board_success |
| and not self.product_has_diffs and not self.board_has_diffs) |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class Directories: |
| out: str |
| out_baseline: str |
| out_product: str |
| out_board: str |
| results: str |
| |
| |
| def get_top() -> str: |
| path = '.' |
| while not os.path.isfile(os.path.join(path, 'build/soong/soong_ui.bash')): |
| if os.path.abspath(path) == '/': |
| sys.exit('Could not find android source tree root.') |
| path = os.path.join(path, '..') |
| return os.path.abspath(path) |
| |
| |
| def get_build_var(variable, product: Product) -> str: |
| """Returns the result of the shell command get_build_var.""" |
| env = { |
| **os.environ, |
| 'TARGET_PRODUCT': product.product, |
| 'TARGET_BUILD_VARIANT': product.variant, |
| } |
| return subprocess.run([ |
| 'build/soong/soong_ui.bash', |
| '--dumpvar-mode', |
| variable |
| ], check=True, capture_output=True, env=env, text=True).stdout.strip() |
| |
| |
| async def run_jailed_command(args: List[str], out_dir: str, env=None) -> bool: |
| """Runs a command, saves its output to out_dir/build.log, and returns if it succeeded.""" |
| with open(os.path.join(out_dir, 'build.log'), 'wb') as f: |
| result = await asyncio.create_subprocess_exec( |
| 'prebuilts/build-tools/linux-x86/bin/nsjail', |
| '-q', |
| '--cwd', |
| os.getcwd(), |
| '-e', |
| '-B', |
| '/', |
| '-B', |
| f'{os.path.abspath(out_dir)}:{os.path.abspath("out")}', |
| '--time_limit', |
| '0', |
| '--skip_setsid', |
| '--keep_caps', |
| '--disable_clone_newcgroup', |
| '--disable_clone_newnet', |
| '--rlimit_as', |
| 'soft', |
| '--rlimit_core', |
| 'soft', |
| '--rlimit_cpu', |
| 'soft', |
| '--rlimit_fsize', |
| 'soft', |
| '--rlimit_nofile', |
| 'soft', |
| '--proc_rw', |
| '--hostname', |
| socket.gethostname(), |
| '--', |
| *args, stdout=f, stderr=subprocess.STDOUT, env=env) |
| return await result.wait() == 0 |
| |
| |
| async def run_build(flags: List[str], out_dir: str) -> bool: |
| return await run_jailed_command([ |
| 'build/soong/soong_ui.bash', |
| '--make-mode', |
| *flags, |
| '--skip-ninja', |
| 'nothing' |
| ], out_dir) |
| |
| |
| async def run_config(product: Product, rbc_product: bool, rbc_board: bool, out_dir: str) -> bool: |
| """Runs config.mk and saves results to out/rbc_variable_dump.txt.""" |
| env = { |
| 'OUT_DIR': 'out', |
| 'TMPDIR': 'tmp', |
| 'BUILD_DATETIME_FILE': 'out/build_date.txt', |
| 'CALLED_FROM_SETUP': 'true', |
| 'TARGET_PRODUCT': product.product, |
| 'TARGET_BUILD_VARIANT': product.variant, |
| 'RBC_PRODUCT_CONFIG': 'true' if rbc_product else '', |
| 'RBC_BOARD_CONFIG': 'true' if rbc_board else '', |
| 'RBC_DUMP_CONFIG_FILE': 'out/rbc_variable_dump.txt', |
| } |
| return await run_jailed_command([ |
| 'prebuilts/build-tools/linux-x86/bin/ckati', |
| '-f', |
| 'build/make/core/config.mk' |
| ], out_dir, env=env) |
| |
| |
| async def has_diffs(success: bool, file_pairs: List[Tuple[str]], results_folder: str) -> bool: |
| """Returns true if the two out folders provided have differing ninja files.""" |
| if not success: |
| return False |
| results = [] |
| for pair in file_pairs: |
| name = 'soong_build.ninja' if pair[0].endswith('soong/build.ninja') else os.path.basename(pair[0]) |
| with open(os.path.join(results_folder, name)+'.diff', 'wb') as f: |
| results.append((await asyncio.create_subprocess_exec( |
| 'diff', |
| pair[0], |
| pair[1], |
| stdout=f, stderr=subprocess.STDOUT)).wait()) |
| |
| for return_code in await asyncio.gather(*results): |
| if return_code != 0: |
| return True |
| return False |
| |
| |
| def generate_html_row(num: int, product: Product, results: ProductResult): |
| def generate_status_cell(success: bool, diffs: bool) -> str: |
| message = 'Success' |
| if diffs: |
| message = 'Results differed' |
| if not success: |
| message = 'Build failed' |
| return f'<td style="background-color: {"lightgreen" if success and not diffs else "salmon"}">{message}</td>' |
| |
| return f''' |
| <tr> |
| <td>{num}</td> |
| <td>{product if results.success() and results.baseline_success else f'<a href="{product}/">{product}</a>'}</td> |
| {generate_status_cell(results.baseline_success, False)} |
| {generate_status_cell(results.product_success, results.product_has_diffs)} |
| {generate_status_cell(results.board_success, results.board_has_diffs)} |
| </tr> |
| ''' |
| |
| |
| def get_branch() -> str: |
| try: |
| tree = ET.parse('.repo/manifests/default.xml') |
| default_tag = tree.getroot().find('default') |
| return default_tag.get('remote') + '/' + default_tag.get('revision') |
| except Exception as e: # pylint: disable=broad-except |
| print(str(e), file=sys.stderr) |
| return 'Unknown' |
| |
| |
| def cleanup_empty_files(path): |
| if os.path.isfile(path): |
| if os.path.getsize(path) == 0: |
| os.remove(path) |
| elif os.path.isdir(path): |
| for subfile in os.listdir(path): |
| cleanup_empty_files(os.path.join(path, subfile)) |
| if not os.listdir(path): |
| os.rmdir(path) |
| |
| |
| async def test_one_product(product: Product, dirs: Directories) -> ProductResult: |
| """Runs the builds and tests for differences for a single product.""" |
| baseline_success, product_success, board_success = await asyncio.gather( |
| run_build([ |
| f'TARGET_PRODUCT={product.product}', |
| f'TARGET_BUILD_VARIANT={product.variant}', |
| ], dirs.out_baseline), |
| run_build([ |
| f'TARGET_PRODUCT={product.product}', |
| f'TARGET_BUILD_VARIANT={product.variant}', |
| 'RBC_PRODUCT_CONFIG=1', |
| ], dirs.out_product), |
| run_build([ |
| f'TARGET_PRODUCT={product.product}', |
| f'TARGET_BUILD_VARIANT={product.variant}', |
| 'RBC_BOARD_CONFIG=1', |
| ], dirs.out_board), |
| ) |
| |
| product_dashboard_folder = os.path.join(dirs.results, str(product)) |
| os.mkdir(product_dashboard_folder) |
| os.mkdir(product_dashboard_folder+'/baseline') |
| os.mkdir(product_dashboard_folder+'/product') |
| os.mkdir(product_dashboard_folder+'/board') |
| |
| if not baseline_success: |
| shutil.copy2(os.path.join(dirs.out_baseline, 'build.log'), |
| f'{product_dashboard_folder}/baseline/build.log') |
| if not product_success: |
| shutil.copy2(os.path.join(dirs.out_product, 'build.log'), |
| f'{product_dashboard_folder}/product/build.log') |
| if not board_success: |
| shutil.copy2(os.path.join(dirs.out_board, 'build.log'), |
| f'{product_dashboard_folder}/board/build.log') |
| |
| files = [f'build-{product.product}.ninja', f'build-{product.product}-package.ninja', 'soong/build.ninja'] |
| product_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_product, x)) for x in files] |
| board_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_board, x)) for x in files] |
| product_has_diffs, board_has_diffs = await asyncio.gather( |
| has_diffs(baseline_success and product_success, product_files, product_dashboard_folder+'/product'), |
| has_diffs(baseline_success and board_success, board_files, product_dashboard_folder+'/board')) |
| |
| # delete files that contain the product name in them to save space, |
| # otherwise the ninja files end up filling up the whole harddrive |
| for out_folder in [dirs.out_baseline, dirs.out_product, dirs.out_board]: |
| for subfolder in ['', 'soong']: |
| folder = os.path.join(out_folder, subfolder) |
| for file in os.listdir(folder): |
| if os.path.isfile(os.path.join(folder, file)) and product.product in file: |
| os.remove(os.path.join(folder, file)) |
| |
| cleanup_empty_files(product_dashboard_folder) |
| |
| return ProductResult(baseline_success, product_success, board_success, product_has_diffs, board_has_diffs) |
| |
| |
| async def test_one_product_quick(product: Product, dirs: Directories) -> ProductResult: |
| """Runs the builds and tests for differences for a single product.""" |
| baseline_success, product_success, board_success = await asyncio.gather( |
| run_config( |
| product, |
| False, |
| False, |
| dirs.out_baseline), |
| run_config( |
| product, |
| True, |
| False, |
| dirs.out_product), |
| run_config( |
| product, |
| False, |
| True, |
| dirs.out_board), |
| ) |
| |
| product_dashboard_folder = os.path.join(dirs.results, str(product)) |
| os.mkdir(product_dashboard_folder) |
| os.mkdir(product_dashboard_folder+'/baseline') |
| os.mkdir(product_dashboard_folder+'/product') |
| os.mkdir(product_dashboard_folder+'/board') |
| |
| if not baseline_success: |
| shutil.copy2(os.path.join(dirs.out_baseline, 'build.log'), |
| f'{product_dashboard_folder}/baseline/build.log') |
| if not product_success: |
| shutil.copy2(os.path.join(dirs.out_product, 'build.log'), |
| f'{product_dashboard_folder}/product/build.log') |
| if not board_success: |
| shutil.copy2(os.path.join(dirs.out_board, 'build.log'), |
| f'{product_dashboard_folder}/board/build.log') |
| |
| files = ['rbc_variable_dump.txt'] |
| product_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_product, x)) for x in files] |
| board_files = [(os.path.join(dirs.out_baseline, x), os.path.join(dirs.out_board, x)) for x in files] |
| product_has_diffs, board_has_diffs = await asyncio.gather( |
| has_diffs(baseline_success and product_success, product_files, product_dashboard_folder+'/product'), |
| has_diffs(baseline_success and board_success, board_files, product_dashboard_folder+'/board')) |
| |
| cleanup_empty_files(product_dashboard_folder) |
| |
| return ProductResult(baseline_success, product_success, board_success, product_has_diffs, board_has_diffs) |
| |
| |
| async def main(): |
| parser = argparse.ArgumentParser( |
| description='Generates a dashboard of the starlark product configuration conversion.') |
| parser.add_argument('products', nargs='*', |
| help='list of products to test. If not given, all ' |
| + 'products will be tested. ' |
| + 'Example: aosp_arm64-userdebug') |
| parser.add_argument('--quick', action='store_true', |
| help='Run a quick test. This will only run config.mk and ' |
| + 'diff the make variables at the end of it, instead of ' |
| + 'diffing the full ninja files.') |
| parser.add_argument('--exclude', nargs='+', default=[], |
| help='Exclude these producs from the build. Useful if not ' |
| + 'supplying a list of products manually.') |
| parser.add_argument('--results-directory', |
| help='Directory to store results in. Defaults to $(OUT_DIR)/rbc_dashboard. ' |
| + 'Warning: will be cleared!') |
| args = parser.parse_args() |
| |
| if args.results_directory: |
| args.results_directory = os.path.abspath(args.results_directory) |
| |
| os.chdir(get_top()) |
| |
| def str_to_product(p: str) -> Product: |
| match = _PRODUCT_REGEX.fullmatch(p) |
| if not match: |
| sys.exit(f'Invalid product name: {p}. Example: aosp_arm64-userdebug') |
| return Product(match.group(1), match.group(2) if match.group(2) else 'userdebug') |
| |
| products = [str_to_product(p) for p in args.products] |
| |
| if not products: |
| products = list(map(lambda x: Product(x, 'userdebug'), get_build_var( |
| 'all_named_products', Product('aosp_arm64', 'userdebug')).split())) |
| |
| excluded = [str_to_product(p) for p in args.exclude] |
| products = [p for p in products if p not in excluded] |
| |
| for i, product in enumerate(products): |
| for j, product2 in enumerate(products): |
| if i != j and product.product == product2.product: |
| sys.exit(f'Product {product.product} cannot be repeated.') |
| |
| out_dir = get_build_var('OUT_DIR', Product('aosp_arm64', 'userdebug')) |
| |
| dirs = Directories( |
| out=out_dir, |
| out_baseline=os.path.join(out_dir, 'rbc_out_baseline'), |
| out_product=os.path.join(out_dir, 'rbc_out_product'), |
| out_board=os.path.join(out_dir, 'rbc_out_board'), |
| results=args.results_directory if args.results_directory else os.path.join(out_dir, 'rbc_dashboard')) |
| |
| for folder in [dirs.out_baseline, dirs.out_product, dirs.out_board, dirs.results]: |
| # delete and recreate the out directories. You can't reuse them for |
| # a particular product, because after we delete some product-specific |
| # files inside the out dir to save space, the build will fail if you |
| # try to build the same product again. |
| shutil.rmtree(folder, ignore_errors=True) |
| os.makedirs(folder) |
| |
| # When running in quick mode, we still need to build |
| # mk2rbc/rbcrun/AndroidProducts.mk.list, so run a get_build_var command to do |
| # that in each folder. |
| if args.quick: |
| commands = [] |
| for folder in [dirs.out_baseline, dirs.out_product, dirs.out_board]: |
| commands.append(run_jailed_command([ |
| 'build/soong/soong_ui.bash', |
| '--dumpvar-mode', |
| 'TARGET_PRODUCT' |
| ], folder)) |
| for success in await asyncio.gather(*commands): |
| if not success: |
| sys.exit('Failed to setup output directories') |
| |
| with open(os.path.join(dirs.results, 'index.html'), 'w') as f: |
| f.write(f''' |
| <body> |
| <h2>RBC Product/Board conversion status</h2> |
| Generated on {datetime.date.today()} for branch {get_branch()} |
| <table> |
| <tr> |
| <th>#</th> |
| <th>product</th> |
| <th>baseline</th> |
| <th>RBC product config</th> |
| <th>RBC board config</th> |
| </tr>\n''') |
| f.flush() |
| |
| all_results = [] |
| start_time = time.time() |
| print(f'{"Current product":31.31} | {"Time Elapsed":>16} | {"Per each":>8} | {"ETA":>16} | Status') |
| print('-' * 91) |
| for i, product in enumerate(products): |
| if i > 0: |
| elapsed_time = time.time() - start_time |
| time_per_product = elapsed_time / i |
| eta = time_per_product * (len(products) - i) |
| elapsed_time_str = str(datetime.timedelta(seconds=int(elapsed_time))) |
| time_per_product_str = str(datetime.timedelta(seconds=int(time_per_product))) |
| eta_str = str(datetime.timedelta(seconds=int(eta))) |
| 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) |
| else: |
| print(f'{f"{i+1}/{len(products)} {product}":31.31} | {"":>16} | {"":>8} | {"":>16} | ', end='', flush=True) |
| |
| if not args.quick: |
| result = await test_one_product(product, dirs) |
| else: |
| result = await test_one_product_quick(product, dirs) |
| |
| all_results.append(result) |
| |
| if result.success(): |
| print('Success') |
| else: |
| print('Failure') |
| |
| f.write(generate_html_row(i+1, product, result)) |
| f.flush() |
| |
| baseline_successes = len([x for x in all_results if x.baseline_success]) |
| product_successes = len([x for x in all_results if x.product_success and not x.product_has_diffs]) |
| board_successes = len([x for x in all_results if x.board_success and not x.board_has_diffs]) |
| f.write(f''' |
| <tr> |
| <td></td> |
| <td># Successful</td> |
| <td>{baseline_successes}</td> |
| <td>{product_successes}</td> |
| <td>{board_successes}</td> |
| </tr> |
| <tr> |
| <td></td> |
| <td># Failed</td> |
| <td>N/A</td> |
| <td>{baseline_successes - product_successes}</td> |
| <td>{baseline_successes - board_successes}</td> |
| </tr> |
| </table> |
| Finished running successfully. |
| </body>\n''') |
| |
| print('Success!') |
| print('file://'+os.path.abspath(os.path.join(dirs.results, 'index.html'))) |
| |
| for result in all_results: |
| if result.baseline_success and not result.success(): |
| print('There were one or more failing products. See the html report for details.') |
| sys.exit(1) |
| |
| if __name__ == '__main__': |
| asyncio.run(main()) |