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
30from __future__ import with_statement
31
32import codecs
33import time
34import traceback
35import os
36
37from datetime import datetime
38from optparse import make_option
39from StringIO import StringIO
40
41from webkitpy.common.config.committervalidator import CommitterValidator
42from webkitpy.common.net.bugzilla import Attachment
43from webkitpy.common.net.layouttestresults import LayoutTestResults
44from webkitpy.common.net.statusserver import StatusServer
45from webkitpy.common.system.deprecated_logging import error, log
46from webkitpy.common.system.executive import ScriptError
47from webkitpy.tool.bot.botinfo import BotInfo
48from webkitpy.tool.bot.commitqueuetask import CommitQueueTask, CommitQueueTaskDelegate
49from webkitpy.tool.bot.feeders import CommitQueueFeeder, EWSFeeder
50from webkitpy.tool.bot.queueengine import QueueEngine, QueueEngineDelegate
51from webkitpy.tool.bot.flakytestreporter import FlakyTestReporter
52from webkitpy.tool.commands.stepsequence import StepSequenceErrorHandler
53from webkitpy.tool.steps.runtests import RunTests
54from webkitpy.tool.multicommandtool import Command, TryAgain
55
56
57class 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
167class 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
203class 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
246class 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
391class 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
435class 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