| # 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 |