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