[MD settings] moving attached() code
[chromium-blink-merge.git] / tools / check_git_config.py
blob17b0d1ef4d742b015977d565531a24413e9932c4
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.
6 """Script that attempts to push to a special git repository to verify that git
7 credentials are configured correctly. It also verifies that gclient solution is
8 configured to use git checkout.
10 It will be added as gclient hook shortly before Chromium switches to git and
11 removed after the switch.
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.
16 """
18 import contextlib
19 import datetime
20 import errno
21 import getpass
22 import json
23 import logging
24 import netrc
25 import optparse
26 import os
27 import pprint
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 import urlparse
39 # Absolute path to src/ directory.
40 REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
42 # Absolute path to a file with gclient solutions.
43 GCLIENT_CONFIG = os.path.join(os.path.dirname(REPO_ROOT), '.gclient')
45 # Incremented whenever some changes to scrip logic are made. Change in version
46 # will cause the check to be rerun on next gclient runhooks invocation.
47 CHECKER_VERSION = 1
49 # Do not attempt to upload a report after this date.
50 UPLOAD_DISABLE_TS = datetime.datetime(2014, 10, 1)
52 # URL to POST json with results to.
53 MOTHERSHIP_URL = (
54 'https://chromium-git-access.appspot.com/'
55 'git_access/api/v1/reports/access_check')
57 # Repository to push test commits to.
58 TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test'
60 # Git-compatible gclient solution.
61 GOOD_GCLIENT_SOLUTION = {
62 'name': 'src',
63 'deps_file': 'DEPS',
64 'managed': False,
65 'url': 'https://chromium.googlesource.com/chromium/src.git',
68 # Possible chunks of git push response in case .netrc is misconfigured.
69 BAD_ACL_ERRORS = (
70 '(prohibited by Gerrit)',
71 'does not match your user account',
72 'Git repository not found',
73 'Invalid user name or password',
74 'Please make sure you have the correct access rights',
77 # Git executable to call.
78 GIT_EXE = 'git.bat' if sys.platform == 'win32' else 'git'
81 def is_on_bot():
82 """True when running under buildbot."""
83 return os.environ.get('CHROME_HEADLESS') == '1'
86 def is_in_google_corp():
87 """True when running in google corp network."""
88 try:
89 return socket.getfqdn().endswith('.corp.google.com')
90 except socket.error:
91 logging.exception('Failed to get FQDN')
92 return False
95 def is_using_git():
96 """True if git checkout is used."""
97 return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects'))
100 def is_using_svn():
101 """True if svn checkout is used."""
102 return os.path.exists(os.path.join(REPO_ROOT, '.svn'))
105 def read_git_config(prop):
106 """Reads git config property of src.git repo.
108 Returns empty string in case of errors.
110 try:
111 proc = subprocess.Popen(
112 [GIT_EXE, 'config', prop], stdout=subprocess.PIPE, cwd=REPO_ROOT)
113 out, _ = proc.communicate()
114 return out.strip().decode('utf-8')
115 except OSError as exc:
116 if exc.errno != errno.ENOENT:
117 logging.exception('Unexpected error when calling git')
118 return ''
121 def read_netrc_user(netrc_obj, host):
122 """Reads 'user' field of a host entry in netrc.
124 Returns empty string if netrc is missing, or host is not there.
126 if not netrc_obj:
127 return ''
128 entry = netrc_obj.authenticators(host)
129 if not entry:
130 return ''
131 return entry[0]
134 def get_git_version():
135 """Returns version of git or None if git is not available."""
136 try:
137 proc = subprocess.Popen([GIT_EXE, '--version'], stdout=subprocess.PIPE)
138 out, _ = proc.communicate()
139 return out.strip() if proc.returncode == 0 else ''
140 except OSError as exc:
141 if exc.errno != errno.ENOENT:
142 logging.exception('Unexpected error when calling git')
143 return ''
146 def read_gclient_solution():
147 """Read information about 'src' gclient solution from .gclient file.
149 Returns tuple:
150 (url, deps_file, managed)
152 (None, None, None) if no such solution.
154 try:
155 env = {}
156 execfile(GCLIENT_CONFIG, env, env)
157 for sol in (env.get('solutions') or []):
158 if sol.get('name') == 'src':
159 return sol.get('url'), sol.get('deps_file'), sol.get('managed')
160 return None, None, None
161 except Exception:
162 logging.exception('Failed to read .gclient solution')
163 return None, None, None
166 def read_git_insteadof(host):
167 """Reads relevant insteadOf config entries."""
168 try:
169 proc = subprocess.Popen([GIT_EXE, 'config', '-l'], stdout=subprocess.PIPE)
170 out, _ = proc.communicate()
171 lines = []
172 for line in out.strip().split('\n'):
173 line = line.lower()
174 if 'insteadof=' in line and host in line:
175 lines.append(line)
176 return '\n'.join(lines)
177 except OSError as exc:
178 if exc.errno != errno.ENOENT:
179 logging.exception('Unexpected error when calling git')
180 return ''
183 def scan_configuration():
184 """Scans local environment for git related configuration values."""
185 # Git checkout?
186 is_git = is_using_git()
188 # On Windows HOME should be set.
189 if 'HOME' in os.environ:
190 netrc_path = os.path.join(
191 os.environ['HOME'],
192 '_netrc' if sys.platform.startswith('win') else '.netrc')
193 else:
194 netrc_path = None
196 # Netrc exists?
197 is_using_netrc = netrc_path and os.path.exists(netrc_path)
199 # Read it.
200 netrc_obj = None
201 if is_using_netrc:
202 try:
203 netrc_obj = netrc.netrc(netrc_path)
204 except Exception:
205 logging.exception('Failed to read netrc from %s', netrc_path)
206 netrc_obj = None
208 # Read gclient 'src' solution.
209 gclient_url, gclient_deps, gclient_managed = read_gclient_solution()
211 return {
212 'checker_version': CHECKER_VERSION,
213 'is_git': is_git,
214 'is_home_set': 'HOME' in os.environ,
215 'is_using_netrc': is_using_netrc,
216 'netrc_file_mode': os.stat(netrc_path).st_mode if is_using_netrc else 0,
217 'git_version': get_git_version(),
218 'platform': sys.platform,
219 'username': getpass.getuser(),
220 'git_user_email': read_git_config('user.email') if is_git else '',
221 'git_user_name': read_git_config('user.name') if is_git else '',
222 'git_insteadof': read_git_insteadof('chromium.googlesource.com'),
223 'chromium_netrc_email':
224 read_netrc_user(netrc_obj, 'chromium.googlesource.com'),
225 'chrome_internal_netrc_email':
226 read_netrc_user(netrc_obj, 'chrome-internal.googlesource.com'),
227 'gclient_deps': gclient_deps,
228 'gclient_managed': gclient_managed,
229 'gclient_url': gclient_url,
233 def last_configuration_path():
234 """Path to store last checked configuration."""
235 if is_using_git():
236 return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json')
237 elif is_using_svn():
238 return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json')
239 else:
240 return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json')
243 def read_last_configuration():
244 """Reads last checked configuration if it exists."""
245 try:
246 with open(last_configuration_path(), 'r') as f:
247 return json.load(f)
248 except (IOError, ValueError):
249 return None
252 def write_last_configuration(conf):
253 """Writes last checked configuration to a file."""
254 try:
255 with open(last_configuration_path(), 'w') as f:
256 json.dump(conf, f, indent=2, sort_keys=True)
257 except IOError:
258 logging.exception('Failed to write JSON to %s', path)
261 @contextlib.contextmanager
262 def temp_directory():
263 """Creates a temp directory, then nukes it."""
264 tmp = tempfile.mkdtemp()
265 try:
266 yield tmp
267 finally:
268 try:
269 shutil.rmtree(tmp)
270 except (OSError, IOError):
271 logging.exception('Failed to remove temp directory %s', tmp)
274 class Runner(object):
275 """Runs a bunch of commands in some directory, collects logs from them."""
277 def __init__(self, cwd, verbose):
278 self.cwd = cwd
279 self.verbose = verbose
280 self.log = []
282 def run(self, cmd):
283 self.append_to_log('> ' + ' '.join(cmd))
284 retcode = -1
285 try:
286 proc = subprocess.Popen(
287 cmd,
288 stdout=subprocess.PIPE,
289 stderr=subprocess.STDOUT,
290 cwd=self.cwd)
291 out, _ = proc.communicate()
292 out = out.strip()
293 retcode = proc.returncode
294 except OSError as exc:
295 out = str(exc)
296 if retcode:
297 out += '\n(exit code: %d)' % retcode
298 self.append_to_log(out)
299 return retcode
301 def append_to_log(self, text):
302 if text:
303 self.log.append(text)
304 if self.verbose:
305 logging.warning(text)
308 def check_git_config(conf, report_url, verbose):
309 """Attempts to push to a git repository, reports results to a server.
311 Returns True if the check finished without incidents (push itself may
312 have failed) and should NOT be retried on next invocation of the hook.
314 # Don't even try to push if netrc is not configured.
315 if not conf['chromium_netrc_email']:
316 return upload_report(
317 conf,
318 report_url,
319 verbose,
320 push_works=False,
321 push_log='',
322 push_duration_ms=0)
324 # Ref to push to, each user has its own ref.
325 ref = 'refs/push-test/%s' % conf['chromium_netrc_email']
327 push_works = False
328 flake = False
329 started = time.time()
330 try:
331 logging.warning('Checking push access to the git repository...')
332 with temp_directory() as tmp:
333 # Prepare a simple commit on a new timeline.
334 runner = Runner(tmp, verbose)
335 runner.run([GIT_EXE, 'init', '.'])
336 if conf['git_user_name']:
337 runner.run([GIT_EXE, 'config', 'user.name', conf['git_user_name']])
338 if conf['git_user_email']:
339 runner.run([GIT_EXE, 'config', 'user.email', conf['git_user_email']])
340 with open(os.path.join(tmp, 'timestamp'), 'w') as f:
341 f.write(str(int(time.time() * 1000)))
342 runner.run([GIT_EXE, 'add', 'timestamp'])
343 runner.run([GIT_EXE, 'commit', '-m', 'Push test.'])
344 # Try to push multiple times if it fails due to issues other than ACLs.
345 attempt = 0
346 while attempt < 5:
347 attempt += 1
348 logging.info('Pushing to %s %s', TEST_REPO_URL, ref)
349 ret = runner.run(
350 [GIT_EXE, 'push', TEST_REPO_URL, 'HEAD:%s' % ref, '-f'])
351 if not ret:
352 push_works = True
353 break
354 if any(x in runner.log[-1] for x in BAD_ACL_ERRORS):
355 push_works = False
356 break
357 except Exception:
358 logging.exception('Unexpected exception when pushing')
359 flake = True
361 if push_works:
362 logging.warning('Git push works!')
363 else:
364 logging.warning(
365 'Git push doesn\'t work, which is fine if you are not a committer.')
367 uploaded = upload_report(
368 conf,
369 report_url,
370 verbose,
371 push_works=push_works,
372 push_log='\n'.join(runner.log),
373 push_duration_ms=int((time.time() - started) * 1000))
374 return uploaded and not flake
377 def check_gclient_config(conf):
378 """Shows warning if gclient solution is not properly configured for git."""
379 # Ignore configs that do not have 'src' solution at all.
380 if not conf['gclient_url']:
381 return
382 current = {
383 'name': 'src',
384 'deps_file': conf['gclient_deps'] or 'DEPS',
385 'managed': conf['gclient_managed'] or False,
386 'url': conf['gclient_url'],
388 # After depot_tools r291592 both DEPS and .DEPS.git are valid.
389 good = GOOD_GCLIENT_SOLUTION.copy()
390 good['deps_file'] = current['deps_file']
391 if current == good:
392 return
393 # Show big warning if url or deps_file is wrong.
394 if current['url'] != good['url'] or current['deps_file'] != good['deps_file']:
395 print '-' * 80
396 print 'Your gclient solution is not set to use supported git workflow!'
397 print
398 print 'Your \'src\' solution (in %s):' % GCLIENT_CONFIG
399 print pprint.pformat(current, indent=2)
400 print
401 print 'Correct \'src\' solution to use git:'
402 print pprint.pformat(good, indent=2)
403 print
404 print 'Please update your .gclient file ASAP.'
405 print '-' * 80
406 # Show smaller (additional) warning about managed workflow.
407 if current['managed']:
408 print '-' * 80
409 print (
410 'You are using managed gclient mode with git, which was deprecated '
411 'on 8/22/13:')
412 print (
413 'https://groups.google.com/a/chromium.org/'
414 'forum/#!topic/chromium-dev/n9N5N3JL2_U')
415 print
416 print (
417 'It is strongly advised to switch to unmanaged mode. For more '
418 'information about managed mode and reasons for its deprecation see:')
419 print 'http://www.chromium.org/developers/how-tos/get-the-code/gclient-managed-mode'
420 print
421 print (
422 'There\'s also a large suite of tools to assist managing git '
423 'checkouts.\nSee \'man depot_tools\' (or read '
424 'depot_tools/man/html/depot_tools.html).')
425 print '-' * 80
428 def upload_report(
429 conf, report_url, verbose, push_works, push_log, push_duration_ms):
430 """Posts report to the server, returns True if server accepted it.
432 Uploads the report only if script is running in Google corp network. Otherwise
433 just prints the report.
435 report = conf.copy()
436 report.update(
437 push_works=push_works,
438 push_log=push_log,
439 push_duration_ms=push_duration_ms)
441 as_bytes = json.dumps({'access_check': report}, indent=2, sort_keys=True)
442 if verbose:
443 print 'Status of git push attempt:'
444 print as_bytes
446 # Do not upload it outside of corp or if server side is already disabled.
447 if not is_in_google_corp() or datetime.datetime.now() > UPLOAD_DISABLE_TS:
448 if verbose:
449 print (
450 'You can send the above report to chrome-git-migration@google.com '
451 'if you need help to set up you committer git account.')
452 return True
454 req = urllib2.Request(
455 url=report_url,
456 data=as_bytes,
457 headers={'Content-Type': 'application/json; charset=utf-8'})
459 attempt = 0
460 success = False
461 while not success and attempt < 10:
462 attempt += 1
463 try:
464 logging.warning(
465 'Attempting to upload the report to %s...',
466 urlparse.urlparse(report_url).netloc)
467 resp = urllib2.urlopen(req, timeout=5)
468 report_id = None
469 try:
470 report_id = json.load(resp)['report_id']
471 except (ValueError, TypeError, KeyError):
472 pass
473 logging.warning('Report uploaded: %s', report_id)
474 success = True
475 except (urllib2.URLError, socket.error, ssl.SSLError) as exc:
476 logging.warning('Failed to upload the report: %s', exc)
477 return success
480 def main(args):
481 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
482 parser.add_option(
483 '--running-as-hook',
484 action='store_true',
485 help='Set when invoked from gclient hook')
486 parser.add_option(
487 '--report-url',
488 default=MOTHERSHIP_URL,
489 help='URL to submit the report to')
490 parser.add_option(
491 '--verbose',
492 action='store_true',
493 help='More logging')
494 options, args = parser.parse_args()
495 if args:
496 parser.error('Unknown argument %s' % args)
497 logging.basicConfig(
498 format='%(message)s',
499 level=logging.INFO if options.verbose else logging.WARN)
501 # When invoked not as a hook, always run the check.
502 if not options.running_as_hook:
503 config = scan_configuration()
504 check_gclient_config(config)
505 check_git_config(config, options.report_url, True)
506 return 0
508 # Always do nothing on bots.
509 if is_on_bot():
510 return 0
512 # Read current config, verify gclient solution looks correct.
513 config = scan_configuration()
514 check_gclient_config(config)
516 # Do not attempt to push from non-google owned machines.
517 if not is_in_google_corp():
518 logging.info('Skipping git push check: non *.corp.google.com machine.')
519 return 0
521 # Skip git push check if current configuration was already checked.
522 if config == read_last_configuration():
523 logging.info('Check already performed, skipping.')
524 return 0
526 # Run the check. Mark configuration as checked only on success. Ignore any
527 # exceptions or errors. This check must not break gclient runhooks.
528 try:
529 ok = check_git_config(config, options.report_url, False)
530 if ok:
531 write_last_configuration(config)
532 else:
533 logging.warning('Check failed and will be retried on the next run')
534 except Exception:
535 logging.exception('Unexpected exception when performing git access check')
536 return 0
539 if __name__ == '__main__':
540 sys.exit(main(sys.argv[1:]))