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