1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# Copyright 2019 The ChromiumOS Authors 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""Performs bisection on LLVM based off a .JSON file.""" 8 9 10import argparse 11import enum 12import errno 13import json 14import os 15import subprocess 16import sys 17 18import chroot 19import get_llvm_hash 20import git_llvm_rev 21import modify_a_tryjob 22import update_chromeos_llvm_hash 23import update_tryjob_status 24 25 26class BisectionExitStatus(enum.Enum): 27 """Exit code when performing bisection.""" 28 29 # Means that there are no more revisions available to bisect. 30 BISECTION_COMPLETE = 126 31 32 33def GetCommandLineArgs(): 34 """Parses the command line for the command line arguments.""" 35 36 # Default path to the chroot if a path is not specified. 37 cros_root = os.path.expanduser("~") 38 cros_root = os.path.join(cros_root, "chromiumos") 39 40 # Create parser and add optional command-line arguments. 41 parser = argparse.ArgumentParser( 42 description="Bisects LLVM via tracking a JSON file." 43 ) 44 45 # Add argument for other change lists that want to run alongside the tryjob 46 # which has a change list of updating a package's git hash. 47 parser.add_argument( 48 "--parallel", 49 type=int, 50 default=3, 51 help="How many tryjobs to create between the last good version and " 52 "the first bad version (default: %(default)s)", 53 ) 54 55 # Add argument for the good LLVM revision for bisection. 56 parser.add_argument( 57 "--start_rev", 58 required=True, 59 type=int, 60 help="The good revision for the bisection.", 61 ) 62 63 # Add argument for the bad LLVM revision for bisection. 64 parser.add_argument( 65 "--end_rev", 66 required=True, 67 type=int, 68 help="The bad revision for the bisection.", 69 ) 70 71 # Add argument for the absolute path to the file that contains information on 72 # the previous tested svn version. 73 parser.add_argument( 74 "--last_tested", 75 required=True, 76 help="the absolute path to the file that contains the tryjobs", 77 ) 78 79 # Add argument for the absolute path to the LLVM source tree. 80 parser.add_argument( 81 "--src_path", 82 help="the path to the LLVM source tree to use (used for retrieving the " 83 "git hash of each version between the last good version and first bad " 84 "version)", 85 ) 86 87 # Add argument for other change lists that want to run alongside the tryjob 88 # which has a change list of updating a package's git hash. 89 parser.add_argument( 90 "--extra_change_lists", 91 type=int, 92 nargs="+", 93 help="change lists that would like to be run alongside the change list " 94 "of updating the packages", 95 ) 96 97 # Add argument for custom options for the tryjob. 98 parser.add_argument( 99 "--options", 100 required=False, 101 nargs="+", 102 help="options to use for the tryjob testing", 103 ) 104 105 # Add argument for the builder to use for the tryjob. 106 parser.add_argument( 107 "--builder", required=True, help="builder to use for the tryjob testing" 108 ) 109 110 # Add argument for the description of the tryjob. 111 parser.add_argument( 112 "--description", 113 required=False, 114 nargs="+", 115 help="the description of the tryjob", 116 ) 117 118 # Add argument for a specific chroot path. 119 parser.add_argument( 120 "--chroot_path", 121 default=cros_root, 122 help="the path to the chroot (default: %(default)s)", 123 ) 124 125 # Add argument for whether to display command contents to `stdout`. 126 parser.add_argument( 127 "--verbose", 128 action="store_true", 129 help="display contents of a command to the terminal " 130 "(default: %(default)s)", 131 ) 132 133 # Add argument for whether to display command contents to `stdout`. 134 parser.add_argument( 135 "--nocleanup", 136 action="store_false", 137 dest="cleanup", 138 help="Abandon CLs created for bisectoin", 139 ) 140 141 args_output = parser.parse_args() 142 143 assert ( 144 args_output.start_rev < args_output.end_rev 145 ), "Start revision %d is >= end revision %d" % ( 146 args_output.start_rev, 147 args_output.end_rev, 148 ) 149 150 if args_output.last_tested and not args_output.last_tested.endswith( 151 ".json" 152 ): 153 raise ValueError( 154 'Filed provided %s does not end in ".json"' 155 % args_output.last_tested 156 ) 157 158 return args_output 159 160 161def GetRemainingRange(start, end, tryjobs): 162 """Gets the start and end intervals in 'json_file'. 163 164 Args: 165 start: The start version of the bisection provided via the command line. 166 end: The end version of the bisection provided via the command line. 167 tryjobs: A list of tryjobs where each element is in the following format: 168 [ 169 {[TRYJOB_INFORMATION]}, 170 {[TRYJOB_INFORMATION]}, 171 ..., 172 {[TRYJOB_INFORMATION]} 173 ] 174 175 Returns: 176 The new start version and end version for bisection, a set of revisions 177 that are 'pending' and a set of revisions that are to be skipped. 178 179 Raises: 180 ValueError: The value for 'status' is missing or there is a mismatch 181 between 'start' and 'end' compared to the 'start' and 'end' in the JSON 182 file. 183 AssertionError: The new start version is >= than the new end version. 184 """ 185 186 if not tryjobs: 187 return start, end, {}, {} 188 189 # Verify that each tryjob has a value for the 'status' key. 190 for cur_tryjob_dict in tryjobs: 191 if not cur_tryjob_dict.get("status", None): 192 raise ValueError( 193 '"status" is missing or has no value, please ' 194 "go to %s and update it" % cur_tryjob_dict["link"] 195 ) 196 197 all_bad_revisions = [end] 198 all_bad_revisions.extend( 199 cur_tryjob["rev"] 200 for cur_tryjob in tryjobs 201 if cur_tryjob["status"] == update_tryjob_status.TryjobStatus.BAD.value 202 ) 203 204 # The minimum value for the 'bad' field in the tryjobs is the new end 205 # version. 206 bad_rev = min(all_bad_revisions) 207 208 all_good_revisions = [start] 209 all_good_revisions.extend( 210 cur_tryjob["rev"] 211 for cur_tryjob in tryjobs 212 if cur_tryjob["status"] == update_tryjob_status.TryjobStatus.GOOD.value 213 ) 214 215 # The maximum value for the 'good' field in the tryjobs is the new start 216 # version. 217 good_rev = max(all_good_revisions) 218 219 # The good version should always be strictly less than the bad version; 220 # otherwise, bisection is broken. 221 assert ( 222 good_rev < bad_rev 223 ), "Bisection is broken because %d (good) is >= " "%d (bad)" % ( 224 good_rev, 225 bad_rev, 226 ) 227 228 # Find all revisions that are 'pending' within 'good_rev' and 'bad_rev'. 229 # 230 # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev' 231 # that have already been launched (this set is used when constructing the 232 # list of revisions to launch tryjobs for). 233 pending_revisions = { 234 tryjob["rev"] 235 for tryjob in tryjobs 236 if tryjob["status"] == update_tryjob_status.TryjobStatus.PENDING.value 237 and good_rev < tryjob["rev"] < bad_rev 238 } 239 240 # Find all revisions that are to be skipped within 'good_rev' and 'bad_rev'. 241 # 242 # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev' 243 # that have already been marked as 'skip' (this set is used when constructing 244 # the list of revisions to launch tryjobs for). 245 skip_revisions = { 246 tryjob["rev"] 247 for tryjob in tryjobs 248 if tryjob["status"] == update_tryjob_status.TryjobStatus.SKIP.value 249 and good_rev < tryjob["rev"] < bad_rev 250 } 251 252 return good_rev, bad_rev, pending_revisions, skip_revisions 253 254 255def GetCommitsBetween( 256 start, end, parallel, src_path, pending_revisions, skip_revisions 257): 258 """Determines the revisions between start and end.""" 259 260 with get_llvm_hash.LLVMHash().CreateTempDirectory() as temp_dir: 261 # We have guaranteed contiguous revision numbers after this, 262 # and that guarnatee simplifies things considerably, so we don't 263 # support anything before it. 264 assert ( 265 start >= git_llvm_rev.base_llvm_revision 266 ), f"{start} was too long ago" 267 268 with get_llvm_hash.CreateTempLLVMRepo(temp_dir) as new_repo: 269 if not src_path: 270 src_path = new_repo 271 index_step = (end - (start + 1)) // (parallel + 1) 272 if not index_step: 273 index_step = 1 274 revisions = [ 275 rev 276 for rev in range(start + 1, end, index_step) 277 if rev not in pending_revisions and rev not in skip_revisions 278 ] 279 git_hashes = [ 280 get_llvm_hash.GetGitHashFrom(src_path, rev) for rev in revisions 281 ] 282 return revisions, git_hashes 283 284 285def Bisect( 286 revisions, 287 git_hashes, 288 bisect_state, 289 last_tested, 290 update_packages, 291 chroot_path, 292 patch_metadata_file, 293 extra_change_lists, 294 options, 295 builder, 296 verbose, 297): 298 """Adds tryjobs and updates the status file with the new tryjobs.""" 299 300 try: 301 for svn_revision, git_hash in zip(revisions, git_hashes): 302 tryjob_dict = modify_a_tryjob.AddTryjob( 303 update_packages, 304 git_hash, 305 svn_revision, 306 chroot_path, 307 patch_metadata_file, 308 extra_change_lists, 309 options, 310 builder, 311 verbose, 312 svn_revision, 313 ) 314 315 bisect_state["jobs"].append(tryjob_dict) 316 finally: 317 # Do not want to lose progress if there is an exception. 318 if last_tested: 319 new_file = "%s.new" % last_tested 320 with open(new_file, "w") as json_file: 321 json.dump( 322 bisect_state, json_file, indent=4, separators=(",", ": ") 323 ) 324 325 os.rename(new_file, last_tested) 326 327 328def LoadStatusFile(last_tested, start, end): 329 """Loads the status file for bisection.""" 330 331 try: 332 with open(last_tested) as f: 333 return json.load(f) 334 except IOError as err: 335 if err.errno != errno.ENOENT: 336 raise 337 338 return {"start": start, "end": end, "jobs": []} 339 340 341def main(args_output): 342 """Bisects LLVM commits. 343 344 Raises: 345 AssertionError: The script was run inside the chroot. 346 """ 347 348 chroot.VerifyOutsideChroot() 349 patch_metadata_file = "PATCHES.json" 350 start = args_output.start_rev 351 end = args_output.end_rev 352 353 bisect_state = LoadStatusFile(args_output.last_tested, start, end) 354 if start != bisect_state["start"] or end != bisect_state["end"]: 355 raise ValueError( 356 f"The start {start} or the end {end} version provided is " 357 f'different than "start" {bisect_state["start"]} or "end" ' 358 f'{bisect_state["end"]} in the .JSON file' 359 ) 360 361 # Pending and skipped revisions are between 'start_rev' and 'end_rev'. 362 start_rev, end_rev, pending_revs, skip_revs = GetRemainingRange( 363 start, end, bisect_state["jobs"] 364 ) 365 366 revisions, git_hashes = GetCommitsBetween( 367 start_rev, 368 end_rev, 369 args_output.parallel, 370 args_output.src_path, 371 pending_revs, 372 skip_revs, 373 ) 374 375 # No more revisions between 'start_rev' and 'end_rev', so 376 # bisection is complete. 377 # 378 # This is determined by finding all valid revisions between 'start_rev' 379 # and 'end_rev' and that are NOT in the 'pending' and 'skipped' set. 380 if not revisions: 381 if pending_revs: 382 # Some tryjobs are not finished which may change the actual bad 383 # commit/revision when those tryjobs are finished. 384 no_revisions_message = ( 385 f"No revisions between start {start_rev} " 386 f"and end {end_rev} to create tryjobs\n" 387 ) 388 389 if pending_revs: 390 no_revisions_message += ( 391 "The following tryjobs are pending:\n" 392 + "\n".join(str(rev) for rev in pending_revs) 393 + "\n" 394 ) 395 396 if skip_revs: 397 no_revisions_message += ( 398 "The following tryjobs were skipped:\n" 399 + "\n".join(str(rev) for rev in skip_revs) 400 + "\n" 401 ) 402 403 raise ValueError(no_revisions_message) 404 405 print(f"Finished bisecting for {args_output.last_tested}") 406 if args_output.src_path: 407 bad_llvm_hash = get_llvm_hash.GetGitHashFrom( 408 args_output.src_path, end_rev 409 ) 410 else: 411 bad_llvm_hash = get_llvm_hash.LLVMHash().GetLLVMHash(end_rev) 412 print( 413 f"The bad revision is {end_rev} and its commit hash is " 414 f"{bad_llvm_hash}" 415 ) 416 if skip_revs: 417 skip_revs_message = ( 418 "\nThe following revisions were skipped:\n" 419 + "\n".join(str(rev) for rev in skip_revs) 420 ) 421 print(skip_revs_message) 422 423 if args_output.cleanup: 424 # Abandon all the CLs created for bisection 425 gerrit = os.path.join( 426 args_output.chroot_path, "chromite/bin/gerrit" 427 ) 428 for build in bisect_state["jobs"]: 429 try: 430 subprocess.check_output( 431 [gerrit, "abandon", str(build["cl"])], 432 stderr=subprocess.STDOUT, 433 encoding="utf-8", 434 ) 435 except subprocess.CalledProcessError as err: 436 # the CL may have been abandoned 437 if "chromite.lib.gob_util.GOBError" not in err.output: 438 raise 439 440 return BisectionExitStatus.BISECTION_COMPLETE.value 441 442 for rev in revisions: 443 if ( 444 update_tryjob_status.FindTryjobIndex(rev, bisect_state["jobs"]) 445 is not None 446 ): 447 raise ValueError(f'Revision {rev} exists already in "jobs"') 448 449 Bisect( 450 revisions, 451 git_hashes, 452 bisect_state, 453 args_output.last_tested, 454 update_chromeos_llvm_hash.DEFAULT_PACKAGES, 455 args_output.chroot_path, 456 patch_metadata_file, 457 args_output.extra_change_lists, 458 args_output.options, 459 args_output.builder, 460 args_output.verbose, 461 ) 462 463 464if __name__ == "__main__": 465 sys.exit(main(GetCommandLineArgs())) 466