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