• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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