| #!/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()) |