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