blob: fce6c33ff404223d3ed50917317c4c35993a7ba3 [file] [log] [blame]
#!/usr/bin/env python
# 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.
"""Commit queue executable.
Reuse Rietveld and the Chromium Try Server to process and automatically commit
patches.
"""
import logging
import logging.handlers
import optparse
import os
import shutil
import signal
import socket
import sys
import tempfile
import time
import find_depot_tools # pylint: disable=W0611
import checkout
import fix_encoding
import rietveld
import subprocess2
import async_push
import cq_alerts
import creds
import errors
import projects
import sig_handler
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
class OnlyIssueRietveld(rietveld.Rietveld):
"""Returns a single issue for end-to-end in prod testing."""
def __init__(self, url, email, password, extra_headers, only_issue):
super(OnlyIssueRietveld, self).__init__(url, email, password, extra_headers)
self._only_issue = only_issue
def get_pending_issues(self):
"""If it's set to return a single issue, only return this one."""
if self._only_issue:
return [self._only_issue]
return []
def get_issue_properties(self, issue, messages):
"""Hacks the result to fake that the issue has the commit bit set."""
data = super(OnlyIssueRietveld, self).get_issue_properties(issue, messages)
if issue == self._only_issue:
data['commit'] = True
return data
def set_flag(self, issue, patchset, flag, value):
if issue == self._only_issue and flag == 'commit' and value == 'False':
self._only_issue = None
return super(OnlyIssueRietveld, self).set_flag(issue, patchset, flag, value)
class FakeCheckout(object):
def __init__(self):
self.project_path = os.getcwd()
self.project_name = os.path.basename(self.project_path)
@staticmethod
def prepare(_revision):
logging.info('FakeCheckout is syncing')
return unicode('FAKE')
@staticmethod
def apply_patch(*_args):
logging.info('FakeCheckout is applying a patch')
@staticmethod
def commit(*_args):
logging.info('FakeCheckout is committing patch')
return 'FAKED'
@staticmethod
def get_settings(_key):
return None
@staticmethod
def revisions(*_args):
return None
def AlertOnUncleanCheckout():
"""Sends an alert if the cq is running live with local edits."""
diff = subprocess2.capture(['gclient', 'diff'], cwd=ROOT_DIR).strip()
if diff:
cq_alerts.SendAlert(
'CQ running with local diff.',
('Ruh-roh! Commit queue was started with an unclean checkout.\n\n'
'$ gclient diff\n%s' % diff))
def SetupLogging(options):
"""Configures the logging module."""
logging.getLogger().setLevel(logging.DEBUG)
if options.verbose:
level = logging.DEBUG
else:
level = logging.INFO
console_logging = logging.StreamHandler()
console_logging.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)7s %(message)s'))
console_logging.setLevel(level)
logging.getLogger().addHandler(console_logging)
log_directory = 'logs-' + options.project
if not os.path.exists(log_directory):
os.mkdir(log_directory)
logging_rotating_file = logging.handlers.RotatingFileHandler(
filename=os.path.join(log_directory, 'commit_queue.log'),
maxBytes= 10*1024*1024,
backupCount=50)
logging_rotating_file.setLevel(logging.DEBUG)
logging_rotating_file.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)-8s %(module)15s(%(lineno)4d): %(message)s'))
logging.getLogger().addHandler(logging_rotating_file)
class SignalInterrupt(Exception):
"""Exception that indicates being interrupted by a caught signal."""
def __init__(self, signal_set=None, *args, **kwargs):
super(SignalInterrupt, self).__init__(*args, **kwargs)
self.signal_set = signal_set
def SaveDatabaseCopyForDebugging(db_path):
"""Saves database file for debugging. Returns name of the saved file."""
with tempfile.NamedTemporaryFile(
dir=os.path.dirname(db_path),
prefix='db.debug.',
suffix='.json',
delete=False) as tmp_file:
with open(db_path) as db_file:
shutil.copyfileobj(db_file, tmp_file)
return tmp_file.name
def main():
# Set a default timeout for sockets. This is critical when talking to remote
# services like AppEngine and buildbot.
# TODO(phajdan.jr): This used to be 70s. Investigate lowering it again.
socket.setdefaulttimeout(60.0 * 15)
parser = optparse.OptionParser(
description=sys.modules['__main__'].__doc__)
project_choices = projects.supported_projects()
parser.add_option('-v', '--verbose', action='store_true')
parser.add_option(
'--no-dry-run',
action='store_false',
dest='dry_run',
default=True,
help='Run for real instead of dry-run mode which is the default. '
'WARNING: while the CQ won\'t touch rietveld in dry-run mode, the '
'Try Server will. So it is recommended to use --only-issue')
parser.add_option(
'--only-issue',
type='int',
help='Limits to a single issue. Useful for live testing; WARNING: it '
'will fake that the issue has the CQ bit set, so only try with an '
'issue you don\'t mind about.')
parser.add_option(
'--fake',
action='store_true',
help='Run with a fake checkout to speed up testing')
parser.add_option(
'--no-try',
action='store_true',
help='Don\'t send try jobs.')
parser.add_option(
'-p',
'--poll-interval',
type='int',
default=10,
help='Minimum delay between each polling loop, default: %default')
parser.add_option(
'--query-only',
action='store_true',
help='Return internal state')
parser.add_option(
'--project',
choices=project_choices,
help='Project to run the commit queue against: %s' %
', '.join(project_choices))
parser.add_option(
'-u',
'--user',
default='commit-bot@chromium.org',
help='User to use instead of %default')
parser.add_option(
'--rietveld',
default='https://codereview.chromium.org',
help='Rietveld server to use instead of %default')
options, args = parser.parse_args()
if args:
parser.error('Unsupported args: %s' % args)
if not options.project:
parser.error('Need to pass a valid project to --project.\nOptions are: %s' %
', '.join(project_choices))
SetupLogging(options)
try:
work_dir = os.path.join(ROOT_DIR, 'workdir')
# Use our specific subversion config.
checkout.SvnMixIn.svn_config = checkout.SvnConfig(
os.path.join(ROOT_DIR, 'subversion_config'))
url = options.rietveld
gaia_creds = creds.Credentials(os.path.join(work_dir, '.gaia_pwd'))
if options.dry_run:
logging.debug('Dry run - skipping SCM check.')
if options.only_issue:
parser.error('--only-issue is not supported with dry run')
else:
print('Using read-only Rietveld')
# Make sure rietveld is not modified. Pass empty email and
# password to bypass authentication; this additionally
# guarantees rietveld will not allow any changes.
rietveld_obj = rietveld.ReadOnlyRietveld(url, email='', password='')
else:
AlertOnUncleanCheckout()
print('WARNING: The Commit Queue is going to commit stuff')
if options.only_issue:
print('Using only issue %d' % options.only_issue)
rietveld_obj = OnlyIssueRietveld(
url,
options.user,
gaia_creds.get(options.user),
None,
options.only_issue)
else:
rietveld_obj = rietveld.Rietveld(
url,
options.user,
gaia_creds.get(options.user),
None)
pc = projects.load_project(
options.project,
options.user,
work_dir,
rietveld_obj,
options.no_try)
if options.dry_run:
if options.fake:
# Disable the checkout.
print 'Using no checkout'
pc.context.checkout = FakeCheckout()
else:
print 'Using read-only checkout'
pc.context.checkout = checkout.ReadOnlyCheckout(pc.context.checkout)
# Save pushed events on disk.
print 'Using read-only chromium-status interface'
pc.context.status = async_push.AsyncPushStore()
landmine_path = os.path.join(work_dir,
pc.context.checkout.project_name + '.landmine')
db_path = os.path.join(work_dir, pc.context.checkout.project_name + '.json')
if os.path.isfile(db_path):
if os.path.isfile(landmine_path):
debugging_path = SaveDatabaseCopyForDebugging(db_path)
os.remove(db_path)
logging.warning(('Deleting database because previous shutdown '
'was unclean. The copy of the database is saved '
'as %s.') % debugging_path)
else:
try:
pc.load(db_path)
except ValueError as e:
debugging_path = SaveDatabaseCopyForDebugging(db_path)
os.remove(db_path)
logging.warning(('Failed to parse database (%r), deleting it. '
'The copy of the database is saved as %s.') %
(e, debugging_path))
raise e
# Create a file to indicate unclean shutdown.
with open(landmine_path, 'w'):
pass
sig_handler.installHandlers(
signal.SIGINT,
signal.SIGHUP
)
# Sync every 5 minutes.
SYNC_DELAY = 5*60
try:
if options.query_only:
pc.look_for_new_pending_commit()
pc.update_status()
print(str(pc.queue))
os.remove(landmine_path)
return 0
now = time.time()
next_loop = now + options.poll_interval
# First sync is on second loop.
next_sync = now + options.poll_interval * 2
while True:
# In theory, we would gain in performance to parallelize these tasks. In
# practice I'm not sure it matters.
pc.look_for_new_pending_commit()
pc.process_new_pending_commit()
pc.update_status()
pc.scan_results()
if sig_handler.getTriggeredSignals():
raise SignalInterrupt(signal_set=sig_handler.getTriggeredSignals())
# Save the db at each loop. The db can easily be in the 1mb range so
# it's slowing down the CQ a tad but it in the 100ms range even for that
# size.
pc.save(db_path)
# More than a second to wait and due to sync.
now = time.time()
if (next_loop - now) >= 1 and (next_sync - now) <= 0:
if sys.stdout.isatty():
sys.stdout.write('Syncing while waiting \r')
sys.stdout.flush()
try:
pc.context.checkout.prepare(None)
except subprocess2.CalledProcessError as e:
# Don't crash, most of the time it's the svn server that is dead.
# How fun. Send a stack trace to annoy the maintainer.
errors.send_stack(e)
next_sync = time.time() + SYNC_DELAY
now = time.time()
next_loop = max(now, next_loop)
while True:
# Abort if any signals are set
if sig_handler.getTriggeredSignals():
raise SignalInterrupt(signal_set=sig_handler.getTriggeredSignals())
delay = next_loop - now
if delay <= 0:
break
if sys.stdout.isatty():
sys.stdout.write('Sleeping for %1.1f seconds \r' % delay)
sys.stdout.flush()
time.sleep(min(delay, 0.1))
now = time.time()
if sys.stdout.isatty():
sys.stdout.write('Running (please do not interrupt) \r')
sys.stdout.flush()
next_loop = time.time() + options.poll_interval
except: # Catch all fatal exit conditions.
logging.exception('CQ loop terminating')
raise
finally:
logging.warning('Saving db...')
pc.save(db_path)
pc.close()
logging.warning('db save successful.')
except SignalInterrupt:
# This is considered a clean shutdown: we only throw this exception
# from selected places in the code where the database should be
# in a known and consistent state.
os.remove(landmine_path)
print 'Bye bye (SignalInterrupt)'
# 23 is an arbitrary value to signal loop.sh that it must stop looping.
return 23
except KeyboardInterrupt:
# This is actually an unclean shutdown. Do not remove the landmine file.
# One example of this is user hitting ctrl-c twice at an arbitrary point
# inside the CQ loop. There are no guarantees about consistent state
# of the database then.
print 'Bye bye (KeyboardInterrupt - this is considered unclean shutdown)'
# 23 is an arbitrary value to signal loop.sh that it must stop looping.
return 23
except errors.ConfigurationError as e:
parser.error(str(e))
return 1
# CQ generally doesn't exit by itself, but if we ever get here, it looks
# like a clean shutdown so remove the landmine file.
# TODO(phajdan.jr): Do we ever get here?
os.remove(landmine_path)
return 0
if __name__ == '__main__':
fix_encoding.fix_encoding()
sys.exit(main())