blob: c79bbc0e66f09bf02b362367ae05d5d0a21d78b9 [file] [log] [blame]
# Copyright (c) 2012 The Chromium 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 re
import traceback
import sys
from buildbot.process.properties import Properties
from buildbot.schedulers.trysched import TryBase
from buildbot.schedulers.trysched import BadJobfile
from twisted.internet import defer
from twisted.python import log
from twisted.web import client
from master.factory.commands import DEFAULT_TESTS
def text_to_dict(text):
"""Converts a series of key=value lines in a string into a multi-value dict.
"""
parsed = {}
regexp = r'([A-Za-z\-\_]+)\=(.+)\w*$'
for match in filter(None, (re.match(regexp, i) for i in text.splitlines())):
parsed.setdefault(match.group(1), []).append(match.group(2))
return parsed
def flatten(options, key, default):
"""Expects only one value."""
if options.setdefault(key, []) == []:
options[key] = default
elif len(options[key]) == 1:
options[key] = options[key][0]
else:
raise ValueError('Expecting one value, got many', key, options[key])
def try_int(options, key, default):
"""Expects a int."""
flatten(options, key, default)
if options[key] != default:
options[key] = int(options[key])
def try_bool(options, key, default):
"""Expects a bool."""
flatten(options, key, default)
if options[key] != default:
if isinstance(options[key], bool):
pass
elif options[key].lower() in ('true', '1'):
options[key] = True
elif options[key].lower() in ('false', '0'):
options[key] = False
else:
raise ValueError('Expecting a bool, got non-bool', key, options[key])
def comma_separated(options, key):
"""Splits comma separated strings into multiple values."""
for i in options.setdefault(key, [])[:]:
if ',' in i:
options[key].remove(i)
options[key].extend(x for x in i.split(',') if x)
def dict_comma(values, valid_keys, default):
"""Splits comma separated strings with : to create a dict."""
out = {}
# We accept two formats:
# 1. Builder(s) only, will run default tests. Eg. win,mac
# 2. Builder(s) with test(s): Eg. win,mac:test1:filter1,test2:filter2,test3
# In this example, win will run default tests, and mac will be ran with
# test1:filter1, test2:filter2, and test3.
# In no cases are builders allowed to be specified after tests are specified.
for value in values:
sections_match = re.match(r'^([^\:]+):?(.*)$', value)
if sections_match:
builders_raw, tests_raw = sections_match.groups()
builders = builders_raw.split(',')
tests = [test for test in tests_raw.split(',') if test]
for builder in builders:
if not builder:
# Empty builder means trailing comma.
raise BadJobfile('Bad input - trailing comma: %s' % value)
if builder not in valid_keys:
# Drop unrecognized builders.
log.msg('%s not in builders, dropping.' % builder)
continue
if not tests or builder != builders[-1]:
# We can only add filters to the last builder.
out.setdefault(builder, set()).add(default)
else:
for test in tests:
if len(test.split(':')) > 2:
# Don't support test:filter:foo.
raise BadJobfile('Failed to process test %s' % value)
out.setdefault(builder, set()).add(test)
else:
raise BadJobfile('Failed to process value %s' % value)
return out
def parse_options(options, builders, pools):
"""Converts try job settings into a dict.
The dict is received as dict(str, list(str)).
"""
try:
# Flush empty values.
options = dict((k, v) for k, v in options.iteritems() if v)
flatten(options, 'name', 'Unnamed')
flatten(options, 'user', 'John Doe')
flatten(options, 'requester', None)
comma_separated(options, 'email')
for email in options['email']:
# Access to a protected member XXX of a client class
# pylint: disable=W0212
if not TryJobBase._EMAIL_VALIDATOR.match(email):
raise BadJobfile("'%s' is an invalid email address!" % email)
flatten(options, 'patch', None)
flatten(options, 'root', None)
flatten(options, 'patch_project', None)
try_int(options, 'patchlevel', 0)
flatten(options, 'branch', None)
flatten(options, 'revision', None)
options.setdefault('orig_revision', options['revision'])
flatten(options, 'reason', '%s: %s' % (options['user'], options['name']))
try_bool(options, 'clobber', False)
flatten(options, 'project', pools.default_pool_name if pools else None)
flatten(options, 'repository', None)
# Code review info. Enforce numbers.
try_int(options, 'patchset', None)
try_int(options, 'issue', None)
# Manages bot selection and test filtering. It is preferable to use
# multiple bot=unit_tests:Gtest.Filter lines. DEFAULT_TESTS is a marker to
# specify that the default tests should be run for this builder.
if not isinstance(options.get('bot'), dict):
options['bot'] = dict_comma(
options.get('bot', []), builders, DEFAULT_TESTS)
if not options['bot'] and pools:
options['bot'] = pools.Select(None, options['project'])
if not options['bot']:
raise BadJobfile('No builder selected')
comma_separated(options, 'testfilter')
if options['testfilter']:
for k in options['bot']:
options['bot'][k].update(options['testfilter'])
# Convert the set back to list.
for bot in options['bot']:
options['bot'][bot] = list(options['bot'][bot])
log.msg(
'Chose %s for job %s' %
(','.join(options['bot']), options['reason']))
return options
except (TypeError, ValueError), e:
lines = [
(i[0].rsplit('/', 1)[-1], i[1])
for i in traceback.extract_tb(sys.exc_info()[2])
]
raise BadJobfile('Failed to parse the metadata: %r' % options, e, lines)
class TryJobBase(TryBase):
compare_attrs = TryBase.compare_attrs + (
'pools', 'last_good-urls', 'code_review_sites')
# Simplistic email matching regexp.
_EMAIL_VALIDATOR = re.compile(
r'[a-zA-Z0-9][a-zA-Z0-9\.\+\-\_]*@[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,}$')
_PROPERTY_SOURCE = 'Try job'
def __init__(self, name, pools, properties,
last_good_urls, code_review_sites):
TryBase.__init__(self, name, pools.ListBuilderNames(), properties or {})
self.pools = pools
pools.SetParent(self)
self.last_good_urls = last_good_urls
self.code_review_sites = code_review_sites
self._last_lkgr = {}
self.valid_builders = []
def setServiceParent(self, parent):
TryBase.setServiceParent(self, parent)
self.valid_builders = self.master.botmaster.builders.keys()
def gotChange(self, change, important): # pylint: disable=R0201
log.msg('ERROR: gotChange was unexpectedly called.')
def parse_options(self, options):
return parse_options(options, self.valid_builders, self.pools)
def get_props(self, builder, options):
"""Current job extra properties that are not related to the source stamp.
Initialize with the Scheduler's base properties.
"""
always_included_keys = (
'orig_revision',
)
optional_keys = (
'clobber',
'issue',
'patch_ref',
'patch_repo_url',
'patch_storage',
'patch_url',
'patch_project',
'patchset',
'requester',
'rietveld',
'root',
'try_job_key',
)
# All these settings have no meaning when False or not set, so don't set
# them in that case.
properties = dict((i, options[i]) for i in optional_keys if options.get(i))
# These settings are meaningful even if the value evaluates to False
# or None. Note that when options don't contain given key, it will
# be set to None.
properties.update(dict((i, options.get(i)) for i in always_included_keys))
# Specially evaluated properties, e.g. ones where key name is different
# between properties and options.
properties['testfilter'] = options['bot'].get(builder, None)
props = Properties()
props.updateFromProperties(self.properties)
props.update(properties, self._PROPERTY_SOURCE)
return props
def create_buildset(self, ssid, parsed_job):
log.msg('Creating try job(s) %s' % ssid)
result = None
for builder in parsed_job['bot']:
result = self.addBuildsetForSourceStamp(ssid=ssid,
reason=parsed_job['name'],
external_idstring=parsed_job['name'],
builderNames=[builder],
properties=self.get_props(builder, parsed_job))
return result
def SubmitJob(self, parsed_job, changeids):
if not parsed_job['bot']:
raise BadJobfile(
'incoming Try job did not specify any allowed builder names')
# Verify the try job patch is not more than 20MB.
if parsed_job.get('patch'):
patchsize = len(parsed_job['patch'])
if patchsize > 20*1024*1024: # 20MB
raise BadJobfile('incoming Try job patch is %s bytes, '
'must be less than 20MB' % (patchsize))
d = self.master.db.sourcestamps.addSourceStamp(
branch=parsed_job['branch'],
revision=parsed_job['revision'],
patch_body=parsed_job['patch'],
patch_level=parsed_job['patchlevel'],
patch_subdir=parsed_job['root'],
project=parsed_job['project'],
repository=parsed_job['repository'] or '',
changeids=changeids)
d.addCallback(self.create_buildset, parsed_job)
d.addErrback(log.err, "Failed to queue a try job!")
return d
def get_lkgr(self, options):
"""Grabs last known good revision number if necessary."""
# TODO: crbug.com/233418 - Only use the blink url for jobs with the blink
# project set. This will require us to always set the project in blink
# configs.
project = options['project'] or 'chrome'
if project == 'chrome' and all('_layout' in bot for bot in options['bot']):
project = 'blink'
options['rietveld'] = (self.code_review_sites or {}).get(project)
self._last_lkgr.setdefault(project, None)
last_good_url = (self.last_good_urls or {}).get(project)
if options['revision'] or not last_good_url:
return defer.succeed(0)
def Success(result):
new_value = result.strip()
if new_value and (not self._last_lkgr[project] or
new_value != self._last_lkgr[project]):
self._last_lkgr[project] = new_value
options['revision'] = self._last_lkgr[project] or 'HEAD'
def Failure(result):
options['revision'] = self._last_lkgr[project] or 'HEAD'
connection = client.getPage(last_good_url, agent='buildbot')
connection.addCallbacks(Success, Failure)
return connection