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