| # Copyright (c) 2013 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. |
| |
| |
| """Miscellaneous utilities needed by the Skia buildbot master.""" |
| |
| |
| import difflib |
| import httplib2 |
| import os |
| import re |
| |
| # requires Google APIs client library for Python; see |
| # https://code.google.com/p/google-api-python-client/wiki/Installation |
| from apiclient.discovery import build |
| from buildbot.scheduler import Dependent |
| from buildbot.scheduler import Scheduler |
| from buildbot.schedulers import timed |
| from buildbot.schedulers.filter import ChangeFilter |
| from config_private import TRY_SVN_BASEURL |
| from master import try_job_svn |
| from master import try_job_rietveld |
| from master.builders_pools import BuildersPools |
| from oauth2client.client import SignedJwtAssertionCredentials |
| |
| import builder_name_schema |
| import config_private |
| import os |
| import skia_vars |
| import subprocess |
| |
| |
| GATEKEEPER_NAME = 'GateKeeper' |
| |
| TRY_SCHEDULER_SVN = 'skia_try_svn' |
| TRY_SCHEDULER_RIETVELD = 'skia_try_rietveld' |
| TRY_SCHEDULERS = [TRY_SCHEDULER_SVN, TRY_SCHEDULER_RIETVELD] |
| TRY_SCHEDULERS_STR = '|'.join(TRY_SCHEDULERS) |
| |
| |
| def GetListFromEnvVar(name, splitstring=','): |
| """ Returns contents of an environment variable, as a list. |
| |
| If the environment variable is unset or set to empty-string, this returns |
| an empty list. |
| |
| name: string; name of the environment variable to read |
| splitstring: string with which to split the env var into list items |
| """ |
| unsplit = os.environ.get(name, None) |
| if unsplit: |
| return unsplit.split(',') |
| else: |
| return [] |
| |
| |
| def StringDiff(expected, actual): |
| """ Returns the diff between two multiline strings, as a multiline string.""" |
| return ''.join(difflib.unified_diff(expected.splitlines(1), |
| actual.splitlines(1))) |
| |
| |
| def _IndentStr(indent): |
| return ' ' * (indent + 1) |
| |
| |
| def ToString(obj, indent=0): |
| """ Returns a string representation of the given object. This differs from the |
| built-in string function in that it does not give memory locations. |
| |
| obj: the object to print. |
| indent: integer; the current indent level. |
| """ |
| if isinstance(obj, list): |
| return _ListToString(obj, indent) |
| elif isinstance(obj, dict): |
| return _DictToString(obj, indent) |
| elif isinstance(obj, tuple): |
| return _ListToString(obj, indent) |
| elif isinstance(obj, str): |
| return '\'%s\'' % obj |
| elif obj is None: |
| return 'None' |
| else: |
| return '<Object>' |
| |
| |
| def _ListToString(list_var, indent): |
| if not list_var: |
| return '[]' |
| indent_str = _IndentStr(indent) |
| val = '[\n' |
| indent += 1 |
| val += ''.join(['%s%s,\n' % (indent_str, ToString(elem, indent)) \ |
| for elem in list_var]) |
| indent -= 1 |
| indent_str = _IndentStr(indent - 1) |
| val += indent_str + ']' |
| return val |
| |
| |
| def _DictToString(d, indent): |
| if not d: |
| return '{}' |
| indent_str = _IndentStr(indent) |
| val = '{\n' |
| indent += 1 |
| val += ''.join(['%s%s: %s,\n' % (indent_str, ToString(k, indent), |
| ToString(d[k], indent)) \ |
| for k in sorted(d.keys())]) |
| indent -= 1 |
| indent_str = _IndentStr(indent - 1) |
| val += indent_str + '}' |
| return val |
| |
| |
| def FixGitSvnEmail(addr): |
| """ Git-svn tacks a git-svn-id onto email addresses. This function removes it. |
| |
| For example, "skia.buildbots@gmail.com@2bbb7eff-a529-9590-31e7-b0007b416f81" |
| becomes, "skia.buildbots@gmail.com". Addresses containing a single '@' will be |
| unchanged. |
| """ |
| return '@'.join(addr.split('@')[:2]) |
| |
| |
| class SkiaChangeFilter(ChangeFilter): |
| """Skia specific subclass of ChangeFilter.""" |
| |
| def __init__(self, builders, **kwargs): |
| self._builders = builders |
| ChangeFilter.__init__(self, **kwargs) |
| |
| def filter_change(self, change): |
| """Overrides ChangeFilter.filter_change to pass builders to filter_fn. |
| |
| The code has been copied from |
| http://buildbot.net/buildbot/docs/0.8.3/reference/buildbot.schedulers.filter-pysrc.html#ChangeFilter |
| with one change: We pass a sequence of builders to the filter function. |
| """ |
| if self.filter_fn is not None and not self.filter_fn(change, |
| self._builders): |
| return False |
| for (filt_list, filt_re, filt_fn, chg_attr) in self.checks: |
| chg_val = getattr(change, chg_attr, '') |
| if filt_list is not None and chg_val not in filt_list: |
| return False |
| if filt_re is not None and ( |
| chg_val is None or not filt_re.match(chg_val)): |
| return False |
| if filt_fn is not None and not filt_fn(chg_val): |
| return False |
| return True |
| |
| |
| def _AssertValidString(var, varName='[unknown]'): |
| """Raises an exception if a var is not a valid string. |
| |
| A string is considered valid if it is not None, is not the empty string and is |
| not just whitespace. |
| |
| Args: |
| var: the variable to validate |
| varName: name of the variable, for error reporting |
| """ |
| if not isinstance(var, str): |
| raise Exception('variable "%s" is not a string' % varName) |
| if not var: |
| raise Exception('variable "%s" is empty' % varName) |
| if var.isspace(): |
| raise Exception('variable "%s" is whitespace' % varName) |
| |
| |
| def _AssertValidStringList(var, varName='[unknown]'): |
| """Raises an exception if var is not a list of valid strings. |
| |
| A list is considered valid if it is either empty or if it contains at |
| least one item and each item it contains is also a valid string. |
| |
| Args: |
| var: the variable to validate |
| varName: name of the variable, for error reporting |
| """ |
| if not isinstance(var, list): |
| raise Exception('variable "%s" is not a list' % varName) |
| for index, item in zip(range(len(var)), var): |
| _AssertValidString(item, '%s[%d]' % (varName, index)) |
| |
| |
| def FileBug(summary, description, owner=None, ccs=None, labels=None): |
| """Files a bug to the Skia issue tracker. |
| |
| Args: |
| summary: a single-line string to use as the issue summary |
| description: a multiline string to use as the issue description |
| owner: email address of the issue owner (as a string), or None if unknown |
| ccs: email addresses (list of strings) to CC on the bug |
| labels: labels (list of strings) to apply to the bug |
| |
| Returns: |
| A representation of the issue tracker issue that was filed or raises an |
| exception if there was a problem. |
| """ |
| project_id = 'skia' # This is the project name: skia |
| key_file = 'key.p12' # Key file from the API console, renamed to key.p12 |
| service_acct = ('352371350305-b3u8jq5sotdh964othi9ntg9d0pelu77' |
| '@developer.gserviceaccount.com') # Created with the key |
| result = {} |
| if not ccs: |
| ccs = [] |
| if not labels: |
| labels = [] |
| |
| if owner is not None: # owner can be None |
| _AssertValidString(owner, 'owner') |
| _AssertValidString(summary, 'summary') |
| _AssertValidString(description, 'description') |
| _AssertValidStringList(ccs, 'ccs') |
| _AssertValidStringList(labels, 'labels') |
| |
| f = file(key_file, 'rb') |
| key = f.read() |
| f.close() |
| |
| # Create an httplib2.Http object to handle the HTTP requests and authorize |
| # it with the credentials. |
| credentials = SignedJwtAssertionCredentials( |
| service_acct, |
| key, |
| scope='https://www.googleapis.com/auth/projecthosting') |
| http = httplib2.Http() |
| http = credentials.authorize(http) |
| |
| service = build("projecthosting", "v2", http=http) |
| |
| # Insert a new issue into the project. |
| body = { |
| 'summary': summary, |
| 'description': description |
| } |
| |
| insertparams = { |
| 'projectId': project_id, |
| 'sendEmail': 'true' |
| } |
| |
| if owner is not None: |
| owner_value = { |
| 'name': owner |
| } |
| body['owner'] = owner_value |
| |
| cc_values = [] |
| for cc in ccs: |
| cc_values.append({'name': cc}) |
| body['cc'] = cc_values |
| |
| body['labels'] = labels |
| |
| insertparams['body'] = body |
| |
| request = service.issues().insert(**insertparams) |
| result = request.execute() |
| |
| return result |
| |
| |
| # Skip buildbot runs of a CL if its commit log message contains the following |
| # substring. |
| SKIP_BUILDBOT_SUBSTRING = '(SkipBuildbotRuns)' |
| |
| # If the below regex is found in a CL's commit log message, only run the |
| # builders specified therein. |
| RUN_BUILDERS_REGEX = '\(RunBuilders:(.+)\)' |
| RUN_BUILDERS_RE_COMPILED = re.compile(RUN_BUILDERS_REGEX) |
| |
| |
| def CapWordsToUnderscores(string): |
| """ Converts a string containing capitalized words to one in which all |
| characters are lowercase and words are separated by underscores. |
| |
| Examples: |
| 'NexusS' becomes 'nexus_s' |
| 'Nexus10' becomes 'nexus_10' |
| |
| string: string; string to manipulate. |
| """ |
| name_parts = [] |
| for part in re.split('(\d+)', string): |
| if re.match('(\d+)', part): |
| name_parts.append(part) |
| else: |
| name_parts.extend(re.findall('[A-Z][a-z]*', part)) |
| return '_'.join([part.lower() for part in name_parts]) |
| |
| |
| def UnderscoresToCapWords(string): |
| """ Converts a string lowercase words separated by underscores to one in which |
| words are capitalized and not separated by underscores. |
| |
| Examples: |
| 'nexus_s' becomes 'NexusS' |
| 'nexus_10' becomes 'Nexus10' |
| |
| string: string; string to manipulate. |
| """ |
| name_parts = string.split('_') |
| return ''.join([part.title() for part in name_parts]) |
| |
| |
| # Since we can't modify the existing Helper class, we subclass it here, |
| # overriding the necessary parts to get things working as we want. |
| class SkiaHelper(object): |
| def __init__(self, defaults): |
| self._defaults = defaults |
| self._builders = [] |
| self._factories = {} |
| self._schedulers = {} |
| |
| def Builder(self, name, factory, gatekeeper=None, scheduler=None, |
| builddir=None, auto_reboot=False, notify_on_missing=False): |
| # Override the category with the first two parts of the builder name. |
| name_parts = name.split(builder_name_schema.BUILDER_NAME_SEP) |
| category = name_parts[0] |
| subcategory = name_parts[1] if len(name_parts) > 1 else 'default' |
| full_category = '|'.join((category, subcategory)) |
| self._builders.append({'name': name, |
| 'factory': factory, |
| 'gatekeeper': gatekeeper, |
| 'schedulers': scheduler.split('|'), |
| 'builddir': builddir, |
| 'category': full_category, |
| 'auto_reboot': auto_reboot, |
| 'notify_on_missing': notify_on_missing}) |
| |
| def PeriodicScheduler(self, name, minute=0, hour='*', dayOfMonth='*', |
| month='*', dayOfWeek='*'): |
| """Helper method for the Periodic scheduler.""" |
| if name in self._schedulers: |
| raise ValueError('Scheduler %s already exists' % name) |
| self._schedulers[name] = {'type': 'PeriodicScheduler', |
| 'builders': [], |
| 'minute': minute, |
| 'hour': hour, |
| 'dayOfMonth': dayOfMonth, |
| 'month': month, |
| 'dayOfWeek': dayOfWeek} |
| |
| def Dependent(self, name, parent): |
| if name in self._schedulers: |
| raise ValueError('Scheduler %s already exists' % name) |
| self._schedulers[name] = {'type': 'Dependent', |
| 'parent': parent, |
| 'builders': []} |
| |
| def Factory(self, name, factory): |
| if name in self._factories: |
| raise ValueError('Factory %s already exists' % name) |
| self._factories[name] = factory |
| |
| def Scheduler(self, name, treeStableTimer=60, categories=None): |
| if name in self._schedulers: |
| raise ValueError('Scheduler %s already exists' % name) |
| self._schedulers[name] = {'type': 'Scheduler', |
| 'treeStableTimer': treeStableTimer, |
| 'builders': [], |
| 'categories': categories} |
| |
| def TryJobSubversion(self, name): |
| """ Adds a Subversion-based try scheduler. """ |
| if name in self._schedulers: |
| raise ValueError('Scheduler %s already exists' % name) |
| self._schedulers[name] = {'type': 'TryJobSubversion', 'builders': []} |
| |
| def TryJobRietveld(self, name): |
| """ Adds a Rietveld-based try scheduler. """ |
| if name in self._schedulers: |
| raise ValueError('Scheduler %s already exists' % name) |
| self._schedulers[name] = {'type': 'TryJobRietveld', 'builders': []} |
| |
| def Update(self, c): |
| for builder in self._builders: |
| # Update the schedulers with the builder. |
| schedulers = builder['schedulers'] |
| if schedulers: |
| for scheduler in schedulers: |
| self._schedulers[scheduler]['builders'].append(builder['name']) |
| |
| # Construct the category. |
| categories = [] |
| if builder.get('category', None): |
| categories.append(builder['category']) |
| if builder.get('gatekeeper', None): |
| categories.extend(builder['gatekeeper'].split('|')) |
| category = '|'.join(categories) |
| |
| # Append the builder to the list. |
| new_builder = {'name': builder['name'], |
| 'factory': self._factories[builder['factory']], |
| 'category': category, |
| 'auto_reboot': builder['auto_reboot']} |
| if builder['builddir']: |
| new_builder['builddir'] = builder['builddir'] |
| c['builders'].append(new_builder) |
| |
| c['builders'].sort(key=lambda builder: builder['name']) |
| |
| # Process the main schedulers. |
| for s_name in self._schedulers: |
| scheduler = self._schedulers[s_name] |
| if scheduler['type'] == 'Scheduler': |
| def filter_fn(change, builders): |
| """Filters out if change.comments contains certain keywords. |
| |
| The change is filtered out if the commit message contains: |
| * SKIP_BUILDBOT_SUBSTRING or |
| * RUN_BUILDERS_REGEX when the scheduler does not contain any of the |
| specified builders |
| |
| Args: |
| change: An instance of changes.Change. |
| builders: Sequence of strings. The builders that are run by this |
| scheduler. |
| |
| Returns: |
| If the change should be filtered out (i.e. not run by the buildbot |
| code) then False is returned else True is returned. |
| """ |
| if SKIP_BUILDBOT_SUBSTRING in change.comments: |
| return False |
| match_obj = RUN_BUILDERS_RE_COMPILED.search(change.comments) |
| if builders and match_obj: |
| for builder_to_run in match_obj.group(1).split(','): |
| if builder_to_run.strip() in builders: |
| break |
| else: |
| return False |
| return True |
| |
| skia_change_filter = SkiaChangeFilter( |
| builders=scheduler['builders'], |
| branch=skia_vars.GetGlobalVariable('master_branch_name'), |
| filter_fn=filter_fn) |
| |
| instance = Scheduler(name=s_name, |
| treeStableTimer=scheduler['treeStableTimer'], |
| builderNames=scheduler['builders'], |
| change_filter=skia_change_filter) |
| c['schedulers'].append(instance) |
| self._schedulers[s_name]['instance'] = instance |
| |
| # Process the periodic schedulers. |
| for s_name in self._schedulers: |
| scheduler = self._schedulers[s_name] |
| if scheduler['type'] == 'PeriodicScheduler': |
| instance = timed.Nightly( |
| name=s_name, |
| branch=skia_vars.GetGlobalVariable('master_branch_name'), |
| builderNames=scheduler['builders'], |
| minute=scheduler['minute'], |
| hour=scheduler['hour'], |
| dayOfMonth=scheduler['dayOfMonth'], |
| month=scheduler['month'], |
| dayOfWeek=scheduler['dayOfWeek']) |
| c['schedulers'].append(instance) |
| self._schedulers[s_name]['instance'] = instance |
| |
| # Process the Rietveld-based try schedulers. |
| for s_name in self._schedulers: |
| scheduler = self._schedulers[s_name] |
| if scheduler['type'] == 'TryJobRietveld': |
| pools = BuildersPools(s_name) |
| pools[s_name].extend(scheduler['builders']) |
| instance = try_job_rietveld.TryJobRietveld( |
| name=s_name, |
| pools=pools, |
| last_good_urls={'skia': None}, |
| code_review_sites={'skia': config_private.CODE_REVIEW_SITE}, |
| project='skia') |
| c['schedulers'].append(instance) |
| self._schedulers[s_name]['instance'] = instance |
| |
| # Process the svn-based try schedulers. |
| for s_name in self._schedulers: |
| scheduler = self._schedulers[s_name] |
| if scheduler['type'] == 'TryJobSubversion': |
| pools = BuildersPools(s_name) |
| pools[s_name].extend(scheduler['builders']) |
| instance = try_job_svn.TryJobSubversion( |
| name=s_name, |
| svn_url=TRY_SVN_BASEURL, |
| last_good_urls={'skia': None}, |
| code_review_sites={'skia': config_private.CODE_REVIEW_SITE}, |
| pools=pools) |
| c['schedulers'].append(instance) |
| self._schedulers[s_name]['instance'] = instance |
| |
| # Process the dependent schedulers. |
| for s_name in self._schedulers: |
| scheduler = self._schedulers[s_name] |
| if scheduler['type'] == 'Dependent': |
| instance = Dependent( |
| s_name, |
| self._schedulers[scheduler['parent']]['instance'], |
| scheduler['builders']) |
| c['schedulers'].append(instance) |
| self._schedulers[s_name]['instance'] = instance |
| |
| |
| def CanMergeBuildRequests(req1, req2): |
| """ Determine whether or not two BuildRequests can be merged. Note that the |
| call to buildbot.sourcestamp.SourceStamp.canBeMergedWith() is conspicuously |
| missing. This is because that method verifies that: |
| 1. req1.source.repository == req2.source.repository |
| 2. req1.source.project == req2.source.project |
| 3. req1.source.branch == req2.source.branch |
| 4. req1.patch == None and req2.patch = None |
| 5. (req1.source.changes and req2.source.changes) or \ |
| (not req1.source.changes and not req2.source.changes and \ |
| req1.source.revision == req2.source.revision) |
| |
| Of the above, we want 1, 2, 3, and 5. |
| Instead of 4, we want to make sure that neither request is a Trybot request. |
| """ |
| # Verify that the repositories are the same (#1 above). |
| if req1.source.repository != req2.source.repository: |
| return False |
| |
| # Verify that the projects are the same (#2 above). |
| if req1.source.project != req2.source.project: |
| return False |
| |
| # Verify that the branches are the same (#3 above). |
| if req1.source.branch != req2.source.branch: |
| return False |
| |
| # If either is a try request, don't merge (#4 above). |
| if (builder_name_schema.IsTrybot(req1.buildername) or |
| builder_name_schema.IsTrybot(req2.buildername)): |
| return False |
| |
| # Verify that either: both requests are associated with changes OR neither |
| # request is associated with a change but the revisions match (#5 above). |
| if req1.source.changes and not req2.source.changes: |
| return False |
| if not req1.source.changes and req2.source.changes: |
| return False |
| if not (req1.source.changes and req2.source.changes): |
| if req1.source.revision != req2.source.revision: |
| return False |
| |
| return True |
| |
| |
| def get_current_revision(): |
| """Obtain the checked-out buildbot code revision.""" |
| checkout_dir = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) |
| if os.path.isdir(os.path.join(checkout_dir, '.git')): |
| return subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip() |
| elif os.path.isdir(os.path.join(checkout_dir, '.svn')): |
| return subprocess.check_output(['svnversion', '.']).strip() |
| raise Exception('Unable to determine version control system.') |