| #!/usr/bin/env python |
| # Copyright 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. |
| |
| """Closes tree if configured masters have failed tree-closing steps. |
| |
| Given a list of masters, gatekeeper_ng will get a list of the latest builds from |
| the specified masters. It then checks if any tree-closing steps have failed, and |
| if so closes the tree and emails appropriate parties. Configuration for which |
| steps to close and which parties to notify are in a local gatekeeper.json file. |
| """ |
| |
| from collections import defaultdict |
| from contextlib import closing, contextmanager |
| import argparse |
| import fnmatch |
| import getpass |
| import hashlib |
| import hmac |
| import httplib2 |
| import itertools |
| import json |
| import logging |
| import operator |
| import os |
| import random |
| import re |
| import sys |
| import time |
| import urllib |
| import urllib2 |
| |
| from slave import build_scan |
| from slave import build_scan_db |
| from slave import gatekeeper_ng_config |
| |
| from master import auth |
| |
| DATA_DIR = os.path.dirname(os.path.abspath(__file__)) |
| |
| # Buildbot status enum. |
| SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY = range(6) |
| |
| |
| def get_pwd(password_file): |
| if os.path.isfile(password_file): |
| return open(password_file, 'r').read().strip() |
| return getpass.getpass() |
| |
| |
| def in_glob_list(value, glob_list): |
| """Returns True if 'value' matches any glob in 'glob_list'. |
| |
| Args: |
| value: (str) The value to search for. |
| glob_list: (list) A list of glob strings to test. |
| """ |
| return any(fnmatch.fnmatch(value, glob) |
| for glob in glob_list) |
| |
| |
| def logging_urlopen(url, *args, **kwargs): |
| try: |
| return urllib2.urlopen(url, *args, **kwargs) |
| except urllib2.HTTPError as e: |
| logging.debug('error accessing url %s: %s' % (url, e)) |
| raise |
| |
| |
| def update_status(tree_message, status_url_root, username, password, simulate): |
| """Connects to chromium-status and closes the tree.""" |
| #TODO(xusydoc): append status if status is already closed. |
| |
| if isinstance(tree_message, unicode): |
| tree_message = tree_message.encode('utf8') |
| elif isinstance(tree_message, str): |
| tree_message = tree_message.decode('utf8') |
| |
| params = urllib.urlencode({ |
| 'message': tree_message, |
| 'username': username, |
| 'password': password |
| }) |
| |
| # Standard urllib doesn't raise an exception on 403, urllib2 does. |
| if not simulate: |
| status_url = status_url_root + "/status" |
| with closing(logging_urlopen(status_url, params)): |
| pass |
| else: |
| logging.info("Simulate: Setting tree [%s] status: %s", |
| status_url_root, tree_message) |
| logging.info('success') |
| |
| |
| def get_tree_status(status_url_root, username, password): |
| status_url = status_url_root + "/current?format=json" |
| with closing(logging_urlopen(status_url)) as f: |
| data = f.read() |
| try: |
| return json.loads(data) |
| except ValueError: |
| # Failed due to authentication error? |
| if 'login' not in data: |
| raise |
| |
| # Try using bot password to authenticate. |
| params = urllib.urlencode({ |
| 'username': username, |
| 'password': password |
| }) |
| try: |
| with closing(logging_urlopen(status_url, params)) as f: |
| data = f.read() |
| except urllib2.HTTPError, e: |
| if e.code == 405: |
| logging.warn("update your chromium_status app.") |
| raise |
| return json.loads(data) |
| |
| |
| def get_builder_section(gatekeeper_section, builder): |
| """Returns the applicable gatekeeper config for the builder. |
| |
| If the builder isn't present or is excluded, return None. |
| """ |
| if builder in gatekeeper_section: |
| builder_section = gatekeeper_section[builder] |
| elif '*' in gatekeeper_section: |
| builder_section = gatekeeper_section['*'] |
| else: |
| return None |
| |
| if not in_glob_list(builder, builder_section.get('excluded_builders', ())): |
| return builder_section |
| return None |
| |
| |
| def check_builds(master_builds, master_jsons, gatekeeper_config): |
| """Given a gatekeeper configuration, see which builds have failed.""" |
| succeeded_builds = [] |
| failed_builds = [] |
| |
| # Sort by buildnumber, highest first. |
| sorted_builds = sorted(master_builds, key=lambda x: x[3], reverse=True) |
| successful_builder_steps = defaultdict(lambda: defaultdict(set)) |
| current_builds_successful = True |
| |
| for build_json, master_url, builder, buildnum in sorted_builds: |
| gatekeeper_sections = gatekeeper_config.get(master_url, []) |
| for gatekeeper_section in gatekeeper_sections: |
| section_hash = gatekeeper_ng_config.gatekeeper_section_hash( |
| gatekeeper_section) |
| |
| gatekeeper = get_builder_section( |
| gatekeeper_section, build_json['builderName']) |
| if not gatekeeper: |
| succeeded_builds.append((master_url, builder, buildnum)) |
| continue |
| |
| steps = build_json['steps'] |
| excluded_steps = gatekeeper.get('excluded_steps', []) |
| forgiving_optional = set( |
| f for f in gatekeeper.get('forgiving_optional', []) |
| if not in_glob_list(f, excluded_steps)) |
| closing_optional = ( |
| set(gatekeeper.get('closing_optional', [])) | forgiving_optional) |
| closing_optional = set( |
| c for c in closing_optional |
| if not in_glob_list(c, excluded_steps)) |
| tree_notify = set(gatekeeper.get('tree_notify', [])) |
| sheriff_classes = set(gatekeeper.get('sheriff_classes', [])) |
| status_template = gatekeeper.get( |
| 'status_template', gatekeeper_ng_config.DEFAULTS['status_template']) |
| subject_template = gatekeeper.get( |
| 'subject_template', gatekeeper_ng_config.DEFAULTS[ |
| 'subject_template']) |
| finished = [s for s in steps if s.get('isFinished')] |
| close_tree = gatekeeper.get('close_tree', True) |
| respect_build_status = gatekeeper.get('respect_build_status', False) |
| |
| # We ignore EXCEPTION and RETRY here since those are usually |
| # infrastructure-related instead of actual test errors. |
| successful_steps = set(s['name'] for s in finished |
| if s.get('results', [FAILURE])[0] != FAILURE) |
| |
| successful_builder_steps[master_url][builder].update(successful_steps) |
| |
| finished_steps = set(s['name'] for s in finished) |
| |
| if '*' in forgiving_optional: |
| forgiving_optional = set( |
| f for f in finished_steps |
| if not in_glob_list(f, excluded_steps)) |
| if '*' in closing_optional: |
| closing_optional = set( |
| f for f in finished_steps |
| if not in_glob_list(f, excluded_steps)) |
| |
| failed_steps = finished_steps - successful_steps |
| unsatisfied_steps = failed_steps & closing_optional |
| |
| # Build is not yet finished, don't penalize on unstarted/unfinished steps. |
| if build_json.get('results', None) is None: |
| unsatisfied_steps &= finished_steps |
| |
| # If the entire build failed. |
| if (not unsatisfied_steps and 'results' in build_json and |
| build_json['results'] == FAILURE and respect_build_status): |
| unsatisfied_steps.add('[overall build status]') |
| |
| buildbot_url = master_url |
| project_name = master_url.split('/')[-1] |
| |
| if unsatisfied_steps: |
| failed_builds.append(({'base_url': buildbot_url, |
| 'build': build_json, |
| 'close_tree': close_tree, |
| 'forgiving_steps': forgiving_optional, |
| 'project_name': project_name, |
| 'sheriff_classes': sheriff_classes, |
| 'subject_template': subject_template, |
| 'status_template': status_template, |
| 'tree_notify': tree_notify, |
| 'unsatisfied': unsatisfied_steps, |
| }, |
| master_url, |
| builder, |
| buildnum, |
| section_hash)) |
| # If there is a failing step that a newer builder hasn't succeeded on, |
| # don't open the tree. |
| still_failing_steps = ( |
| unsatisfied_steps - successful_builder_steps[master_url][builder]) |
| if still_failing_steps: |
| logging.debug('%s failed on %s, not yet resolved.', |
| ','.join(still_failing_steps), |
| generate_build_url(failed_builds[-1][0])) |
| current_builds_successful = False |
| else: |
| succeeded_builds.append((master_url, builder, buildnum)) |
| |
| return (list(reversed(failed_builds)), list(reversed(succeeded_builds)), |
| successful_builder_steps, current_builds_successful) |
| |
| |
| def propagate_build_status_back_to_db(failure_tuples, success_tuples, build_db): |
| """Write back to build_db which finished steps failed or succeeded.""" |
| for _, master_url, builder, buildnum, _ in failure_tuples: |
| builder_dict = build_db.masters[master_url][builder] |
| if builder_dict[buildnum].finished: |
| # pylint: disable=W0212 |
| builder_dict[buildnum] = builder_dict[buildnum]._replace( |
| succeeded=False) |
| for master_url, builder, buildnum in success_tuples: |
| builder_dict = build_db.masters[master_url][builder] |
| if builder_dict[buildnum].finished: |
| # pylint: disable=W0212 |
| builder_dict[buildnum] = builder_dict[buildnum]._replace( |
| succeeded=True) |
| |
| |
| def get_build_properties(build_json, properties): |
| """Obtains multiple build_properties from a build. |
| |
| Sets a property to None if it's not in the build. |
| """ |
| |
| properties = set(properties) |
| result = dict.fromkeys(properties) # Populates dict with {key: None}. |
| for p in build_json.get('properties', []): |
| if p[0] in properties: |
| result[p[0]] = p[1] |
| return result |
| |
| |
| @contextmanager |
| def log_section(url, builder, buildnum, section_hash=None): |
| """Wraps a code block with information about a build it operates on.""" |
| logging.debug('%sbuilders/%s/builds/%d ----', url, builder, buildnum) |
| if section_hash: |
| logging.debug(' section hash: %s', section_hash) |
| yield |
| logging.debug('----') |
| |
| |
| COMMIT_POSITION_REGEX = re.compile(r'(.*)@{#(\d+)}') |
| |
| |
| def parse_commit_position(prop): |
| """Determine if the revision is a SVN revision or a git commit position. |
| |
| If the revision is a git commit position, return just the numeric part. |
| """ |
| if not isinstance(prop, basestring): |
| return prop |
| |
| match = COMMIT_POSITION_REGEX.match(prop) |
| if not match: |
| return prop |
| else: |
| return int(match.group(2)) |
| |
| |
| def convert_revisions_to_positions(property_dict): |
| """Given a dictionary of revisions, return a dict of parsed revisions.""" |
| result = {} |
| for k, v in property_dict.iteritems(): |
| result[k] = parse_commit_position(v) |
| return result |
| |
| |
| def reject_old_revisions(failed_builds, build_db): |
| """Ignore builds which triggered on revisions older than the current. |
| |
| triggered_revisions has the format: {'revision': 500} |
| |
| Each key is a buildproperty that was previously triggered on, and each value |
| was the value of that key. Note that all keys present in triggered_revisions |
| are used for the comparison. Only builds where at least one number is greater |
| than and all numbers are greater than or equal are considered 'new' and are |
| not rejected by this function. Any change in the set of keys triggers a full |
| reset of the recorded data. In the common case, triggered_revisions only has |
| one key ('revision') and rejects all builds where revision is less than or |
| equal to the last triggered revision. |
| """ |
| |
| triggered_revisions = build_db.aux.get('triggered_revisions', {}) |
| if not triggered_revisions: |
| # There was no previous revision information, so by default keep all |
| # failing builds. |
| logging.debug('no previous revision tracking information, ' |
| 'keeping all failures.') |
| return failed_builds |
| |
| def build_start_time(build): |
| """Sorting key that returns a build's build start time. |
| |
| By using reversed start time, we sort such that the latest builds come |
| first. This gives us a crude approximation of revision order, which means |
| we can update triggered_revisions with the highest revision first. Note that |
| this isn't perfect, but the likelihood of multiple failures occurring in the |
| same minute is low and multi-revision sorting is potentially error-prone. An |
| action-log based approach would obviate this hack. |
| """ |
| return build['build'].get('times', [None])[0] |
| |
| kept_builds = [] |
| for build in sorted(failed_builds, key=build_start_time, reverse=True): |
| builder = build['build']['builderName'] |
| buildnum = build['build']['number'] |
| with log_section(build['base_url'], builder, buildnum): |
| # get_build_properties will return a dict with all the keys given to it. |
| # Since we're giving it triggered_revisions.keys(), revisions is |
| # guaranteed to have the same keys as triggered_revisions. |
| revisions = convert_revisions_to_positions(get_build_properties( |
| build['build'], triggered_revisions.keys())) |
| |
| logging.debug('previous revision information: %s', |
| str(triggered_revisions)) |
| logging.debug('current revision information: %s', str(revisions)) |
| |
| if any(x is None for x in revisions.itervalues()): |
| # The revisions aren't in this build, err on the side of noisy. |
| logging.debug('Nones detected in revision tracking information, ' |
| 'keeping build.') |
| triggered_revisions = revisions |
| kept_builds.append(build) |
| continue |
| |
| paired = [] |
| for k in revisions: |
| paired.append((triggered_revisions[k], revisions[k])) |
| |
| if all(l <= r for l, r in paired) and any(l < r for l, r in paired): |
| # At least one revision is greater and all the others are >=, so let |
| # this revision through. |
| # TODO(stip): evaluate the greatest revision if we see a stream of |
| # failures at once. |
| logging.debug('keeping build') |
| kept_builds.append(build) |
| triggered_revisions = revisions |
| continue |
| logging.debug('rejecting build') |
| |
| build_db.aux['triggered_revisions'] = triggered_revisions |
| return kept_builds |
| |
| |
| def debounce_failures(failed_builds, current_builds_successful, build_db): |
| """Using trigger information in build_db, make sure we don't double-fire.""" |
| |
| @contextmanager |
| def save_build_failures(master_url, builder, buildnum, section_hash, |
| unsatisfied): |
| yield |
| build_db.masters[master_url][builder][buildnum].triggered[ |
| section_hash] = unsatisfied |
| |
| if failed_builds and current_builds_successful: |
| logging.debug( |
| 'All failing steps succeeded in later runs, not closing tree.') |
| return [] |
| true_failed_builds = [] |
| for build, master_url, builder, buildnum, section_hash in failed_builds: |
| with log_section(build['base_url'], builder, buildnum, section_hash): |
| with save_build_failures(master_url, builder, buildnum, section_hash, |
| build['unsatisfied']): |
| build_db_builder = build_db.masters[master_url][builder] |
| |
| # Determine what the current and previous failing steps are. |
| prev_triggered = [] |
| if buildnum-1 in build_db_builder: |
| prev_triggered = build_db_builder[buildnum-1].triggered.get( |
| section_hash, []) |
| |
| logging.debug(' previous failing tests: %s', ','.join( |
| sorted(prev_triggered))) |
| logging.debug(' current failing tests: %s', ','.join( |
| sorted(build['unsatisfied']))) |
| |
| # Skip build if we already fired (or if the failing tests aren't new). |
| if section_hash in build_db_builder[buildnum].triggered: |
| logging.debug(' section has already been triggered for this build, ' |
| 'skipping...') |
| continue |
| |
| new_tests = set(build['unsatisfied']) - set(prev_triggered) |
| if not new_tests: |
| logging.debug(' no new steps failed since previous build %d', |
| buildnum-1) |
| continue |
| |
| logging.debug(' new failing steps since build %d: %s', buildnum, |
| ','.join(sorted(new_tests))) |
| |
| # If we're here it's a legit failing build. |
| true_failed_builds.append(build) |
| |
| logging.debug(' build steps: %s', ', '.join( |
| s['name'] for s in build['build']['steps'])) |
| logging.debug(' build complete: %s', bool( |
| build['build'].get('results', None) is not None)) |
| logging.debug(' set to close tree: %s', build['close_tree']) |
| logging.debug(' build failed: %s', bool(build['unsatisfied'])) |
| |
| return true_failed_builds |
| |
| |
| def parse_sheriff_file(url): |
| """Given a sheriff url, download and parse the appropirate sheriff list.""" |
| with closing(logging_urlopen(url)) as f: |
| line = f.readline() |
| usernames_matcher_ = re.compile(r'document.write\(\'([\w, ]+)\'\)') |
| usernames_match = usernames_matcher_.match(line) |
| sheriffs = set() |
| if usernames_match: |
| usernames_str = usernames_match.group(1) |
| if usernames_str != 'None (channel is sheriff)': |
| for sheriff in usernames_str.split(', '): |
| if sheriff.count('@') == 0: |
| sheriff += '@google.com' |
| sheriffs.add(sheriff) |
| return sheriffs |
| |
| |
| def get_sheriffs(classes, base_url): |
| """Given a list of sheriff classes, download and combine sheriff emails.""" |
| sheriff_sets = (parse_sheriff_file(base_url % cls) for cls in classes) |
| return reduce(operator.or_, sheriff_sets, set()) |
| |
| |
| SCOPES = ['https://www.googleapis.com/auth/userinfo.email'] |
| |
| |
| def _http_req_auth(url, method, body, http): |
| http = http or httplib2.Http() |
| |
| resp, content = http.request(url, method, body=body) |
| return resp.status, content |
| |
| |
| def submit_email(email_app, build_data, simulate, creds): |
| """Submit json to a mailer app which sends out the alert email.""" |
| if simulate: |
| logging.info("Simulate: Sending e-mail via [%s]: %s", email_app, build_data) |
| return |
| |
| url = email_app + '/email' |
| |
| # The data is wrapped because it used to pass other auth data in this message, |
| # which is now done by service accounts. Theoretically this wrapping should be |
| # removed, but it requires coordinating a change on the server and client, |
| # which is complicated. |
| data = { |
| 'message': json.dumps(build_data, sort_keys=True), |
| } |
| |
| http = httplib2.Http() |
| if creds: |
| creds = auth.create_service_account_credentials(creds, SCOPES) |
| http = creds.authorize(http) |
| creds.refresh(http) |
| |
| code, content = _http_req_auth( |
| url, "POST", urllib.urlencode({'json': json.dumps(data)}), http) |
| if code != 200: |
| raise Exception('error connecting to email app: code %d %s' % ( |
| code, content)) |
| |
| |
| def open_tree_if_possible(build_db, master_jsons, successful_builder_steps, |
| current_builds_successful, username, password, status_url_root, |
| set_status, emoji, simulate): |
| if not current_builds_successful: |
| logging.debug('Not opening tree because failing steps were detected.') |
| return |
| |
| previously_failed_builds = [] |
| for master_url, master in master_jsons.iteritems(): |
| for builder in master['builders']: |
| builder_dict = build_db.masters.get(master_url, {}).get(builder, {}) |
| for buildnum, build in builder_dict.iteritems(): |
| if build.finished: |
| if not build.succeeded: |
| if build.triggered: |
| # See crbug.com/389740 for why the 0 is there. |
| failing_steps = set(build.triggered.values()[0]) |
| else: |
| failing_steps = set() |
| still_failing_steps = ( |
| failing_steps - successful_builder_steps[master_url][builder]) |
| if still_failing_steps: |
| previously_failed_builds.append( |
| '%s on %s %s/builders/%s/builds/%d' % ( |
| ','.join(still_failing_steps), builder, master_url, |
| urllib.quote(builder), buildnum)) |
| |
| if previously_failed_builds: |
| logging.debug( |
| 'Not opening tree because previous builds weren\'t successful:') |
| for build in previously_failed_builds: |
| logging.debug(' %s' % build) |
| return |
| |
| status = get_tree_status(status_url_root, username, password) |
| # Don't change the status unless the tree is currently closed. |
| if status['general_state'] != 'closed': |
| logging.debug('Not opening tree because it is not closed (%s)' |
| % status['general_state']) |
| return |
| |
| # Don't override human closures. |
| closed_tree_key = 'closed_tree-%s' % status_url_root |
| last_gatekeeper_closure = build_db.aux.get(closed_tree_key) |
| if last_gatekeeper_closure: |
| # Line 378 of http://goo.gl/ViyxDW limits status messages to 500 chars. The |
| # code replaces the 500th char with a unicode ellipsis, so we effectively |
| # have 499 chars to work with. |
| status_limit = 499 |
| if (last_gatekeeper_closure['message'][:status_limit] |
| != status['message'][:status_limit]): |
| logging.debug( |
| 'Not opening tree because we didn\'t set the last message: %s vs %s' |
| % (last_gatekeeper_closure['message'], status['message'])) |
| return |
| else: |
| # Backwards compatability hack. |
| if not re.search(r"automatic", status['message'], re.IGNORECASE): |
| logging.debug('Not opening tree because \'automatic\' was not found in %s' |
| % status['message']) |
| return |
| |
| logging.info('All builders are green, opening the tree...') |
| |
| tree_status = 'Tree is open (Automatic)' |
| if emoji: |
| random_emoji = random.choice(emoji) |
| if random_emoji.endswith(')'): |
| random_emoji += ' ' |
| tree_status = 'Tree is open (Automatic: %s)' % random_emoji |
| logging.info('Opening tree with message: \'%s\'' % tree_status) |
| build_db.aux[closed_tree_key] = {} |
| if set_status: |
| update_status(tree_status, status_url_root, username, password, simulate) |
| else: |
| logging.info('set-status not set, not connecting to chromium-status!') |
| |
| |
| def generate_build_url(build): |
| """Creates a URL to reference the build.""" |
| |
| # Luci builds have a view_path. |
| if 'view_path' in build['build']: |
| return 'https://ci.chromium.org%s' % ( |
| urllib.quote(build['build']['view_path']) |
| ) |
| |
| return '%s/builders/%s/builds/%d' % ( |
| build['base_url'].rstrip('/'), |
| urllib.quote(build['build']['builderName']), |
| build['build']['number'] |
| ) |
| |
| |
| def get_results_string(result_value): |
| """Returns a string for a BuildBot result value (SUCCESS, FAILURE, etc.).""" |
| return { |
| SUCCESS: 'success', |
| WARNINGS: 'warnings', |
| FAILURE: 'failure', |
| SKIPPED: 'skipped', |
| EXCEPTION: 'exception', |
| RETRY: 'retry', |
| }.get(result_value, 'unknown') |
| |
| |
| def close_tree_if_necessary(build_db, failed_builds, username, password, |
| status_url_root, set_status, revision_properties, |
| simulate): |
| """Given a list of failed builds, close the tree if necessary.""" |
| |
| closing_builds = [b for b in failed_builds if b['close_tree']] |
| if not closing_builds: |
| logging.info('no tree-closing failures!') |
| return |
| |
| status = get_tree_status(status_url_root, username, password) |
| # Don't change the status unless the tree is currently open. |
| if status['general_state'] != 'open': |
| return |
| |
| logging.info('%d failed builds found, closing the tree...' % |
| len(closing_builds)) |
| |
| template_build = closing_builds[0] |
| template_vars = { |
| 'blamelist': ','.join(template_build['build']['blame']), |
| 'build_url': generate_build_url(template_build), |
| 'builder_name': template_build['build']['builderName'], |
| 'project_name': template_build['project_name'], |
| 'unsatisfied': ','.join(template_build['unsatisfied']), |
| 'result': get_results_string(template_build['build'].get('results')), |
| } |
| |
| # First populate un-transformed build properties |
| revision_props = get_build_properties(template_build['build'], |
| ['revision', 'got_revision', 'buildnumber',]) |
| |
| # Second add in transformed specified revision_properties. |
| revision_props.update(convert_revisions_to_positions( |
| get_build_properties(template_build['build'], revision_properties))) |
| |
| template_vars.update(revision_props) |
| |
| # Close on first failure seen. |
| tree_status = template_build['status_template'] % template_vars |
| |
| logging.info('closing the tree with message: \'%s\'' % tree_status) |
| if set_status: |
| update_status(tree_status, status_url_root, username, password, simulate) |
| closed_tree_key = 'closed_tree-%s' % status_url_root |
| build_db.aux[closed_tree_key] = { |
| 'message': tree_status, |
| } |
| else: |
| logging.info('set-status not set, not connecting to chromium-status!') |
| |
| |
| def notify_failures(failed_builds, sheriff_url, default_from_email, |
| email_app_url, domain, filter_domain, |
| disable_domain_filter, simulate, creds): |
| # Email everyone that should be notified. |
| emails_to_send = [] |
| for failed_build in failed_builds: |
| waterfall_url = failed_build['base_url'].rstrip('/') |
| build_url = generate_build_url(failed_build) |
| project_name = failed_build['project_name'] |
| fromaddr = failed_build['build'].get('fromAddr', default_from_email) |
| |
| tree_notify = failed_build['tree_notify'] |
| |
| if failed_build['unsatisfied'] <= failed_build['forgiving_steps']: |
| blamelist = set() |
| else: |
| blamelist = set(failed_build['build']['blame']) |
| |
| sheriffs = get_sheriffs(failed_build['sheriff_classes'], sheriff_url) |
| watchers = list(tree_notify | blamelist | sheriffs) |
| |
| build_data = { |
| 'build_url': build_url, |
| 'from_addr': fromaddr, |
| 'project_name': project_name, |
| 'subject_template': failed_build['subject_template'], |
| 'steps': [], |
| 'unsatisfied': list(failed_build['unsatisfied']), |
| 'waterfall_url': waterfall_url, |
| } |
| |
| for field in ['builderName', 'number', 'reason']: |
| build_data[field] = failed_build['build'][field] |
| |
| # The default value here is 2. In the case of failing on an unfinished |
| # build, the build won't have a result yet. As of now, chromium-build treats |
| # anything as 'not failure' as warning. Since we can't get into |
| # notify_failures without a failure, it makes sense to have the default |
| # value be failure (2) here. |
| build_data['result'] = failed_build['build'].get('results', 2) |
| build_data['blamelist'] = failed_build['build']['blame'] |
| build_data['changes'] = failed_build['build'].get('sourceStamp', {}).get( |
| 'changes', []) |
| |
| build_data['revisions'] = [x['revision'] for x in build_data['changes']] |
| |
| for step in failed_build['build']['steps']: |
| new_step = {} |
| for field, default in [('text', ''), ('name', '')]: |
| new_step[field] = step.get(field) or default |
| new_step['logs'] = step.get('logs') or [] |
| new_step['started'] = step.get('isStarted', False) |
| new_step['urls'] = step.get('urls') or {} |
| new_step['results'] = step.get('results', [0, None])[0] |
| build_data['steps'].append(new_step) |
| |
| if email_app_url and watchers: |
| emails_to_send.append((watchers, json.dumps(build_data, sort_keys=True))) |
| |
| builder = failed_build['build']['builderName'] |
| buildnum = failed_build['build']['number'] |
| result = failed_build['build'].get('results') |
| steps = failed_build['unsatisfied'] |
| logging.info( |
| 'to %s: failure with result %s in %s build %s: %s' % ( |
| ', '.join(watchers), |
| result, |
| builder, |
| buildnum, |
| list(steps))) |
| if not email_app_url: |
| logging.warn('no email_app_url specified, no email sent!') |
| |
| filtered_emails_to_send = [] |
| for email in emails_to_send: |
| new_watchers = [x if '@' in x else (x + '@' + domain) for x in email[0]] |
| if not disable_domain_filter: |
| new_watchers = [x for x in new_watchers if x.split('@')[-1] in |
| filter_domain] |
| if new_watchers: |
| filtered_emails_to_send.append((new_watchers, email[1])) |
| |
| # Deduplicate emails. |
| keyfunc = lambda x: x[1] |
| for k, g in itertools.groupby(sorted(filtered_emails_to_send, key=keyfunc), |
| keyfunc): |
| watchers = list(reduce(operator.or_, [set(e[0]) for e in g], set())) |
| build_data = json.loads(k) |
| build_data['recipients'] = watchers |
| submit_email(email_app_url, build_data, simulate, creds) |
| |
| |
| def simulate_build_failure(build_db, master, builder, *steps): |
| master_json = { |
| 'project': { |
| 'buildbotURL': master, |
| 'title': 'Simulated Master', |
| }, |
| 'builders': [builder], |
| } |
| build_json = ( |
| { |
| 'builderName': builder, |
| 'number': 0, |
| 'steps': [{ |
| 'name': s, |
| 'isFinished': True, |
| 'text': [ |
| 'Simulated Build Step', |
| ], |
| 'logs': [], |
| } for s in steps], |
| 'results': FAILURE, |
| 'reason': 'simulation', |
| 'blame': ['you'], |
| }, |
| master, |
| builder, |
| 0, |
| ) |
| build_db.masters.setdefault(master, {}) |
| build_db.masters[master].setdefault(builder, {}) |
| build_db.masters[master][builder][0] = build_scan_db.gen_build(finished=True) |
| return {master: master_json}, (build_json,) |
| |
| |
| def get_args(argv): |
| parser = argparse.ArgumentParser(description='Closes the tree if annotated ' |
| 'builds fail.') |
| parser.add_argument('--service-account-path', |
| help='Location of service account credentials. Used to' |
| 'access Milo and Chromium build') |
| parser.add_argument('--milo-creds', help='DEPRECATED. Use' |
| '--service--acount-path') |
| parser.add_argument('--build-db', default='build_db.json', |
| help='records the last-seen build for each builder') |
| parser.add_argument('--clear-build-db', action='store_true', |
| help='reset build_db to be empty') |
| parser.add_argument('--sync-build-db', action='store_true', |
| help='don\'t process any builds, but update build_db ' |
| 'to the latest build numbers') |
| parser.add_argument('--skip-build-db-update', action='store_true', |
| help='don\' write to the build_db, overridden by sync and' |
| ' clear db options') |
| parser.add_argument('--password-file', default='.status_password', |
| help='password file to update chromium-status') |
| parser.add_argument('-s', '--set-status', action='store_true', |
| help='close the tree by connecting to chromium-status') |
| parser.add_argument('--open-tree', action='store_true', |
| help='open the tree by connecting to chromium-status') |
| parser.add_argument('--status-url', |
| default='https://chromium-status.appspot.com', |
| help='URL for root of the status app') |
| parser.add_argument('--track-revisions', action='store_true', |
| help='only close on increasing revisions') |
| parser.add_argument('--revision-properties', default='revision', |
| help='comma-separated list of buildproperties to compare ' |
| 'revision on.') |
| parser.add_argument('--status-user', default='buildbot@chromium.org', |
| help='username for the status app') |
| parser.add_argument('--disable-domain-filter', action='store_true', |
| help='allow emailing any domain') |
| parser.add_argument('--filter-domain', default='chromium.org,google.com', |
| help='only email users in these comma separated domains') |
| parser.add_argument('--email-domain', default='google.com', |
| help='default email domain to add to users without one') |
| parser.add_argument('--sheriff-url', |
| default='http://build.chromium.org/p/chromium/%s.js', |
| help='URL pattern for the current sheriff list') |
| parser.add_argument('--parallelism', default=16, |
| help='up to this many builds can be queried ' |
| 'simultaneously') |
| parser.add_argument('--default-from-email', |
| default='buildbot@chromium.org', |
| help='default email address to send from') |
| parser.add_argument('--email-app-url', |
| default='https://chromium-build.appspot.com/mailer', |
| help='URL of the application to send email from') |
| parser.add_argument('--no-email-app', action='store_true', |
| help='don\'t send emails') |
| parser.add_argument('--json', |
| default=os.path.join(DATA_DIR, 'gatekeeper.json'), |
| help='location of gatekeeper configuration file') |
| parser.add_argument('--emoji', |
| default=os.path.join(DATA_DIR, 'gatekeeper_emoji.json'), |
| help='location of gatekeeper configuration file (None to' |
| 'turn off)') |
| parser.add_argument('--verify', action='store_true', |
| help='verify that the gatekeeper config file is correct') |
| parser.add_argument('--flatten-json', action='store_true', |
| help='display flattened gatekeeper.json for debugging') |
| parser.add_argument('--no-hashes', action='store_true', |
| help='don\'t insert gatekeeper section hashes') |
| parser.add_argument('-v', '--verbose', action='store_true', |
| help='turn on extra debugging information') |
| parser.add_argument('master_url', nargs='*', |
| help='The master URLs to poll.') |
| |
| group = parser.add_argument_group(title='Testing') |
| group.add_argument('--simulate-master', metavar='MASTER', |
| help='Simulate a build failure. This is the name of the ' |
| 'master on which the failure occurs.') |
| group.add_argument('--simulate-builder', metavar='BUILDER', |
| help='The builder to simulate the failure on.') |
| group.add_argument('--simulate-step', metavar='NAME', default=[], |
| action='append', |
| help='The steps to simulate completion.') |
| |
| args = parser.parse_args(argv) |
| |
| if args.milo_creds: |
| if args.service_account_path: |
| parser.error('only specify one of --milo-creds and' |
| '--service-account-path') |
| args.service_account_path = args.milo_creds |
| args.password = None |
| |
| if args.no_hashes and not args.flatten_json: |
| parser.error('specifying --no-hashes doesn\'t make sense without ' |
| '--flatten-json') |
| |
| if args.verify or args.flatten_json: |
| return args |
| |
| if not args: |
| parser.error('you need to specify at least one master URL') |
| |
| if args.no_email_app: |
| args.email_app_url = None |
| |
| args.filter_domain = args.filter_domain.split(',') |
| |
| args.master_url = [url.rstrip('/') for url in args.master_url] |
| |
| return args |
| |
| |
| def main(argv): |
| args = get_args(argv) |
| |
| logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) |
| |
| gatekeeper_config = gatekeeper_ng_config.load_gatekeeper_config(args.json) |
| |
| if args.verify: |
| return 0 |
| |
| simulate = bool(args.simulate_master) |
| |
| if args.flatten_json: |
| if not args.no_hashes: |
| gatekeeper_config = gatekeeper_ng_config.inject_hashes(gatekeeper_config) |
| gatekeeper_ng_config.flatten_to_json(gatekeeper_config, sys.stdout) |
| print |
| return 0 |
| |
| if args.set_status and not simulate: |
| args.password = get_pwd(args.password_file) |
| |
| masters = defaultdict(set) |
| for m in args.master_url: |
| if m.count(':') > 1: |
| # Master in master_url:builder,builder format. |
| http, mname, builderlist = m.split(':', 2) |
| mastername = ':'.join([http, mname]) |
| masters[mastername].update(builderlist.split(',')) |
| else: |
| # Regular master URL, just add '*'. |
| masters[m].add(build_scan.BUILDER_WILDCARD) |
| if not set(masters) <= set(gatekeeper_config): |
| print 'The following masters are not present in the gatekeeper config:' |
| for m in set(masters) - set(gatekeeper_config): |
| print ' ' + m |
| return 1 |
| |
| emoji = [] |
| if args.emoji != 'None': |
| try: |
| with open(args.emoji) as f: |
| emoji = json.load(f) |
| except (IOError, ValueError) as e: |
| logging.warning('Could not load emoji file %s: %s', args.emoji, e) |
| |
| if args.clear_build_db: |
| build_db = build_scan_db.gen_db() |
| build_scan_db.save_build_db(build_db, gatekeeper_config, |
| args.build_db) |
| else: |
| build_db = build_scan_db.get_build_db(args.build_db) |
| |
| if not simulate: |
| master_jsons, build_jsons = build_scan.get_updated_builds( |
| masters, build_db, args.parallelism, args.service_account_path) |
| else: |
| master_jsons, build_jsons = simulate_build_failure( |
| build_db, args.simulate_master, args.simulate_builder, |
| *args.simulate_step) |
| |
| if args.sync_build_db: |
| build_scan_db.save_build_db(build_db, gatekeeper_config, |
| args.build_db) |
| return 0 |
| |
| (failure_tuples, success_tuples, successful_builder_steps, |
| current_builds_successful) = check_builds( |
| build_jsons, master_jsons, gatekeeper_config) |
| |
| # Write failure / success information back to the build_db. |
| propagate_build_status_back_to_db(failure_tuples, success_tuples, build_db) |
| |
| # opening is an option, mostly to keep the unittests working which |
| # assume that any setting of status is negative. |
| if args.open_tree: |
| open_tree_if_possible(build_db, master_jsons, successful_builder_steps, |
| current_builds_successful, args.status_user, args.password, |
| args.status_url, args.set_status, emoji, simulate) |
| |
| # debounce_failures does 3 things: |
| # 1. Groups logging by builder |
| # 2. Selects out the "build" part from the failure tuple. |
| # 3. Rejects builds we've already warned about (and logs). |
| new_failures = debounce_failures(failure_tuples, |
| current_builds_successful, build_db) |
| |
| if args.track_revisions: |
| # Only close the tree if it's a newer revision than before. |
| properties = args.revision_properties.split(',') |
| triggered_revisions = build_db.aux.get('triggered_revisions', {}) |
| if not triggered_revisions or ( |
| sorted(triggered_revisions) != sorted(properties)): |
| logging.info('revision properties have changed from %s to %s. ' |
| 'clearing previous data.', triggered_revisions, properties) |
| build_db.aux['triggered_revisions'] = dict.fromkeys(properties) |
| new_failures = reject_old_revisions(new_failures, build_db) |
| |
| close_tree_if_necessary(build_db, new_failures, |
| args.status_user, args.password, |
| args.status_url, args.set_status, |
| args.revision_properties.split(','), |
| simulate) |
| try: |
| notify_failures(new_failures, args.sheriff_url, |
| args.default_from_email, args.email_app_url, |
| args.email_domain, args.filter_domain, |
| args.disable_domain_filter, simulate, |
| args.service_account_path) |
| finally: |
| if not args.skip_build_db_update and not simulate: |
| build_scan_db.save_build_db(build_db, gatekeeper_config, |
| args.build_db) |
| |
| return 0 |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(sys.argv[1:])) |