|  | #!/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. | 
|  | rietveld_obj = rietveld.ReadOnlyRietveld( | 
|  | url, | 
|  | options.user, | 
|  | gaia_creds.get(options.user), | 
|  | None) | 
|  | 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()) |