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