[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | # Copyright 2014 The Chromium Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | """Script that attempts to push to a special git repository to verify that git |
| 7 | credentials are configured correctly. It also attempts to fix misconfigurations |
| 8 | if possible. |
| 9 | |
| 10 | It will be added as gclient hook shortly before Chromium switches to git and |
| 11 | removed after the switch. |
| 12 | |
| 13 | When running as hook in *.corp.google.com network it will also report status |
| 14 | of the push attempt to the server (on appengine), so that chrome-infra team can |
| 15 | collect information about misconfigured Git accounts (to fix them). |
| 16 | |
| 17 | When invoked manually will do the access test and submit the report regardless |
| 18 | of where it is running. |
| 19 | """ |
| 20 | |
| 21 | import contextlib |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 22 | import errno |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 23 | import getpass |
| 24 | import json |
| 25 | import logging |
| 26 | import netrc |
| 27 | import optparse |
| 28 | import os |
| 29 | import shutil |
| 30 | import socket |
| 31 | import ssl |
| 32 | import subprocess |
| 33 | import sys |
| 34 | import tempfile |
| 35 | import time |
| 36 | import urllib2 |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 37 | import urlparse |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 38 | |
| 39 | |
| 40 | # Absolute path to src/ directory. |
| 41 | REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 42 | |
| 43 | # Incremented whenever some changes to scrip logic are made. Change in version |
| 44 | # will cause the check to be rerun on next gclient runhooks invocation. |
| 45 | CHECKER_VERSION = 0 |
| 46 | |
| 47 | # URL to POST json with results to. |
| 48 | MOTHERSHIP_URL = ( |
| 49 | 'https://chromium-git-access.appspot.com/' |
| 50 | 'git_access/api/v1/reports/access_check') |
| 51 | |
| 52 | # Repository to push test commits to. |
| 53 | TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test' |
| 54 | |
| 55 | # Possible chunks of git push response in case .netrc is misconfigured. |
| 56 | BAD_ACL_ERRORS = ( |
| 57 | '(prohibited by Gerrit)', |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 58 | 'does not match your user account', |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 59 | 'Invalid user name or password', |
| 60 | ) |
| 61 | |
| 62 | |
| 63 | def is_on_bot(): |
| 64 | """True when running under buildbot.""" |
| 65 | return os.environ.get('CHROME_HEADLESS') == '1' |
| 66 | |
| 67 | |
| 68 | def is_in_google_corp(): |
| 69 | """True when running in google corp network.""" |
| 70 | try: |
| 71 | return socket.getfqdn().endswith('.corp.google.com') |
| 72 | except socket.error: |
| 73 | logging.exception('Failed to get FQDN') |
| 74 | return False |
| 75 | |
| 76 | |
| 77 | def is_using_git(): |
| 78 | """True if git checkout is used.""" |
| 79 | return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects')) |
| 80 | |
| 81 | |
| 82 | def is_using_svn(): |
| 83 | """True if svn checkout is used.""" |
| 84 | return os.path.exists(os.path.join(REPO_ROOT, '.svn')) |
| 85 | |
| 86 | |
| 87 | def read_git_config(prop): |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 88 | """Reads git config property of src.git repo. |
| 89 | |
| 90 | Returns empty string in case of errors. |
| 91 | """ |
| 92 | try: |
| 93 | proc = subprocess.Popen( |
| 94 | ['git', 'config', prop], stdout=subprocess.PIPE, cwd=REPO_ROOT) |
| 95 | out, _ = proc.communicate() |
| 96 | return out.strip() |
| 97 | except OSError as exc: |
| 98 | if exc.errno != errno.ENOENT: |
| 99 | logging.exception('Unexpected error when calling git') |
| 100 | return '' |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 101 | |
| 102 | |
| 103 | def read_netrc_user(netrc_obj, host): |
| 104 | """Reads 'user' field of a host entry in netrc. |
| 105 | |
| 106 | Returns empty string if netrc is missing, or host is not there. |
| 107 | """ |
| 108 | if not netrc_obj: |
| 109 | return '' |
| 110 | entry = netrc_obj.authenticators(host) |
| 111 | if not entry: |
| 112 | return '' |
| 113 | return entry[0] |
| 114 | |
| 115 | |
| 116 | def get_git_version(): |
| 117 | """Returns version of git or None if git is not available.""" |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 118 | try: |
| 119 | proc = subprocess.Popen(['git', '--version'], stdout=subprocess.PIPE) |
| 120 | out, _ = proc.communicate() |
| 121 | return out.strip() if proc.returncode == 0 else '' |
| 122 | except OSError as exc: |
| 123 | if exc.errno != errno.ENOENT: |
| 124 | logging.exception('Unexpected error when calling git') |
| 125 | return '' |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 126 | |
| 127 | |
| 128 | def scan_configuration(): |
| 129 | """Scans local environment for git related configuration values.""" |
| 130 | # Git checkout? |
| 131 | is_git = is_using_git() |
| 132 | |
| 133 | # On Windows HOME should be set. |
| 134 | if 'HOME' in os.environ: |
| 135 | netrc_path = os.path.join( |
| 136 | os.environ['HOME'], |
| 137 | '_netrc' if sys.platform.startswith('win') else '.netrc') |
| 138 | else: |
| 139 | netrc_path = None |
| 140 | |
| 141 | # Netrc exists? |
| 142 | is_using_netrc = netrc_path and os.path.exists(netrc_path) |
| 143 | |
| 144 | # Read it. |
| 145 | netrc_obj = None |
| 146 | if is_using_netrc: |
| 147 | try: |
| 148 | netrc_obj = netrc.netrc(netrc_path) |
| 149 | except Exception: |
| 150 | logging.exception('Failed to read netrc from %s', netrc_path) |
| 151 | netrc_obj = None |
| 152 | |
| 153 | return { |
| 154 | 'checker_version': CHECKER_VERSION, |
| 155 | 'is_git': is_git, |
| 156 | 'is_home_set': 'HOME' in os.environ, |
| 157 | 'is_using_netrc': is_using_netrc, |
| 158 | 'netrc_file_mode': os.stat(netrc_path).st_mode if is_using_netrc else 0, |
| 159 | 'git_version': get_git_version(), |
| 160 | 'platform': sys.platform, |
| 161 | 'username': getpass.getuser(), |
| 162 | 'git_user_email': read_git_config('user.email') if is_git else '', |
| 163 | 'git_user_name': read_git_config('user.name') if is_git else '', |
| 164 | 'chromium_netrc_email': |
| 165 | read_netrc_user(netrc_obj, 'chromium.googlesource.com'), |
| 166 | 'chrome_internal_netrc_email': |
| 167 | read_netrc_user(netrc_obj, 'chrome-internal.googlesource.com'), |
| 168 | } |
| 169 | |
| 170 | |
| 171 | def last_configuration_path(): |
| 172 | """Path to store last checked configuration.""" |
| 173 | if is_using_git(): |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 174 | return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json') |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 175 | elif is_using_svn(): |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 176 | return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json') |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 177 | else: |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 178 | return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json') |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 179 | |
| 180 | |
| 181 | def read_last_configuration(): |
| 182 | """Reads last checked configuration if it exists.""" |
| 183 | try: |
| 184 | with open(last_configuration_path(), 'r') as f: |
| 185 | return json.load(f) |
| 186 | except (IOError, ValueError): |
| 187 | return None |
| 188 | |
| 189 | |
| 190 | def write_last_configuration(conf): |
| 191 | """Writes last checked configuration to a file.""" |
| 192 | try: |
| 193 | with open(last_configuration_path(), 'w') as f: |
| 194 | json.dump(conf, f, indent=2, sort_keys=True) |
| 195 | except IOError: |
| 196 | logging.exception('Failed to write JSON to %s', path) |
| 197 | |
| 198 | |
| 199 | @contextlib.contextmanager |
| 200 | def temp_directory(): |
| 201 | """Creates a temp directory, then nukes it.""" |
| 202 | tmp = tempfile.mkdtemp() |
| 203 | try: |
| 204 | yield tmp |
| 205 | finally: |
| 206 | try: |
| 207 | shutil.rmtree(tmp) |
| 208 | except (OSError, IOError): |
| 209 | logging.exception('Failed to remove temp directory %s', tmp) |
| 210 | |
| 211 | |
| 212 | class Runner(object): |
| 213 | """Runs a bunch of commands in some directory, collects logs from them.""" |
| 214 | |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 215 | def __init__(self, cwd, verbose): |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 216 | self.cwd = cwd |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 217 | self.verbose = verbose |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 218 | self.log = [] |
| 219 | |
| 220 | def run(self, cmd): |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 221 | self.append_to_log('> ' + ' '.join(cmd)) |
| 222 | retcode = -1 |
| 223 | try: |
| 224 | proc = subprocess.Popen( |
| 225 | cmd, |
| 226 | stdout=subprocess.PIPE, |
| 227 | stderr=subprocess.STDOUT, |
| 228 | cwd=self.cwd) |
| 229 | out, _ = proc.communicate() |
| 230 | out = out.strip() |
| 231 | retcode = proc.returncode |
| 232 | except OSError as exc: |
| 233 | out = str(exc) |
| 234 | if retcode: |
| 235 | out += '\n(exit code: %d)' % retcode |
| 236 | self.append_to_log(out) |
| 237 | return retcode |
| 238 | |
| 239 | def append_to_log(self, text): |
| 240 | if text: |
| 241 | self.log.append(text) |
| 242 | if self.verbose: |
| 243 | logging.warning(text) |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 244 | |
| 245 | |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 246 | def check_git_access(conf, report_url, verbose): |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 247 | """Attempts to push to a git repository, reports results to a server. |
| 248 | |
| 249 | Returns True if the check finished without incidents (push itself may |
| 250 | have failed) and should NOT be retried on next invocation of the hook. |
| 251 | """ |
| 252 | logging.warning('Checking push access to the git repository...') |
| 253 | |
| 254 | # Don't even try to push if netrc is not configured. |
| 255 | if not conf['chromium_netrc_email']: |
| 256 | return upload_report( |
| 257 | conf, |
| 258 | report_url, |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 259 | verbose, |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 260 | push_works=False, |
| 261 | push_log='', |
| 262 | push_duration_ms=0) |
| 263 | |
| 264 | # Ref to push to, each user has its own ref. |
| 265 | ref = 'refs/push-test/%s' % conf['chromium_netrc_email'] |
| 266 | |
| 267 | push_works = False |
| 268 | flake = False |
| 269 | started = time.time() |
| 270 | try: |
| 271 | with temp_directory() as tmp: |
| 272 | # Prepare a simple commit on a new timeline. |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 273 | runner = Runner(tmp, verbose) |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 274 | runner.run(['git', 'init', '.']) |
| 275 | if conf['git_user_name']: |
| 276 | runner.run(['git', 'config', 'user.name', conf['git_user_name']]) |
| 277 | if conf['git_user_email']: |
| 278 | runner.run(['git', 'config', 'user.email', conf['git_user_email']]) |
| 279 | with open(os.path.join(tmp, 'timestamp'), 'w') as f: |
| 280 | f.write(str(int(time.time() * 1000))) |
| 281 | runner.run(['git', 'add', 'timestamp']) |
| 282 | runner.run(['git', 'commit', '-m', 'Push test.']) |
| 283 | # Try to push multiple times if it fails due to issues other than ACLs. |
| 284 | attempt = 0 |
| 285 | while attempt < 5: |
| 286 | attempt += 1 |
| 287 | logging.info('Pushing to %s %s', TEST_REPO_URL, ref) |
| 288 | ret = runner.run(['git', 'push', TEST_REPO_URL, 'HEAD:%s' % ref, '-f']) |
| 289 | if not ret: |
| 290 | push_works = True |
| 291 | break |
| 292 | if any(x in runner.log[-1] for x in BAD_ACL_ERRORS): |
| 293 | push_works = False |
| 294 | break |
| 295 | except Exception: |
| 296 | logging.exception('Unexpected exception when pushing') |
| 297 | flake = True |
| 298 | |
| 299 | uploaded = upload_report( |
| 300 | conf, |
| 301 | report_url, |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 302 | verbose, |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 303 | push_works=push_works, |
| 304 | push_log='\n'.join(runner.log), |
| 305 | push_duration_ms=int((time.time() - started) * 1000)) |
| 306 | return uploaded and not flake |
| 307 | |
| 308 | |
| 309 | def upload_report( |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 310 | conf, report_url, verbose, push_works, push_log, push_duration_ms): |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 311 | """Posts report to the server, returns True if server accepted it. |
| 312 | |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 313 | Uploads the report only if script is running in Google corp network. Otherwise |
| 314 | just prints the report. |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 315 | """ |
| 316 | report = conf.copy() |
| 317 | report.update( |
| 318 | push_works=push_works, |
| 319 | push_log=push_log, |
| 320 | push_duration_ms=push_duration_ms) |
| 321 | |
| 322 | as_bytes = json.dumps({'access_check': report}, indent=2, sort_keys=True) |
| 323 | |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 324 | if push_works: |
| 325 | logging.warning('Git push works!') |
| 326 | else: |
| 327 | logging.warning( |
| 328 | 'Git push doesn\'t work, which is fine if you are not a committer.') |
| 329 | |
| 330 | if verbose: |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 331 | print 'Status of git push attempt:' |
| 332 | print as_bytes |
| 333 | |
| 334 | # Do not upload it outside of corp. |
| 335 | if not is_in_google_corp(): |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 336 | if verbose: |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 337 | print ( |
| 338 | 'You can send the above report to [email protected] ' |
| 339 | 'if you need help to set up you committer git account.') |
| 340 | return True |
| 341 | |
| 342 | req = urllib2.Request( |
| 343 | url=report_url, |
| 344 | data=as_bytes, |
| 345 | headers={'Content-Type': 'application/json; charset=utf-8'}) |
| 346 | |
| 347 | attempt = 0 |
| 348 | success = False |
| 349 | while not success and attempt < 10: |
| 350 | attempt += 1 |
| 351 | try: |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 352 | logging.warning( |
| 353 | 'Attempting to upload the report to %s...', |
| 354 | urlparse.urlparse(report_url).netloc) |
| 355 | resp = urllib2.urlopen(req, timeout=5) |
| 356 | report_id = None |
| 357 | try: |
| 358 | report_id = json.load(resp)['report_id'] |
| 359 | except (ValueError, TypeError, KeyError): |
| 360 | pass |
| 361 | logging.warning('Report uploaded: %s', report_id) |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 362 | success = True |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 363 | except (urllib2.URLError, socket.error, ssl.SSLError) as exc: |
[email protected] | aa52d31 | 2014-08-18 20:28:52 | [diff] [blame^] | 364 | logging.warning('Failed to upload the report: %s', exc) |
[email protected] | 94c6412 | 2014-08-16 02:03:55 | [diff] [blame] | 365 | return success |
| 366 | |
| 367 | |
| 368 | def main(args): |
| 369 | parser = optparse.OptionParser(description=sys.modules[__name__].__doc__) |
| 370 | parser.add_option( |
| 371 | '--running-as-hook', |
| 372 | action='store_true', |
| 373 | help='Set when invoked from gclient hook') |
| 374 | parser.add_option( |
| 375 | '--report-url', |
| 376 | default=MOTHERSHIP_URL, |
| 377 | help='URL to submit the report to') |
| 378 | parser.add_option( |
| 379 | '--verbose', |
| 380 | action='store_true', |
| 381 | help='More logging') |
| 382 | options, args = parser.parse_args() |
| 383 | if args: |
| 384 | parser.error('Unknown argument %s' % args) |
| 385 | logging.basicConfig( |
| 386 | format='%(message)s', |
| 387 | level=logging.INFO if options.verbose else logging.WARN) |
| 388 | |
| 389 | # When invoked not as hook, always run the check. |
| 390 | if not options.running_as_hook: |
| 391 | if check_git_access(scan_configuration(), options.report_url, True): |
| 392 | return 0 |
| 393 | return 1 |
| 394 | |
| 395 | # Otherwise, do it only on google owned, non-bot machines. |
| 396 | if is_on_bot() or not is_in_google_corp(): |
| 397 | logging.info('Skipping the check: bot or non corp.') |
| 398 | return 0 |
| 399 | |
| 400 | # Skip the check if current configuration was already checked. |
| 401 | config = scan_configuration() |
| 402 | if config == read_last_configuration(): |
| 403 | logging.info('Check already performed, skipping.') |
| 404 | return 0 |
| 405 | |
| 406 | # Run the check. Mark configuration as checked only on success. Ignore any |
| 407 | # exceptions or errors. This check must not break gclient runhooks. |
| 408 | try: |
| 409 | ok = check_git_access(config, options.report_url, False) |
| 410 | if ok: |
| 411 | write_last_configuration(config) |
| 412 | else: |
| 413 | logging.warning('Check failed and will be retried on the next run') |
| 414 | except Exception: |
| 415 | logging.exception('Unexpected exception when performing git access check') |
| 416 | return 0 |
| 417 | |
| 418 | |
| 419 | if __name__ == '__main__': |
| 420 | sys.exit(main(sys.argv[1:])) |