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