blob: 67f687849dd4ec4654f4fb743537db1004b3ffe1 [file] [log] [blame]
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import cgi
import collections
import json
import logging
import re
import common
from autotest_lib.client.common_lib import global_config
from autotest_lib.server.cros.dynamic_suite import job_status
from autotest_lib.site_utils import phapi_lib
from autotest_lib.site_utils.suite_scheduler import base_event
# Try importing the essential bug reporting libraries. Chromite and gdata_lib
# are useless unless they can import gdata too.
try:
__import__('chromite')
__import__('gdata')
except ImportError, e:
fundamental_libs = False
logging.info('Bug filing disabled. %s', e)
else:
from chromite.lib import cros_build_lib, gdata_lib, gs
from gdata import client
fundamental_libs = True
BUG_CONFIG_SECTION = 'BUG_REPORTING'
class TestFailure(object):
"""
Wrap up all information needed to make an intelligent report about a
test failure. Each TestFailure has a search marker associated with it
that can be used to find reports of the same error.
"""
# global configurations needed for build artifacts
_gs_domain = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'gs_domain', default='')
_chromeos_image_archive = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'chromeos_image_archive', default='')
_arg_prefix = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'arg_prefix', default='')
# global configurations needed for results log
_retrieve_logs_cgi = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'retrieve_logs_cgi', default='')
_generic_results_bin = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'generic_results_bin', default='')
_debug_dir = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'debug_dir', default='')
# gs prefix to perform file like operations (gs://)
_gs_file_prefix = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'gs_file_prefix', default='')
# global configurations needed for buildbot stages link
_buildbot_builders = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'buildbot_builders', default='')
_build_prefix = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'build_prefix', default='')
# Number of times to retry if a gs command fails. Defaults to 10,
# which is far too long given that we already wait on these files
# before starting HWTests.
_GS_RETRIES = 1
_HTTP_ERROR_THRESHOLD = 400
def __init__(self, build, suite, result):
"""
@param build The build type, of the form <board>/<milestone>-<release>.
ie. x86-mario-release/R25-4321.0.0
@param suite The name of the suite that this test run was a part of.
@param result: The status of the job associated with this failure.
This contains the status, job id, test name, hostname
and reason for failure.
"""
self.build = build
self.suite = suite
self.test = result.test_name
self.reason = result.reason
self.owner = result.owner
self.hostname = result.hostname
self.job_id = result.id
# Aborts, server/client job failures or a test failure without a
# reason field need lab attention. Errors with a reason are deemed
# worse than Errors without one, and since Errors themselves don't
# require special attention the candidate status we create needs a
# reason.
self.lab_error = (job_status.is_for_infrastructure_fail(result) or
result.is_worse_than(job_status.Status('ERROR', '', 'reason'))
or not result.reason)
def bug_title(self):
"""Combines information about this failure into a title string."""
return '[%s] %s failed on %s' % (self.suite, self.test, self.build)
def bug_summary(self):
"""Combines information about this failure into a summary string."""
links = self._get_links_for_failure()
summary = ('This bug has been automatically filed to track the '
'following failure:\nTest: %(test)s.\nSuite: %(suite)s.\n'
'Build: %(build)s.\n\nReason:\n%(reason)s.\n\n'
'build artifacts: %(build_artifacts)s.\n'
'results log: %(results_log)s.\n'
'buildbot stages: %(buildbot_stages)s.\n')
specifics = {
'test': self.test,
'suite': self.suite,
'build': self.build,
'reason': self.reason,
'build_artifacts': links.artifacts,
'results_log': links.results,
'buildbot_stages': links.buildbot,
}
return summary % specifics
def search_marker(self):
"""Return an Anchor that we can use to dedupe this exact failure."""
return "%s(%s,%s,%s)" % ('TestFailure', self.suite,
self.test, self.reason)
def get_milestone(self):
"""Parses the build string and returns a milestone."""
try:
return 'M-%s'% base_event.ParseBuildName(self.build)[2]
except base_event.ParseBuildNameException as e:
logging.error(e)
return ''
def _link_build_artifacts(self):
"""Returns an url to build artifacts on google storage."""
return (self._gs_domain + self._arg_prefix +
self._chromeos_image_archive + self.build)
def _link_result_logs(self):
"""Returns an url to test logs on google storage."""
if self.job_id and self.owner and self.hostname:
path_to_object = '%s-%s/%s/%s' % (self.job_id, self.owner,
self.hostname, self._debug_dir)
return (self._retrieve_logs_cgi + self._generic_results_bin +
path_to_object)
return 'NA'
def _get_metadata_dict(self):
"""
Get a dictionary of metadata related to this failure.
Metadata.json is created in the HWTest Archiving stage, if this file
isn't found the call to Cat will timeout after the number of retries
specified in the GSContext object. If metadata.json exists we parse
a json string of it's contents into a dictionary, which we return.
@return: a dictionary with the contents of metadata.json.
"""
if not fundamental_libs:
return
try:
gs_context = gs.GSContext(retries=self._GS_RETRIES)
gs_cmd = '%s%s%s/metadata.json' % (self._gs_file_prefix,
self._chromeos_image_archive,
self.build)
return json.loads(gs_context.Cat(gs_cmd).output)
except cros_build_lib.RunCommandError, e:
logging.debug(e)
def _link_buildbot_stages(self):
"""
Link to the buildbot page associated with this run of HWTests.
@return: A link to the buildbot stages page, or 'NA' if we cannot glean
enough information from metadata.json (or it doesn't exist).
"""
metadata = self._get_metadata_dict()
if (metadata and
metadata.get('builder-name') and
metadata.get('build-number')):
return ('%s%s/builds/%s' %
(self._buildbot_builders,
metadata.get('builder-name'),
metadata.get('build-number'))).replace(' ', '%20')
return 'NA'
def _get_links_for_failure(self):
"""Returns a named tuple of links related to this failure."""
links = collections.namedtuple('links', ('results,'
'artifacts,'
'buildbot'))
return links(self._link_result_logs(),
self._link_build_artifacts(),
self._link_buildbot_stages())
class Reporter(object):
"""
Files external reports about bug failures that happened inside
autotest.
"""
_project_name = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'project_name', default='')
_username = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'username', default='')
_password = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'password', default='')
_SEARCH_MARKER = 'ANCHOR '
_api_key = global_config.global_config.get_config_value(
BUG_CONFIG_SECTION, 'api_key', default='')
_PREDEFINED_LABELS = ['autofiled', 'OS-Chrome',
'Type-Bug', 'Restrict-View-EditIssue']
_OWNER = 'beeps@chromium.org'
_LAB_ERROR_TEMPLATE = {
'labels': ['Hardware-Lab'],
'owner': _OWNER,
}
def _get_tracker(self, project, user, password):
"""Returns an initialized tracker object."""
if project and user and password:
creds = gdata_lib.Creds()
creds.SetCreds(user, password)
tracker = gdata_lib.TrackerComm()
tracker.Connect(creds, project)
return tracker
logging.error('Tracker auth not set up in shadow_config.ini, '
'cannot file bugs.')
return None
def __init__(self):
if not fundamental_libs:
logging.warning("Bug filing disabled due to missing imports.")
return
self._tracker = self._get_tracker(self._project_name,
self._username, self._password)
self._phapi_client = phapi_lib.ProjectHostingApiClient(
self._api_key,
self._project_name)
def _check_tracker(self):
"""Returns True if we have a tracker object to use for filing bugs."""
return fundamental_libs and self._tracker
def _get_owner(self, failure):
"""
Returns an owner for the given failure.
@param failure: A failure object for which a bug is about to get filed.
@return: A string with the email address of the owner of this failure.
The issue associated with the failure will get assigned to the
owner and they will receive an email from the bug tracker. If
there is no obvious owner for the failure an empty string is
returned.
"""
if failure.lab_error:
return self._OWNER
return ''
def _get_labels(self, test_name):
"""
Creates labels for an issue.
Does a simple check to see if any of the areas listed in the
projects pre-defined labels are embedded in the name of the
failing test.
@param test_name: name of the failing test.
@return: a list of labels.
"""
def match_area(test_name, test_area):
"""
Matches the prefix of a test name to an area, and then the suffix
of the area (if any) to the test name. Both parts of the test name
don't need to be in the area, this function prioritizes the first
half of the test name. A helical match is needed to allow situations
like the third example:
kernel_Video matches kernel, kernel-video but not kernel-audio.
network_Ping matches Systems-Network.
kernel_ConfigVerify matches kernel, but would also match both
kernel-config and kernel-verify.
@param test_name: lower case test name.
@param test_area: lower case Cr-OS area from tracker.
"""
return (test_name and test_name[:test_name.find('_')] in test_area
and (not '-' in test_area or
test_area[test_area.find('-')+1:] in test_name))
cros_areas = self._phapi_client.get_areas()
return ['Cr-OS-%s' % area for area in cros_areas
if match_area(test_name, area.lower())]
def _resolve_slotvals(self, override, **kwargs):
"""
Override the default issue configuration with a suite specific
configuration when one is specified in the suite's bug_template.
The bug_template is specified in the suite control file.
TODO(beeps): crbug.com/226124. Modify gdata_lib to support cclist,
explore the possibility of specifying comments, id in the template.
@param override: Suite specific dictionary with issue config operations.
@param kwargs: Keyword args containing the default issue config options.
@return: A dictionary which contains the suite specific options, and the
default option when a suite specific option isn't specified.
"""
if override:
kwargs.update((k,v) for k,v in override.iteritems() if v)
return kwargs
def _create_bug_report(self, summary, title, name, owner, milestone='',
bug_template={}):
"""
Creates a new bug report.
@param summary: A summary of the failure.
@param title: Title of the bug.
@param name: Failing Test name, used to assigning labels.
@param owner: The owner of the new bug.
@return: id of the created issue.
"""
issue_options = self._resolve_slotvals(
bug_template, title=title,
summary=summary, labels=self._get_labels(name.lower()),
status='Untriaged', owner=owner)
issue_options['labels'] = set(issue_options['labels'] +
self._PREDEFINED_LABELS +
[milestone])
issue = gdata_lib.Issue(**issue_options)
bugid = self._tracker.CreateTrackerIssue(issue)
logging.info('Filing new bug %s, with summary %s', bugid, summary)
# The tracker api will not allow us to assign an owner to a new bug,
# To work around this we must first create a bug and then update it
# with an owner. crbug.com/221757.
if issue_options['owner']:
self._modify_bug_report(issue.id, owner=issue_options['owner'])
return issue.id
def _modify_bug_report(self, issue_id, comment='', owner=''):
"""
Modifies an existing bug report with a new comment or owner.
We'll catch a RequestError in at least the following cases:
1. If the bug report isn't really updated.
Eg: Update a bug with the same owner it's assigned to, without any
comment.
2. If the new owner of the bug is invalid:
Eg: owner='beeps@'.
Note owner='---' will un-assign without an exception on the chromium
tracker; owner='' will not un-assign an issue. New issues created with
owner='' will automatically get an owner='---'.
@param issue_id: Id of the issue to update with.
@param comment: Comment to update the issue with.
@param owner: Owner the issue with issue_id needs to get assigned to.
"""
try:
self._tracker.AppendTrackerIssueById(issue_id, comment, owner)
except client.RequestError as e:
logging.debug(e)
def _find_issue_by_marker(self, marker):
"""
Queries the tracker to find if there is a bug filed for this issue.
1. 'Escape' the string: cgi.escape is the easiest way to achieve this,
though it doesn't handle all html escape characters.
eg: replace '"<' with '&quot;&lt;'
2. Perform an exact search for the escaped string, if this returns an
empty issue list perform a more relaxed query and finally fall back
to a query devoid of the reason field. Between these 3 queries we
should retrieve the super set of all issues that this marker can be
in. In most cases the first search should return a result, examples
where this might not be the case are when the reason field contains
information that varies between test runs. Since the second search
has raw escape characters it will match comments too, and the last
should match all similar issues regardless.
3. Look through the issues for an exact match between clean versions
of the marker and summary; for now 'clean' means bereft of numbers.
4. If no match is found look through a list of comments for each issue.
@param marker The marker string to search for to find a duplicate of
this issue.
@return A gdata_lib.Issue instance of the issue that was found, or
None if no issue was found.
"""
html_escaped_marker = cgi.escape(marker, quote=True)
# The tracker frontend stores summaries and comments as html elements,
# specifically, a summary turns into a span and a comment into
# preformatted text. Eg:
# 1. A summary of >& would become <span>&gt;&amp;</span>
# 2. A comment of >& would become <pre>&gt;&amp;</pre>
# When searching for exact matches in text, the gdata api gets this
# feed and parses all <pre> tags unescaping html, then matching your
# exact string to that. However it does not unescape all <span> tags,
# presumably for reasons of performance. Therefore a search for the
# exact string ">&" would match issue 2, but not issue 1, and a search
# for "&gt;&amp;" would match issue 1 but not issue 2. This problem is
# further exacerbated when we have quotes within our search string,
# which is common when the reason field contains a python dictionary.
#
# Our searching strategy prioritizes exact matches in the summary, since
# the first bug thats filed will have a summary with the anchor. If we
# do not find an exact match in any summary we search through all
# related issues of the same bug/suite in the hope of finding an exact
# match in the comments. Note that the comments are returned as
# unescaped text.
#
# TODO beeps: when we start merging issues this could return bloated
# results, for now we only search open issues.
markers = ['"' + self._SEARCH_MARKER + html_escaped_marker + '"',
self._SEARCH_MARKER + marker,
self._SEARCH_MARKER + marker[:marker.rfind(',')]]
for decorated_marker in markers:
# This will return at most 25 matches, as that's how the
# code.google.com API limits this query.
issues = self._tracker.GetTrackerIssuesByText(decorated_marker)
if issues:
break
if not issues:
return
# Breadth first, since open issues/failure probably < comments/issue.
# If we find more than one issue matching a particular anchor assign
# a mystery bug with all relevent information on the owner and return
# the first matching issue.
clean_marker = re.sub('[0-9]+', '', html_escaped_marker)
all_issues = [issue for issue in issues
if clean_marker in re.sub('[0-9]+', '', issue.summary)]
if len(all_issues) > 1:
issue_ids = [issue.id for issue in all_issues]
self._create_bug_report(
'Query: %s, results: %s' % (marker, issue_ids),
'Multiple results for a specific query', '',
self._OWNER, self._LAB_ERROR_TEMPLATE)
if all_issues:
return all_issues[0]
unescaped_clean_marker = re.sub('[0-9]+', '', marker)
for issue in issues:
if any(unescaped_clean_marker in re.sub('[0-9]+', '', comment.text)
for comment in issue.comments if comment.text):
return issue
def report(self, failure, bug_template={}):
"""
Report a failure to the bug tracker. If this failure has already
happened, post a comment on the existing bug about it occurring again.
If this is a new failure, create a new bug about it.
@param failure A TestFailure instance about the failure.
@param bug_template: A template dictionary specifying the default bug
filing options for failures in this suite.
@return: The issue id of the issue that was either created or modified.
"""
if not self._check_tracker():
logging.error("Can't file %s", failure.bug_title())
return None
issue = self._find_issue_by_marker(failure.search_marker())
summary = '%s\n\n%s%s\n' % (failure.bug_summary(),
self._SEARCH_MARKER,
failure.search_marker())
if issue:
comment = '%s\n\n%s' % (failure.bug_title(), summary)
self._modify_bug_report(issue.id, comment)
return issue.id
if failure.lab_error:
if bug_template.get('labels'):
self._LAB_ERROR_TEMPLATE['labels'] += bug_template.get('labels')
bug_template = self._LAB_ERROR_TEMPLATE
return self._create_bug_report(summary, failure.bug_title(),
failure.test, self._get_owner(failure),
failure.get_milestone(), bug_template)