Index: tools/check_git_push_access.py |
diff --git a/tools/check_git_push_access.py b/tools/check_git_push_access.py |
new file mode 100755 |
index 0000000000000000000000000000000000000000..2e71a1b64421ca2c57a66d2ea47afb17f5362f86 |
--- /dev/null |
+++ b/tools/check_git_push_access.py |
@@ -0,0 +1,381 @@ |
+#!/usr/bin/env python |
+# Copyright 2014 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. |
+ |
+"""Script that attempts to push to a special git repository to verify that git |
+credentials are configured correctly. It also attempts to fix misconfigurations |
+if possible. |
+ |
+It will be added as gclient hook shortly before Chromium switches to git and |
+removed after the switch. |
+ |
+When running as hook in *.corp.google.com network it will also report status |
+of the push attempt to the server (on appengine), so that chrome-infra team can |
+collect information about misconfigured Git accounts (to fix them). |
+ |
+When invoked manually will do the access test and submit the report regardless |
+of where it is running. |
+""" |
+ |
+import contextlib |
+import getpass |
+import json |
+import logging |
+import netrc |
+import optparse |
+import os |
+import shutil |
+import socket |
+import ssl |
+import subprocess |
+import sys |
+import tempfile |
+import time |
+import urllib2 |
+ |
+ |
+# Absolute path to src/ directory. |
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
+ |
+# Incremented whenever some changes to scrip logic are made. Change in version |
+# will cause the check to be rerun on next gclient runhooks invocation. |
+CHECKER_VERSION = 0 |
+ |
+# URL to POST json with results to. |
+MOTHERSHIP_URL = ( |
+ 'https://chromium-git-access.appspot.com/' |
+ 'git_access/api/v1/reports/access_check') |
+ |
+# Repository to push test commits to. |
+TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test' |
+ |
+# Possible chunks of git push response in case .netrc is misconfigured. |
+BAD_ACL_ERRORS = ( |
+ '(prohibited by Gerrit)', |
+ 'Invalid user name or password', |
+) |
+ |
+ |
+def is_on_bot(): |
+ """True when running under buildbot.""" |
+ return os.environ.get('CHROME_HEADLESS') == '1' |
+ |
+ |
+def is_in_google_corp(): |
+ """True when running in google corp network.""" |
+ try: |
+ return socket.getfqdn().endswith('.corp.google.com') |
+ except socket.error: |
+ logging.exception('Failed to get FQDN') |
+ return False |
+ |
+ |
+def is_using_git(): |
+ """True if git checkout is used.""" |
+ return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects')) |
+ |
+ |
+def is_using_svn(): |
+ """True if svn checkout is used.""" |
+ return os.path.exists(os.path.join(REPO_ROOT, '.svn')) |
+ |
+ |
+def read_git_config(prop): |
+ """Reads git config property of src.git repo.""" |
+ proc = subprocess.Popen( |
+ ['git', 'config', prop], stdout=subprocess.PIPE, cwd=REPO_ROOT) |
+ out, _ = proc.communicate() |
+ return out.strip() |
+ |
+ |
+def read_netrc_user(netrc_obj, host): |
+ """Reads 'user' field of a host entry in netrc. |
+ |
+ Returns empty string if netrc is missing, or host is not there. |
+ """ |
+ if not netrc_obj: |
+ return '' |
+ entry = netrc_obj.authenticators(host) |
+ if not entry: |
+ return '' |
+ return entry[0] |
+ |
+ |
+def get_git_version(): |
+ """Returns version of git or None if git is not available.""" |
+ proc = subprocess.Popen(['git', '--version'], stdout=subprocess.PIPE) |
+ out, _ = proc.communicate() |
+ return out.strip() if proc.returncode == 0 else '' |
+ |
+ |
+def scan_configuration(): |
+ """Scans local environment for git related configuration values.""" |
+ # Git checkout? |
+ is_git = is_using_git() |
+ |
+ # On Windows HOME should be set. |
+ if 'HOME' in os.environ: |
+ netrc_path = os.path.join( |
+ os.environ['HOME'], |
+ '_netrc' if sys.platform.startswith('win') else '.netrc') |
+ else: |
+ netrc_path = None |
+ |
+ # Netrc exists? |
+ is_using_netrc = netrc_path and os.path.exists(netrc_path) |
+ |
+ # Read it. |
+ netrc_obj = None |
+ if is_using_netrc: |
+ try: |
+ netrc_obj = netrc.netrc(netrc_path) |
+ except Exception: |
+ logging.exception('Failed to read netrc from %s', netrc_path) |
+ netrc_obj = None |
+ |
+ return { |
+ 'checker_version': CHECKER_VERSION, |
+ 'is_git': is_git, |
+ 'is_home_set': 'HOME' in os.environ, |
+ 'is_using_netrc': is_using_netrc, |
+ 'netrc_file_mode': os.stat(netrc_path).st_mode if is_using_netrc else 0, |
+ 'git_version': get_git_version(), |
+ 'platform': sys.platform, |
+ 'username': getpass.getuser(), |
+ 'git_user_email': read_git_config('user.email') if is_git else '', |
+ 'git_user_name': read_git_config('user.name') if is_git else '', |
+ 'chromium_netrc_email': |
+ read_netrc_user(netrc_obj, 'chromium.googlesource.com'), |
+ 'chrome_internal_netrc_email': |
+ read_netrc_user(netrc_obj, 'chrome-internal.googlesource.com'), |
+ } |
+ |
+ |
+def last_configuration_path(): |
+ """Path to store last checked configuration.""" |
+ if is_using_git(): |
+ return os.path.join(REPO_ROOT, '.git', 'check_git_access_conf.json') |
+ elif is_using_svn(): |
+ return os.path.join(REPO_ROOT, '.svn', 'check_git_access_conf.json') |
+ else: |
+ return os.path.join(REPO_ROOT, '.check_git_access_conf.json') |
+ |
+ |
+def read_last_configuration(): |
+ """Reads last checked configuration if it exists.""" |
+ try: |
+ with open(last_configuration_path(), 'r') as f: |
+ return json.load(f) |
+ except (IOError, ValueError): |
+ return None |
+ |
+ |
+def write_last_configuration(conf): |
+ """Writes last checked configuration to a file.""" |
+ try: |
+ with open(last_configuration_path(), 'w') as f: |
+ json.dump(conf, f, indent=2, sort_keys=True) |
+ except IOError: |
+ logging.exception('Failed to write JSON to %s', path) |
+ |
+ |
+@contextlib.contextmanager |
+def temp_directory(): |
+ """Creates a temp directory, then nukes it.""" |
+ tmp = tempfile.mkdtemp() |
+ try: |
+ yield tmp |
+ finally: |
+ try: |
+ shutil.rmtree(tmp) |
+ except (OSError, IOError): |
+ logging.exception('Failed to remove temp directory %s', tmp) |
+ |
+ |
+class Runner(object): |
+ """Runs a bunch of commands in some directory, collects logs from them.""" |
+ |
+ def __init__(self, cwd): |
+ self.cwd = cwd |
+ self.log = [] |
+ |
+ def run(self, cmd): |
+ log = ['> ' + ' '.join(cmd)] |
+ proc = subprocess.Popen( |
+ cmd, |
+ stdout=subprocess.PIPE, |
+ stderr=subprocess.STDOUT, |
+ cwd=self.cwd) |
+ out, _ = proc.communicate() |
+ out = out.strip() |
+ if out: |
+ log.append(out) |
+ if proc.returncode: |
+ log.append('(exit code: %d)' % proc.returncode) |
+ self.log.append('\n'.join(log)) |
+ return proc.returncode |
+ |
+ |
+def check_git_access(conf, report_url, interactive): |
+ """Attempts to push to a git repository, reports results to a server. |
+ |
+ Returns True if the check finished without incidents (push itself may |
+ have failed) and should NOT be retried on next invocation of the hook. |
+ """ |
+ logging.warning('Checking push access to the git repository...') |
+ |
+ # Don't even try to push if netrc is not configured. |
+ if not conf['chromium_netrc_email']: |
+ return upload_report( |
+ conf, |
+ report_url, |
+ interactive, |
+ push_works=False, |
+ push_log='', |
+ push_duration_ms=0) |
+ |
+ # Ref to push to, each user has its own ref. |
+ ref = 'refs/push-test/%s' % conf['chromium_netrc_email'] |
+ |
+ push_works = False |
+ flake = False |
+ started = time.time() |
+ try: |
+ with temp_directory() as tmp: |
+ # Prepare a simple commit on a new timeline. |
+ runner = Runner(tmp) |
+ runner.run(['git', 'init', '.']) |
+ if conf['git_user_name']: |
+ runner.run(['git', 'config', 'user.name', conf['git_user_name']]) |
+ if conf['git_user_email']: |
+ runner.run(['git', 'config', 'user.email', conf['git_user_email']]) |
+ with open(os.path.join(tmp, 'timestamp'), 'w') as f: |
+ f.write(str(int(time.time() * 1000))) |
+ runner.run(['git', 'add', 'timestamp']) |
+ runner.run(['git', 'commit', '-m', 'Push test.']) |
+ # Try to push multiple times if it fails due to issues other than ACLs. |
+ attempt = 0 |
+ while attempt < 5: |
+ attempt += 1 |
+ logging.info('Pushing to %s %s', TEST_REPO_URL, ref) |
+ ret = runner.run(['git', 'push', TEST_REPO_URL, 'HEAD:%s' % ref, '-f']) |
+ if not ret: |
+ push_works = True |
+ break |
+ if any(x in runner.log[-1] for x in BAD_ACL_ERRORS): |
+ push_works = False |
+ break |
+ except Exception: |
+ logging.exception('Unexpected exception when pushing') |
+ flake = True |
+ |
+ uploaded = upload_report( |
+ conf, |
+ report_url, |
+ interactive, |
+ push_works=push_works, |
+ push_log='\n'.join(runner.log), |
+ push_duration_ms=int((time.time() - started) * 1000)) |
+ return uploaded and not flake |
+ |
+ |
+def upload_report( |
+ conf, report_url, interactive, push_works, push_log, push_duration_ms): |
+ """Posts report to the server, returns True if server accepted it. |
+ |
+ If interactive is True and the script is running outside of *.corp.google.com |
+ network, will ask the user to submit collected information manually. |
+ """ |
+ report = conf.copy() |
+ report.update( |
+ push_works=push_works, |
+ push_log=push_log, |
+ push_duration_ms=push_duration_ms) |
+ |
+ as_bytes = json.dumps({'access_check': report}, indent=2, sort_keys=True) |
+ |
+ if interactive: |
+ print 'Status of git push attempt:' |
+ print as_bytes |
+ |
+ # Do not upload it outside of corp. |
+ if not is_in_google_corp(): |
+ if interactive: |
+ print ( |
+ 'You can send the above report to chrome-git-migration@google.com ' |
+ 'if you need help to set up you committer git account.') |
+ return True |
+ |
+ req = urllib2.Request( |
+ url=report_url, |
+ data=as_bytes, |
+ headers={'Content-Type': 'application/json; charset=utf-8'}) |
+ |
+ attempt = 0 |
+ success = False |
+ while not success and attempt < 10: |
+ attempt += 1 |
+ try: |
+ logging.info('Attempting to upload the report to %s', report_url) |
+ urllib2.urlopen(req, timeout=5) |
+ success = True |
+ logging.warning('Report uploaded.') |
+ except (urllib2.URLError, socket.error, ssl.SSLError) as exc: |
+ logging.info('Failed to upload the report: %s', exc) |
+ return success |
+ |
+ |
+def main(args): |
+ parser = optparse.OptionParser(description=sys.modules[__name__].__doc__) |
+ parser.add_option( |
+ '--running-as-hook', |
+ action='store_true', |
+ help='Set when invoked from gclient hook') |
+ parser.add_option( |
+ '--report-url', |
+ default=MOTHERSHIP_URL, |
+ help='URL to submit the report to') |
+ parser.add_option( |
+ '--verbose', |
+ action='store_true', |
+ help='More logging') |
+ options, args = parser.parse_args() |
+ if args: |
+ parser.error('Unknown argument %s' % args) |
+ logging.basicConfig( |
+ format='%(message)s', |
+ level=logging.INFO if options.verbose else logging.WARN) |
+ |
+ # When invoked not as hook, always run the check. |
+ if not options.running_as_hook: |
+ if check_git_access(scan_configuration(), options.report_url, True): |
+ return 0 |
+ return 1 |
+ |
+ # Otherwise, do it only on google owned, non-bot machines. |
+ if is_on_bot() or not is_in_google_corp(): |
+ logging.info('Skipping the check: bot or non corp.') |
+ return 0 |
+ |
+ # Skip the check if current configuration was already checked. |
+ config = scan_configuration() |
+ if config == read_last_configuration(): |
+ logging.info('Check already performed, skipping.') |
+ return 0 |
+ |
+ # Run the check. Mark configuration as checked only on success. Ignore any |
+ # exceptions or errors. This check must not break gclient runhooks. |
+ try: |
+ ok = check_git_access(config, options.report_url, False) |
+ if ok: |
+ write_last_configuration(config) |
+ else: |
+ logging.warning('Check failed and will be retried on the next run') |
+ except Exception: |
+ logging.exception('Unexpected exception when performing git access check') |
+ return 0 |
+ |
+ |
+if __name__ == '__main__': |
+ sys.exit(main(sys.argv[1:])) |