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