Home | History | Annotate | Download | only in commands
      1 # Copyright (c) 2009 Google Inc. All rights reserved.
      2 # Copyright (c) 2009 Apple Inc. All rights reserved.
      3 #
      4 # Redistribution and use in source and binary forms, with or without
      5 # modification, are permitted provided that the following conditions are
      6 # met:
      7 # 
      8 #     * Redistributions of source code must retain the above copyright
      9 # notice, this list of conditions and the following disclaimer.
     10 #     * Redistributions in binary form must reproduce the above
     11 # copyright notice, this list of conditions and the following disclaimer
     12 # in the documentation and/or other materials provided with the
     13 # distribution.
     14 #     * Neither the name of Google Inc. nor the names of its
     15 # contributors may be used to endorse or promote products derived from
     16 # this software without specific prior written permission.
     17 # 
     18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
     19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
     20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
     21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
     22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
     23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
     24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
     25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
     26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
     28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     29 
     30 from __future__ import with_statement
     31 
     32 import codecs
     33 import time
     34 import traceback
     35 import os
     36 
     37 from datetime import datetime
     38 from optparse import make_option
     39 from StringIO import StringIO
     40 
     41 from webkitpy.common.config.committervalidator import CommitterValidator
     42 from webkitpy.common.net.bugzilla import Attachment
     43 from webkitpy.common.net.layouttestresults import LayoutTestResults
     44 from webkitpy.common.net.statusserver import StatusServer
     45 from webkitpy.common.system.deprecated_logging import error, log
     46 from webkitpy.common.system.executive import ScriptError
     47 from webkitpy.tool.bot.botinfo import BotInfo
     48 from webkitpy.tool.bot.commitqueuetask import CommitQueueTask, CommitQueueTaskDelegate
     49 from webkitpy.tool.bot.feeders import CommitQueueFeeder, EWSFeeder
     50 from webkitpy.tool.bot.queueengine import QueueEngine, QueueEngineDelegate
     51 from webkitpy.tool.bot.flakytestreporter import FlakyTestReporter
     52 from webkitpy.tool.commands.stepsequence import StepSequenceErrorHandler
     53 from webkitpy.tool.steps.runtests import RunTests
     54 from webkitpy.tool.multicommandtool import Command, TryAgain
     55 
     56 
     57 class AbstractQueue(Command, QueueEngineDelegate):
     58     watchers = [
     59     ]
     60 
     61     _pass_status = "Pass"
     62     _fail_status = "Fail"
     63     _retry_status = "Retry"
     64     _error_status = "Error"
     65 
     66     def __init__(self, options=None): # Default values should never be collections (like []) as default values are shared between invocations
     67         options_list = (options or []) + [
     68             make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
     69             make_option("--exit-after-iteration", action="store", type="int", dest="iterations", default=None, help="Stop running the queue after iterating this number of times."),
     70         ]
     71         Command.__init__(self, "Run the %s" % self.name, options=options_list)
     72         self._iteration_count = 0
     73 
     74     def _cc_watchers(self, bug_id):
     75         try:
     76             self._tool.bugs.add_cc_to_bug(bug_id, self.watchers)
     77         except Exception, e:
     78             traceback.print_exc()
     79             log("Failed to CC watchers.")
     80 
     81     def run_webkit_patch(self, args):
     82         webkit_patch_args = [self._tool.path()]
     83         # FIXME: This is a hack, we should have a more general way to pass global options.
     84         # FIXME: We must always pass global options and their value in one argument
     85         # because our global option code looks for the first argument which does
     86         # not begin with "-" and assumes that is the command name.
     87         webkit_patch_args += ["--status-host=%s" % self._tool.status_server.host]
     88         if self._tool.status_server.bot_id:
     89             webkit_patch_args += ["--bot-id=%s" % self._tool.status_server.bot_id]
     90         if self._options.port:
     91             webkit_patch_args += ["--port=%s" % self._options.port]
     92         webkit_patch_args.extend(args)
     93         # FIXME: There is probably no reason to use run_and_throw_if_fail anymore.
     94         # run_and_throw_if_fail was invented to support tee'd output
     95         # (where we write both to a log file and to the console at once),
     96         # but the queues don't need live-progress, a dump-of-output at the
     97         # end should be sufficient.
     98         return self._tool.executive.run_and_throw_if_fail(webkit_patch_args)
     99 
    100     def _log_directory(self):
    101         return os.path.join("..", "%s-logs" % self.name)
    102 
    103     # QueueEngineDelegate methods
    104 
    105     def queue_log_path(self):
    106         return os.path.join(self._log_directory(), "%s.log" % self.name)
    107 
    108     def work_item_log_path(self, work_item):
    109         raise NotImplementedError, "subclasses must implement"
    110 
    111     def begin_work_queue(self):
    112         log("CAUTION: %s will discard all local changes in \"%s\"" % (self.name, self._tool.scm().checkout_root))
    113         if self._options.confirm:
    114             response = self._tool.user.prompt("Are you sure?  Type \"yes\" to continue: ")
    115             if (response != "yes"):
    116                 error("User declined.")
    117         log("Running WebKit %s." % self.name)
    118         self._tool.status_server.update_status(self.name, "Starting Queue")
    119 
    120     def stop_work_queue(self, reason):
    121         self._tool.status_server.update_status(self.name, "Stopping Queue, reason: %s" % reason)
    122 
    123     def should_continue_work_queue(self):
    124         self._iteration_count += 1
    125         return not self._options.iterations or self._iteration_count <= self._options.iterations
    126 
    127     def next_work_item(self):
    128         raise NotImplementedError, "subclasses must implement"
    129 
    130     def should_proceed_with_work_item(self, work_item):
    131         raise NotImplementedError, "subclasses must implement"
    132 
    133     def process_work_item(self, work_item):
    134         raise NotImplementedError, "subclasses must implement"
    135 
    136     def handle_unexpected_error(self, work_item, message):
    137         raise NotImplementedError, "subclasses must implement"
    138 
    139     # Command methods
    140 
    141     def execute(self, options, args, tool, engine=QueueEngine):
    142         self._options = options # FIXME: This code is wrong.  Command.options is a list, this assumes an Options element!
    143         self._tool = tool  # FIXME: This code is wrong too!  Command.bind_to_tool handles this!
    144         return engine(self.name, self, self._tool.wakeup_event).run()
    145 
    146     @classmethod
    147     def _log_from_script_error_for_upload(cls, script_error, output_limit=None):
    148         # We have seen request timeouts with app engine due to large
    149         # log uploads.  Trying only the last 512k.
    150         if not output_limit:
    151             output_limit = 512 * 1024  # 512k
    152         output = script_error.message_with_output(output_limit=output_limit)
    153         # We pre-encode the string to a byte array before passing it
    154         # to status_server, because ClientForm (part of mechanize)
    155         # wants a file-like object with pre-encoded data.
    156         return StringIO(output.encode("utf-8"))
    157 
    158     @classmethod
    159     def _update_status_for_script_error(cls, tool, state, script_error, is_error=False):
    160         message = str(script_error)
    161         if is_error:
    162             message = "Error: %s" % message
    163         failure_log = cls._log_from_script_error_for_upload(script_error)
    164         return tool.status_server.update_status(cls.name, message, state["patch"], failure_log)
    165 
    166 
    167 class FeederQueue(AbstractQueue):
    168     name = "feeder-queue"
    169 
    170     _sleep_duration = 30  # seconds
    171 
    172     # AbstractPatchQueue methods
    173 
    174     def begin_work_queue(self):
    175         AbstractQueue.begin_work_queue(self)
    176         self.feeders = [
    177             CommitQueueFeeder(self._tool),
    178             EWSFeeder(self._tool),
    179         ]
    180 
    181     def next_work_item(self):
    182         # This really show inherit from some more basic class that doesn't
    183         # understand work items, but the base class in the heirarchy currently
    184         # understands work items.
    185         return "synthetic-work-item"
    186 
    187     def should_proceed_with_work_item(self, work_item):
    188         return True
    189 
    190     def process_work_item(self, work_item):
    191         for feeder in self.feeders:
    192             feeder.feed()
    193         time.sleep(self._sleep_duration)
    194         return True
    195 
    196     def work_item_log_path(self, work_item):
    197         return None
    198 
    199     def handle_unexpected_error(self, work_item, message):
    200         log(message)
    201 
    202 
    203 class AbstractPatchQueue(AbstractQueue):
    204     def _update_status(self, message, patch=None, results_file=None):
    205         return self._tool.status_server.update_status(self.name, message, patch, results_file)
    206 
    207     def _next_patch(self):
    208         patch_id = self._tool.status_server.next_work_item(self.name)
    209         if not patch_id:
    210             return None
    211         patch = self._tool.bugs.fetch_attachment(patch_id)
    212         if not patch:
    213             # FIXME: Using a fake patch because release_work_item has the wrong API.
    214             # We also don't really need to release the lock (although that's fine),
    215             # mostly we just need to remove this bogus patch from our queue.
    216             # If for some reason bugzilla is just down, then it will be re-fed later.
    217             patch = Attachment({'id': patch_id}, None)
    218             self._release_work_item(patch)
    219             return None
    220         return patch
    221 
    222     def _release_work_item(self, patch):
    223         self._tool.status_server.release_work_item(self.name, patch)
    224 
    225     def _did_pass(self, patch):
    226         self._update_status(self._pass_status, patch)
    227         self._release_work_item(patch)
    228 
    229     def _did_fail(self, patch):
    230         self._update_status(self._fail_status, patch)
    231         self._release_work_item(patch)
    232 
    233     def _did_retry(self, patch):
    234         self._update_status(self._retry_status, patch)
    235         self._release_work_item(patch)
    236 
    237     def _did_error(self, patch, reason):
    238         message = "%s: %s" % (self._error_status, reason)
    239         self._update_status(message, patch)
    240         self._release_work_item(patch)
    241 
    242     def work_item_log_path(self, patch):
    243         return os.path.join(self._log_directory(), "%s.log" % patch.bug_id())
    244 
    245 
    246 class CommitQueue(AbstractPatchQueue, StepSequenceErrorHandler, CommitQueueTaskDelegate):
    247     name = "commit-queue"
    248 
    249     # AbstractPatchQueue methods
    250 
    251     def begin_work_queue(self):
    252         AbstractPatchQueue.begin_work_queue(self)
    253         self.committer_validator = CommitterValidator(self._tool.bugs)
    254 
    255     def next_work_item(self):
    256         return self._next_patch()
    257 
    258     def should_proceed_with_work_item(self, patch):
    259         patch_text = "rollout patch" if patch.is_rollout() else "patch"
    260         self._update_status("Processing %s" % patch_text, patch)
    261         return True
    262 
    263     # FIXME: This is not really specific to the commit-queue and could be shared.
    264     def _upload_results_archive_for_patch(self, patch, results_archive_zip):
    265         bot_id = self._tool.status_server.bot_id or "bot"
    266         description = "Archive of layout-test-results from %s" % bot_id
    267         # results_archive is a ZipFile object, grab the File object (.fp) to pass to Mechanize for uploading.
    268         results_archive_file = results_archive_zip.fp
    269         # Rewind the file object to start (since Mechanize won't do that automatically)
    270         # See https://bugs.webkit.org/show_bug.cgi?id=54593
    271         results_archive_file.seek(0)
    272         comment_text = "The attached test failures were seen while running run-webkit-tests on the %s.\n" % (self.name)
    273         # FIXME: We could easily list the test failures from the archive here.
    274         comment_text += BotInfo(self._tool).summary_text()
    275         self._tool.bugs.add_attachment_to_bug(patch.bug_id(), results_archive_file, description, filename="layout-test-results.zip", comment_text=comment_text)
    276 
    277     def process_work_item(self, patch):
    278         self._cc_watchers(patch.bug_id())
    279         task = CommitQueueTask(self, patch)
    280         try:
    281             if task.run():
    282                 self._did_pass(patch)
    283                 return True
    284             self._did_retry(patch)
    285         except ScriptError, e:
    286             validator = CommitterValidator(self._tool.bugs)
    287             validator.reject_patch_from_commit_queue(patch.id(), self._error_message_for_bug(task.failure_status_id, e))
    288             results_archive = task.results_archive_from_patch_test_run(patch)
    289             if results_archive:
    290                 self._upload_results_archive_for_patch(patch, results_archive)
    291             self._did_fail(patch)
    292 
    293     def _error_message_for_bug(self, status_id, script_error):
    294         if not script_error.output:
    295             return script_error.message_with_output()
    296         results_link = self._tool.status_server.results_url_for_status(status_id)
    297         return "%s\nFull output: %s" % (script_error.message_with_output(), results_link)
    298 
    299     def handle_unexpected_error(self, patch, message):
    300         self.committer_validator.reject_patch_from_commit_queue(patch.id(), message)
    301 
    302     # CommitQueueTaskDelegate methods
    303 
    304     def run_command(self, command):
    305         self.run_webkit_patch(command)
    306 
    307     def command_passed(self, message, patch):
    308         self._update_status(message, patch=patch)
    309 
    310     def command_failed(self, message, script_error, patch):
    311         failure_log = self._log_from_script_error_for_upload(script_error)
    312         return self._update_status(message, patch=patch, results_file=failure_log)
    313 
    314     # FIXME: This exists for mocking, but should instead be mocked via
    315     # tool.filesystem.read_text_file.  They have different error handling at the moment.
    316     def _read_file_contents(self, path):
    317         try:
    318             return self._tool.filesystem.read_text_file(path)
    319         except IOError, e:  # File does not exist or can't be read.
    320             return None
    321 
    322     # FIXME: This logic should move to the port object.
    323     def _create_layout_test_results(self):
    324         results_path = self._tool.port().layout_tests_results_path()
    325         results_html = self._read_file_contents(results_path)
    326         if not results_html:
    327             return None
    328         return LayoutTestResults.results_from_string(results_html)
    329 
    330     def layout_test_results(self):
    331         results = self._create_layout_test_results()
    332         # FIXME: We should not have to set failure_limit_count, but we
    333         # do until run-webkit-tests can be updated save off the value
    334         # of --exit-after-N-failures in results.html/results.json.
    335         # https://bugs.webkit.org/show_bug.cgi?id=58481
    336         if results:
    337             results.set_failure_limit_count(RunTests.NON_INTERACTIVE_FAILURE_LIMIT_COUNT)
    338         return results
    339 
    340     def _results_directory(self):
    341         results_path = self._tool.port().layout_tests_results_path()
    342         # FIXME: This is wrong in two ways:
    343         # 1. It assumes that results.html is at the top level of the results tree.
    344         # 2. This uses the "old" ports.py infrastructure instead of the new layout_tests/port
    345         # which will not support Chromium.  However the new arch doesn't work with old-run-webkit-tests
    346         # so we have to use this for now.
    347         return os.path.dirname(results_path)
    348 
    349     def archive_last_layout_test_results(self, patch):
    350         results_directory = self._results_directory()
    351         results_name, _ = os.path.splitext(os.path.basename(results_directory))
    352         # Note: We name the zip with the bug_id instead of patch_id to match work_item_log_path().
    353         zip_path = self._tool.workspace.find_unused_filename(self._log_directory(), "%s-%s" % (patch.bug_id(), results_name), "zip")
    354         if not zip_path:
    355             return None
    356         archive = self._tool.workspace.create_zip(zip_path, results_directory)
    357         # Remove the results directory to prevent http logs, etc. from getting huge between runs.
    358         # We could have create_zip remove the original, but this is more explicit.
    359         self._tool.filesystem.rmtree(results_directory)
    360         return archive
    361 
    362     def refetch_patch(self, patch):
    363         return self._tool.bugs.fetch_attachment(patch.id())
    364 
    365     def report_flaky_tests(self, patch, flaky_test_results, results_archive=None):
    366         reporter = FlakyTestReporter(self._tool, self.name)
    367         reporter.report_flaky_tests(patch, flaky_test_results, results_archive)
    368 
    369     # StepSequenceErrorHandler methods
    370 
    371     def handle_script_error(cls, tool, state, script_error):
    372         # Hitting this error handler should be pretty rare.  It does occur,
    373         # however, when a patch no longer applies to top-of-tree in the final
    374         # land step.
    375         log(script_error.message_with_output())
    376 
    377     @classmethod
    378     def handle_checkout_needs_update(cls, tool, state, options, error):
    379         message = "Tests passed, but commit failed (checkout out of date).  Updating, then landing without building or re-running tests."
    380         tool.status_server.update_status(cls.name, message, state["patch"])
    381         # The only time when we find out that out checkout needs update is
    382         # when we were ready to actually pull the trigger and land the patch.
    383         # Rather than spinning in the master process, we retry without
    384         # building or testing, which is much faster.
    385         options.build = False
    386         options.test = False
    387         options.update = True
    388         raise TryAgain()
    389 
    390 
    391 class AbstractReviewQueue(AbstractPatchQueue, StepSequenceErrorHandler):
    392     """This is the base-class for the EWS queues and the style-queue."""
    393     def __init__(self, options=None):
    394         AbstractPatchQueue.__init__(self, options)
    395 
    396     def review_patch(self, patch):
    397         raise NotImplementedError("subclasses must implement")
    398 
    399     # AbstractPatchQueue methods
    400 
    401     def begin_work_queue(self):
    402         AbstractPatchQueue.begin_work_queue(self)
    403 
    404     def next_work_item(self):
    405         return self._next_patch()
    406 
    407     def should_proceed_with_work_item(self, patch):
    408         raise NotImplementedError("subclasses must implement")
    409 
    410     def process_work_item(self, patch):
    411         try:
    412             if not self.review_patch(patch):
    413                 return False
    414             self._did_pass(patch)
    415             return True
    416         except ScriptError, e:
    417             if e.exit_code != QueueEngine.handled_error_code:
    418                 self._did_fail(patch)
    419             else:
    420                 # The subprocess handled the error, but won't have released the patch, so we do.
    421                 # FIXME: We need to simplify the rules by which _release_work_item is called.
    422                 self._release_work_item(patch)
    423             raise e
    424 
    425     def handle_unexpected_error(self, patch, message):
    426         log(message)
    427 
    428     # StepSequenceErrorHandler methods
    429 
    430     @classmethod
    431     def handle_script_error(cls, tool, state, script_error):
    432         log(script_error.message_with_output())
    433 
    434 
    435 class StyleQueue(AbstractReviewQueue):
    436     name = "style-queue"
    437     def __init__(self):
    438         AbstractReviewQueue.__init__(self)
    439 
    440     def should_proceed_with_work_item(self, patch):
    441         self._update_status("Checking style", patch)
    442         return True
    443 
    444     def review_patch(self, patch):
    445         self.run_webkit_patch(["check-style", "--force-clean", "--non-interactive", "--parent-command=style-queue", patch.id()])
    446         return True
    447 
    448     @classmethod
    449     def handle_script_error(cls, tool, state, script_error):
    450         is_svn_apply = script_error.command_name() == "svn-apply"
    451         status_id = cls._update_status_for_script_error(tool, state, script_error, is_error=is_svn_apply)
    452         if is_svn_apply:
    453             QueueEngine.exit_after_handled_error(script_error)
    454         message = "Attachment %s did not pass %s:\n\n%s\n\nIf any of these errors are false positives, please file a bug against check-webkit-style." % (state["patch"].id(), cls.name, script_error.message_with_output(output_limit=3*1024))
    455         tool.bugs.post_comment_to_bug(state["patch"].bug_id(), message, cc=cls.watchers)
    456         exit(1)
    457