| OLD | NEW |
| (Empty) |
| 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 | |
| 22 import errno | |
| 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 | |
| 37 import urlparse | |
| 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)', | |
| 58 'does not match your user account', | |
| 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): | |
| 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 '' | |
| 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.""" | |
| 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 '' | |
| 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(): | |
| 174 return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json') | |
| 175 elif is_using_svn(): | |
| 176 return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json') | |
| 177 else: | |
| 178 return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json') | |
| 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 | |
| 215 def __init__(self, cwd, verbose): | |
| 216 self.cwd = cwd | |
| 217 self.verbose = verbose | |
| 218 self.log = [] | |
| 219 | |
| 220 def run(self, cmd): | |
| 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) | |
| 244 | |
| 245 | |
| 246 def check_git_access(conf, report_url, verbose): | |
| 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, | |
| 259 verbose, | |
| 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. | |
| 273 runner = Runner(tmp, verbose) | |
| 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, | |
| 302 verbose, | |
| 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( | |
| 310 conf, report_url, verbose, push_works, push_log, push_duration_ms): | |
| 311 """Posts report to the server, returns True if server accepted it. | |
| 312 | |
| 313 Uploads the report only if script is running in Google corp network. Otherwise | |
| 314 just prints the report. | |
| 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 | |
| 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: | |
| 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(): | |
| 336 if verbose: | |
| 337 print ( | |
| 338 'You can send the above report to chrome-git-migration@google.com ' | |
| 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: | |
| 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) | |
| 362 success = True | |
| 363 except (urllib2.URLError, socket.error, ssl.SSLError) as exc: | |
| 364 logging.warning('Failed to upload the report: %s', exc) | |
| 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:])) | |
| OLD | NEW |