#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Copyright 2019 The ChromiumOS Authors # 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.""" 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_chromeos_llvm_hash 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() 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_rev' and 'end_rev'. start_rev, end_rev, pending_revs, skip_revs = GetRemainingRange( start, end, bisect_state["jobs"] ) revisions, git_hashes = GetCommitsBetween( start_rev, end_rev, args_output.parallel, args_output.src_path, pending_revs, skip_revs, ) # No more revisions between 'start_rev' and 'end_rev', so # bisection is complete. # # This is determined by finding all valid revisions between 'start_rev' # and 'end_rev' and that are NOT in the 'pending' and 'skipped' set. if not revisions: if pending_revs: # 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_rev} " f"and end {end_rev} to create tryjobs\n" ) if pending_revs: no_revisions_message += ( "The following tryjobs are pending:\n" + "\n".join(str(rev) for rev in pending_revs) + "\n" ) if skip_revs: no_revisions_message += ( "The following tryjobs were skipped:\n" + "\n".join(str(rev) for rev in skip_revs) + "\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_rev ) else: bad_llvm_hash = get_llvm_hash.LLVMHash().GetLLVMHash(end_rev) print( f"The bad revision is {end_rev} and its commit hash is " f"{bad_llvm_hash}" ) if skip_revs: skip_revs_message = ( "\nThe following revisions were skipped:\n" + "\n".join(str(rev) for rev in skip_revs) ) print(skip_revs_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_chromeos_llvm_hash.DEFAULT_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()))