| #!/usr/bin/env python3 |
| # -*- coding: utf-8 -*- |
| # Copyright 2019 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Performs bisection on LLVM based off a .JSON file.""" |
| |
| from __future__ import print_function |
| |
| import argparse |
| import enum |
| import errno |
| import json |
| import os |
| import subprocess |
| import sys |
| |
| import chroot |
| import get_llvm_hash |
| import git_llvm_rev |
| import modify_a_tryjob |
| import update_tryjob_status |
| |
| |
| class BisectionExitStatus(enum.Enum): |
| """Exit code when performing bisection.""" |
| |
| # Means that there are no more revisions available to bisect. |
| BISECTION_COMPLETE = 126 |
| |
| |
| def GetCommandLineArgs(): |
| """Parses the command line for the command line arguments.""" |
| |
| # Default path to the chroot if a path is not specified. |
| cros_root = os.path.expanduser('~') |
| cros_root = os.path.join(cros_root, 'chromiumos') |
| |
| # Create parser and add optional command-line arguments. |
| parser = argparse.ArgumentParser( |
| description='Bisects LLVM via tracking a JSON file.') |
| |
| # Add argument for other change lists that want to run alongside the tryjob |
| # which has a change list of updating a package's git hash. |
| parser.add_argument( |
| '--parallel', |
| type=int, |
| default=3, |
| help='How many tryjobs to create between the last good version and ' |
| 'the first bad version (default: %(default)s)') |
| |
| # Add argument for the good LLVM revision for bisection. |
| parser.add_argument( |
| '--start_rev', |
| required=True, |
| type=int, |
| help='The good revision for the bisection.') |
| |
| # Add argument for the bad LLVM revision for bisection. |
| parser.add_argument( |
| '--end_rev', |
| required=True, |
| type=int, |
| help='The bad revision for the bisection.') |
| |
| # Add argument for the absolute path to the file that contains information on |
| # the previous tested svn version. |
| parser.add_argument( |
| '--last_tested', |
| required=True, |
| help='the absolute path to the file that contains the tryjobs') |
| |
| # Add argument for the absolute path to the LLVM source tree. |
| parser.add_argument( |
| '--src_path', |
| help='the path to the LLVM source tree to use (used for retrieving the ' |
| 'git hash of each version between the last good version and first bad ' |
| 'version)') |
| |
| # Add argument for other change lists that want to run alongside the tryjob |
| # which has a change list of updating a package's git hash. |
| parser.add_argument( |
| '--extra_change_lists', |
| type=int, |
| nargs='+', |
| help='change lists that would like to be run alongside the change list ' |
| 'of updating the packages') |
| |
| # Add argument for custom options for the tryjob. |
| parser.add_argument( |
| '--options', |
| required=False, |
| nargs='+', |
| help='options to use for the tryjob testing') |
| |
| # Add argument for the builder to use for the tryjob. |
| parser.add_argument( |
| '--builder', required=True, help='builder to use for the tryjob testing') |
| |
| # Add argument for the description of the tryjob. |
| parser.add_argument( |
| '--description', |
| required=False, |
| nargs='+', |
| help='the description of the tryjob') |
| |
| # Add argument for a specific chroot path. |
| parser.add_argument( |
| '--chroot_path', |
| default=cros_root, |
| help='the path to the chroot (default: %(default)s)') |
| |
| # Add argument for whether to display command contents to `stdout`. |
| parser.add_argument( |
| '--verbose', |
| action='store_true', |
| help='display contents of a command to the terminal ' |
| '(default: %(default)s)') |
| |
| # Add argument for whether to display command contents to `stdout`. |
| parser.add_argument( |
| '--nocleanup', |
| action='store_false', |
| dest='cleanup', |
| help='Abandon CLs created for bisectoin') |
| |
| args_output = parser.parse_args() |
| |
| assert args_output.start_rev < args_output.end_rev, ( |
| 'Start revision %d is >= end revision %d' % |
| (args_output.start_rev, args_output.end_rev)) |
| |
| if args_output.last_tested and not args_output.last_tested.endswith('.json'): |
| raise ValueError('Filed provided %s does not end in ".json"' % |
| args_output.last_tested) |
| |
| return args_output |
| |
| |
| def GetRemainingRange(start, end, tryjobs): |
| """Gets the start and end intervals in 'json_file'. |
| |
| Args: |
| start: The start version of the bisection provided via the command line. |
| end: The end version of the bisection provided via the command line. |
| tryjobs: A list of tryjobs where each element is in the following format: |
| [ |
| {[TRYJOB_INFORMATION]}, |
| {[TRYJOB_INFORMATION]}, |
| ..., |
| {[TRYJOB_INFORMATION]} |
| ] |
| |
| Returns: |
| The new start version and end version for bisection, a set of revisions |
| that are 'pending' and a set of revisions that are to be skipped. |
| |
| Raises: |
| ValueError: The value for 'status' is missing or there is a mismatch |
| between 'start' and 'end' compared to the 'start' and 'end' in the JSON |
| file. |
| AssertionError: The new start version is >= than the new end version. |
| """ |
| |
| if not tryjobs: |
| return start, end, {}, {} |
| |
| # Verify that each tryjob has a value for the 'status' key. |
| for cur_tryjob_dict in tryjobs: |
| if not cur_tryjob_dict.get('status', None): |
| raise ValueError('"status" is missing or has no value, please ' |
| 'go to %s and update it' % cur_tryjob_dict['link']) |
| |
| all_bad_revisions = [end] |
| all_bad_revisions.extend( |
| cur_tryjob['rev'] |
| for cur_tryjob in tryjobs |
| if cur_tryjob['status'] == update_tryjob_status.TryjobStatus.BAD.value) |
| |
| # The minimum value for the 'bad' field in the tryjobs is the new end |
| # version. |
| bad_rev = min(all_bad_revisions) |
| |
| all_good_revisions = [start] |
| all_good_revisions.extend( |
| cur_tryjob['rev'] |
| for cur_tryjob in tryjobs |
| if cur_tryjob['status'] == update_tryjob_status.TryjobStatus.GOOD.value) |
| |
| # The maximum value for the 'good' field in the tryjobs is the new start |
| # version. |
| good_rev = max(all_good_revisions) |
| |
| # The good version should always be strictly less than the bad version; |
| # otherwise, bisection is broken. |
| assert good_rev < bad_rev, ('Bisection is broken because %d (good) is >= ' |
| '%d (bad)' % (good_rev, bad_rev)) |
| |
| # Find all revisions that are 'pending' within 'good_rev' and 'bad_rev'. |
| # |
| # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev' |
| # that have already been launched (this set is used when constructing the |
| # list of revisions to launch tryjobs for). |
| pending_revisions = { |
| tryjob['rev'] |
| for tryjob in tryjobs |
| if tryjob['status'] == update_tryjob_status.TryjobStatus.PENDING.value and |
| good_rev < tryjob['rev'] < bad_rev |
| } |
| |
| # Find all revisions that are to be skipped within 'good_rev' and 'bad_rev'. |
| # |
| # NOTE: The intent is to not launch tryjobs between 'good_rev' and 'bad_rev' |
| # that have already been marked as 'skip' (this set is used when constructing |
| # the list of revisions to launch tryjobs for). |
| skip_revisions = { |
| tryjob['rev'] |
| for tryjob in tryjobs |
| if tryjob['status'] == update_tryjob_status.TryjobStatus.SKIP.value and |
| good_rev < tryjob['rev'] < bad_rev |
| } |
| |
| return good_rev, bad_rev, pending_revisions, skip_revisions |
| |
| |
| def GetCommitsBetween(start, end, parallel, src_path, pending_revisions, |
| skip_revisions): |
| """Determines the revisions between start and end.""" |
| |
| with get_llvm_hash.LLVMHash().CreateTempDirectory() as temp_dir: |
| # We have guaranteed contiguous revision numbers after this, |
| # and that guarnatee simplifies things considerably, so we don't |
| # support anything before it. |
| assert start >= git_llvm_rev.base_llvm_revision, f'{start} was too long ago' |
| |
| with get_llvm_hash.CreateTempLLVMRepo(temp_dir) as new_repo: |
| if not src_path: |
| src_path = new_repo |
| index_step = (end - (start + 1)) // (parallel + 1) |
| if not index_step: |
| index_step = 1 |
| revisions = [ |
| rev for rev in range(start + 1, end, index_step) |
| if rev not in pending_revisions and rev not in skip_revisions |
| ] |
| git_hashes = [ |
| get_llvm_hash.GetGitHashFrom(src_path, rev) for rev in revisions |
| ] |
| return revisions, git_hashes |
| |
| |
| def Bisect(revisions, git_hashes, bisect_state, last_tested, update_packages, |
| chroot_path, patch_metadata_file, extra_change_lists, options, |
| builder, verbose): |
| """Adds tryjobs and updates the status file with the new tryjobs.""" |
| |
| try: |
| for svn_revision, git_hash in zip(revisions, git_hashes): |
| tryjob_dict = modify_a_tryjob.AddTryjob(update_packages, git_hash, |
| svn_revision, chroot_path, |
| patch_metadata_file, |
| extra_change_lists, options, |
| builder, verbose, svn_revision) |
| |
| bisect_state['jobs'].append(tryjob_dict) |
| finally: |
| # Do not want to lose progress if there is an exception. |
| if last_tested: |
| new_file = '%s.new' % last_tested |
| with open(new_file, 'w') as json_file: |
| json.dump(bisect_state, json_file, indent=4, separators=(',', ': ')) |
| |
| os.rename(new_file, last_tested) |
| |
| |
| def LoadStatusFile(last_tested, start, end): |
| """Loads the status file for bisection.""" |
| |
| try: |
| with open(last_tested) as f: |
| return json.load(f) |
| except IOError as err: |
| if err.errno != errno.ENOENT: |
| raise |
| |
| return {'start': start, 'end': end, 'jobs': []} |
| |
| |
| def main(args_output): |
| """Bisects LLVM commits. |
| |
| Raises: |
| AssertionError: The script was run inside the chroot. |
| """ |
| |
| chroot.VerifyOutsideChroot() |
| update_packages = [ |
| 'sys-devel/llvm', 'sys-libs/compiler-rt', 'sys-libs/libcxx', |
| 'sys-libs/libcxxabi', 'sys-libs/llvm-libunwind' |
| ] |
| patch_metadata_file = 'PATCHES.json' |
| start = args_output.start_rev |
| end = args_output.end_rev |
| |
| bisect_state = LoadStatusFile(args_output.last_tested, start, end) |
| if start != bisect_state['start'] or end != bisect_state['end']: |
| raise ValueError(f'The start {start} or the end {end} version provided is ' |
| f'different than "start" {bisect_state["start"]} or "end" ' |
| f'{bisect_state["end"]} in the .JSON file') |
| |
| # Pending and skipped revisions are between 'start_revision' and |
| # 'end_revision'. |
| start_revision, end_revision, pending_revisions, skip_revisions = \ |
| GetRemainingRange(start, end, bisect_state['jobs']) |
| |
| revisions, git_hashes = GetCommitsBetween(start_revision, end_revision, |
| args_output.parallel, |
| args_output.src_path, |
| pending_revisions, skip_revisions) |
| |
| # No more revisions between 'start_revision' and 'end_revision', so |
| # bisection is complete. |
| # |
| # This is determined by finding all valid revisions between 'start_revision' |
| # and 'end_revision' and that are NOT in the 'pending' and 'skipped' set. |
| if not revisions: |
| if pending_revisions: |
| # Some tryjobs are not finished which may change the actual bad |
| # commit/revision when those tryjobs are finished. |
| no_revisions_message = (f'No revisions between start {start_revision} ' |
| f'and end {end_revision} to create tryjobs\n') |
| |
| if pending_revisions: |
| no_revisions_message += ( |
| 'The following tryjobs are pending:\n' + |
| '\n'.join(str(rev) for rev in pending_revisions) + '\n') |
| |
| if skip_revisions: |
| no_revisions_message += ('The following tryjobs were skipped:\n' + |
| '\n'.join(str(rev) for rev in skip_revisions) + |
| '\n') |
| |
| raise ValueError(no_revisions_message) |
| |
| print(f'Finished bisecting for {args_output.last_tested}') |
| if args_output.src_path: |
| bad_llvm_hash = get_llvm_hash.GetGitHashFrom(args_output.src_path, |
| end_revision) |
| else: |
| bad_llvm_hash = get_llvm_hash.LLVMHash().GetLLVMHash(end_revision) |
| print(f'The bad revision is {end_revision} and its commit hash is ' |
| f'{bad_llvm_hash}') |
| if skip_revisions: |
| skip_revisions_message = ('\nThe following revisions were skipped:\n' + |
| '\n'.join(str(rev) for rev in skip_revisions)) |
| print(skip_revisions_message) |
| |
| if args_output.cleanup: |
| # Abandon all the CLs created for bisection |
| gerrit = os.path.join(args_output.chroot_path, 'chromite/bin/gerrit') |
| for build in bisect_state['jobs']: |
| try: |
| subprocess.check_output( |
| [gerrit, 'abandon', str(build['cl'])], |
| stderr=subprocess.STDOUT, |
| encoding='utf-8') |
| except subprocess.CalledProcessError as err: |
| # the CL may have been abandoned |
| if 'chromite.lib.gob_util.GOBError' not in err.output: |
| raise |
| |
| return BisectionExitStatus.BISECTION_COMPLETE.value |
| |
| for rev in revisions: |
| if update_tryjob_status.FindTryjobIndex(rev, |
| bisect_state['jobs']) is not None: |
| raise ValueError(f'Revision {rev} exists already in "jobs"') |
| |
| Bisect(revisions, git_hashes, bisect_state, args_output.last_tested, |
| update_packages, args_output.chroot_path, patch_metadata_file, |
| args_output.extra_change_lists, args_output.options, |
| args_output.builder, args_output.verbose) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(GetCommandLineArgs())) |