blob: 18b62ef378c195f3a603966564fe644726fd1bc5 [file] [log] [blame]
[email protected]94c64122014-08-16 02:03:551#!/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
[email protected]46b32a82014-08-19 00:37:577credentials are configured correctly. It also verifies that gclient solution is
8configured to use git checkout.
[email protected]94c64122014-08-16 02:03:559
10It will be added as gclient hook shortly before Chromium switches to git and
11removed after the switch.
12
13When running as hook in *.corp.google.com network it will also report status
14of the push attempt to the server (on appengine), so that chrome-infra team can
[email protected]46b32a82014-08-19 00:37:5715collect information about misconfigured Git accounts.
[email protected]94c64122014-08-16 02:03:5516"""
17
18import contextlib
[email protected]46b32a82014-08-19 00:37:5719import datetime
[email protected]aa52d312014-08-18 20:28:5220import errno
[email protected]94c64122014-08-16 02:03:5521import getpass
22import json
23import logging
24import netrc
25import optparse
26import os
[email protected]46b32a82014-08-19 00:37:5727import pprint
[email protected]94c64122014-08-16 02:03:5528import shutil
29import socket
30import ssl
31import subprocess
32import sys
33import tempfile
34import time
35import urllib2
[email protected]aa52d312014-08-18 20:28:5236import urlparse
[email protected]94c64122014-08-16 02:03:5537
38
39# Absolute path to src/ directory.
40REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41
[email protected]46b32a82014-08-19 00:37:5742# Absolute path to a file with gclient solutions.
43GCLIENT_CONFIG = os.path.join(os.path.dirname(REPO_ROOT), '.gclient')
44
[email protected]94c64122014-08-16 02:03:5545# 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.
47CHECKER_VERSION = 0
48
[email protected]46b32a82014-08-19 00:37:5749# Do not attempt to upload a report after this date.
50UPLOAD_DISABLE_TS = datetime.datetime(2014, 10, 1)
51
[email protected]94c64122014-08-16 02:03:5552# URL to POST json with results to.
53MOTHERSHIP_URL = (
54 'https://chromium-git-access.appspot.com/'
55 'git_access/api/v1/reports/access_check')
56
57# Repository to push test commits to.
58TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test'
59
[email protected]46b32a82014-08-19 00:37:5760# Git-compatible gclient solution.
61GOOD_GCLIENT_SOLUTION = {
62 'name': 'src',
63 'deps_file': '.DEPS.git',
64 'managed': False,
65 'url': 'https://chromium.googlesource.com/chromium/src.git',
66}
67
[email protected]94c64122014-08-16 02:03:5568# Possible chunks of git push response in case .netrc is misconfigured.
69BAD_ACL_ERRORS = (
70 '(prohibited by Gerrit)',
[email protected]aa52d312014-08-18 20:28:5271 'does not match your user account',
[email protected]94c64122014-08-16 02:03:5572 'Invalid user name or password',
73)
74
75
76def is_on_bot():
77 """True when running under buildbot."""
78 return os.environ.get('CHROME_HEADLESS') == '1'
79
80
81def is_in_google_corp():
82 """True when running in google corp network."""
83 try:
84 return socket.getfqdn().endswith('.corp.google.com')
85 except socket.error:
86 logging.exception('Failed to get FQDN')
87 return False
88
89
90def is_using_git():
91 """True if git checkout is used."""
92 return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects'))
93
94
95def is_using_svn():
96 """True if svn checkout is used."""
97 return os.path.exists(os.path.join(REPO_ROOT, '.svn'))
98
99
100def read_git_config(prop):
[email protected]aa52d312014-08-18 20:28:52101 """Reads git config property of src.git repo.
102
103 Returns empty string in case of errors.
104 """
105 try:
106 proc = subprocess.Popen(
107 ['git', 'config', prop], stdout=subprocess.PIPE, cwd=REPO_ROOT)
108 out, _ = proc.communicate()
109 return out.strip()
110 except OSError as exc:
111 if exc.errno != errno.ENOENT:
112 logging.exception('Unexpected error when calling git')
113 return ''
[email protected]94c64122014-08-16 02:03:55114
115
116def read_netrc_user(netrc_obj, host):
117 """Reads 'user' field of a host entry in netrc.
118
119 Returns empty string if netrc is missing, or host is not there.
120 """
121 if not netrc_obj:
122 return ''
123 entry = netrc_obj.authenticators(host)
124 if not entry:
125 return ''
126 return entry[0]
127
128
129def get_git_version():
130 """Returns version of git or None if git is not available."""
[email protected]aa52d312014-08-18 20:28:52131 try:
132 proc = subprocess.Popen(['git', '--version'], stdout=subprocess.PIPE)
133 out, _ = proc.communicate()
134 return out.strip() if proc.returncode == 0 else ''
135 except OSError as exc:
136 if exc.errno != errno.ENOENT:
137 logging.exception('Unexpected error when calling git')
138 return ''
[email protected]94c64122014-08-16 02:03:55139
140
[email protected]46b32a82014-08-19 00:37:57141def read_gclient_solution():
142 """Read information about 'src' gclient solution from .gclient file.
143
144 Returns tuple:
145 (url, deps_file, managed)
146 or
147 (None, None, None) if no such solution.
148 """
149 try:
150 env = {}
151 execfile(GCLIENT_CONFIG, env, env)
152 for sol in env['solutions']:
153 if sol['name'] == 'src':
154 return sol.get('url'), sol.get('deps_file'), sol.get('managed')
155 return None, None, None
156 except Exception:
157 logging.exception('Failed to read .gclient solution')
158 return None, None, None
159
160
[email protected]94c64122014-08-16 02:03:55161def scan_configuration():
162 """Scans local environment for git related configuration values."""
163 # Git checkout?
164 is_git = is_using_git()
165
166 # On Windows HOME should be set.
167 if 'HOME' in os.environ:
168 netrc_path = os.path.join(
169 os.environ['HOME'],
170 '_netrc' if sys.platform.startswith('win') else '.netrc')
171 else:
172 netrc_path = None
173
174 # Netrc exists?
175 is_using_netrc = netrc_path and os.path.exists(netrc_path)
176
177 # Read it.
178 netrc_obj = None
179 if is_using_netrc:
180 try:
181 netrc_obj = netrc.netrc(netrc_path)
182 except Exception:
183 logging.exception('Failed to read netrc from %s', netrc_path)
184 netrc_obj = None
185
[email protected]46b32a82014-08-19 00:37:57186 # Read gclient 'src' solution.
187 gclient_url, gclient_deps, gclient_managed = read_gclient_solution()
188
[email protected]94c64122014-08-16 02:03:55189 return {
190 'checker_version': CHECKER_VERSION,
191 'is_git': is_git,
192 'is_home_set': 'HOME' in os.environ,
193 'is_using_netrc': is_using_netrc,
194 'netrc_file_mode': os.stat(netrc_path).st_mode if is_using_netrc else 0,
195 'git_version': get_git_version(),
196 'platform': sys.platform,
197 'username': getpass.getuser(),
198 'git_user_email': read_git_config('user.email') if is_git else '',
199 'git_user_name': read_git_config('user.name') if is_git else '',
200 'chromium_netrc_email':
201 read_netrc_user(netrc_obj, 'chromium.googlesource.com'),
202 'chrome_internal_netrc_email':
203 read_netrc_user(netrc_obj, 'chrome-internal.googlesource.com'),
[email protected]46b32a82014-08-19 00:37:57204 'gclient_deps': gclient_deps,
205 'gclient_managed': gclient_managed,
206 'gclient_url': gclient_url,
[email protected]94c64122014-08-16 02:03:55207 }
208
209
210def last_configuration_path():
211 """Path to store last checked configuration."""
212 if is_using_git():
[email protected]aa52d312014-08-18 20:28:52213 return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55214 elif is_using_svn():
[email protected]aa52d312014-08-18 20:28:52215 return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55216 else:
[email protected]aa52d312014-08-18 20:28:52217 return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55218
219
220def read_last_configuration():
221 """Reads last checked configuration if it exists."""
222 try:
223 with open(last_configuration_path(), 'r') as f:
224 return json.load(f)
225 except (IOError, ValueError):
226 return None
227
228
229def write_last_configuration(conf):
230 """Writes last checked configuration to a file."""
231 try:
232 with open(last_configuration_path(), 'w') as f:
233 json.dump(conf, f, indent=2, sort_keys=True)
234 except IOError:
235 logging.exception('Failed to write JSON to %s', path)
236
237
238@contextlib.contextmanager
239def temp_directory():
240 """Creates a temp directory, then nukes it."""
241 tmp = tempfile.mkdtemp()
242 try:
243 yield tmp
244 finally:
245 try:
246 shutil.rmtree(tmp)
247 except (OSError, IOError):
248 logging.exception('Failed to remove temp directory %s', tmp)
249
250
251class Runner(object):
252 """Runs a bunch of commands in some directory, collects logs from them."""
253
[email protected]aa52d312014-08-18 20:28:52254 def __init__(self, cwd, verbose):
[email protected]94c64122014-08-16 02:03:55255 self.cwd = cwd
[email protected]aa52d312014-08-18 20:28:52256 self.verbose = verbose
[email protected]94c64122014-08-16 02:03:55257 self.log = []
258
259 def run(self, cmd):
[email protected]aa52d312014-08-18 20:28:52260 self.append_to_log('> ' + ' '.join(cmd))
261 retcode = -1
262 try:
263 proc = subprocess.Popen(
264 cmd,
265 stdout=subprocess.PIPE,
266 stderr=subprocess.STDOUT,
267 cwd=self.cwd)
268 out, _ = proc.communicate()
269 out = out.strip()
270 retcode = proc.returncode
271 except OSError as exc:
272 out = str(exc)
273 if retcode:
274 out += '\n(exit code: %d)' % retcode
275 self.append_to_log(out)
276 return retcode
277
278 def append_to_log(self, text):
279 if text:
280 self.log.append(text)
281 if self.verbose:
282 logging.warning(text)
[email protected]94c64122014-08-16 02:03:55283
284
[email protected]46b32a82014-08-19 00:37:57285def check_git_config(conf, report_url, verbose):
[email protected]94c64122014-08-16 02:03:55286 """Attempts to push to a git repository, reports results to a server.
287
288 Returns True if the check finished without incidents (push itself may
289 have failed) and should NOT be retried on next invocation of the hook.
290 """
[email protected]94c64122014-08-16 02:03:55291 # Don't even try to push if netrc is not configured.
292 if not conf['chromium_netrc_email']:
293 return upload_report(
294 conf,
295 report_url,
[email protected]aa52d312014-08-18 20:28:52296 verbose,
[email protected]94c64122014-08-16 02:03:55297 push_works=False,
298 push_log='',
299 push_duration_ms=0)
300
301 # Ref to push to, each user has its own ref.
302 ref = 'refs/push-test/%s' % conf['chromium_netrc_email']
303
304 push_works = False
305 flake = False
306 started = time.time()
307 try:
[email protected]46b32a82014-08-19 00:37:57308 logging.warning('Checking push access to the git repository...')
[email protected]94c64122014-08-16 02:03:55309 with temp_directory() as tmp:
310 # Prepare a simple commit on a new timeline.
[email protected]aa52d312014-08-18 20:28:52311 runner = Runner(tmp, verbose)
[email protected]94c64122014-08-16 02:03:55312 runner.run(['git', 'init', '.'])
313 if conf['git_user_name']:
314 runner.run(['git', 'config', 'user.name', conf['git_user_name']])
315 if conf['git_user_email']:
316 runner.run(['git', 'config', 'user.email', conf['git_user_email']])
317 with open(os.path.join(tmp, 'timestamp'), 'w') as f:
318 f.write(str(int(time.time() * 1000)))
319 runner.run(['git', 'add', 'timestamp'])
320 runner.run(['git', 'commit', '-m', 'Push test.'])
321 # Try to push multiple times if it fails due to issues other than ACLs.
322 attempt = 0
323 while attempt < 5:
324 attempt += 1
325 logging.info('Pushing to %s %s', TEST_REPO_URL, ref)
326 ret = runner.run(['git', 'push', TEST_REPO_URL, 'HEAD:%s' % ref, '-f'])
327 if not ret:
328 push_works = True
329 break
330 if any(x in runner.log[-1] for x in BAD_ACL_ERRORS):
331 push_works = False
332 break
333 except Exception:
334 logging.exception('Unexpected exception when pushing')
335 flake = True
336
[email protected]46b32a82014-08-19 00:37:57337 if push_works:
338 logging.warning('Git push works!')
339 else:
340 logging.warning(
341 'Git push doesn\'t work, which is fine if you are not a committer.')
342
[email protected]94c64122014-08-16 02:03:55343 uploaded = upload_report(
344 conf,
345 report_url,
[email protected]aa52d312014-08-18 20:28:52346 verbose,
[email protected]94c64122014-08-16 02:03:55347 push_works=push_works,
348 push_log='\n'.join(runner.log),
349 push_duration_ms=int((time.time() - started) * 1000))
350 return uploaded and not flake
351
352
[email protected]46b32a82014-08-19 00:37:57353def check_gclient_config(conf):
354 """Shows warning if gclient solution is not properly configured for git."""
355 current = {
356 'name': 'src',
357 'deps_file': conf['gclient_deps'],
358 'managed': conf['gclient_managed'],
359 'url': conf['gclient_url'],
360 }
361 if current != GOOD_GCLIENT_SOLUTION:
362 print '-' * 80
363 print 'Your gclient solution is not set to use supported git workflow!'
364 print
365 print 'Your \'src\' solution (in %s):' % GCLIENT_CONFIG
366 print pprint.pformat(current, indent=2)
367 print
368 print 'Correct \'src\' solution to use git:'
369 print pprint.pformat(GOOD_GCLIENT_SOLUTION, indent=2)
370 print
371 print 'Please update your .gclient file ASAP.'
372 print '-' * 80
373
374
[email protected]94c64122014-08-16 02:03:55375def upload_report(
[email protected]aa52d312014-08-18 20:28:52376 conf, report_url, verbose, push_works, push_log, push_duration_ms):
[email protected]94c64122014-08-16 02:03:55377 """Posts report to the server, returns True if server accepted it.
378
[email protected]aa52d312014-08-18 20:28:52379 Uploads the report only if script is running in Google corp network. Otherwise
380 just prints the report.
[email protected]94c64122014-08-16 02:03:55381 """
382 report = conf.copy()
383 report.update(
384 push_works=push_works,
385 push_log=push_log,
386 push_duration_ms=push_duration_ms)
387
388 as_bytes = json.dumps({'access_check': report}, indent=2, sort_keys=True)
[email protected]aa52d312014-08-18 20:28:52389 if verbose:
[email protected]94c64122014-08-16 02:03:55390 print 'Status of git push attempt:'
391 print as_bytes
392
[email protected]46b32a82014-08-19 00:37:57393 # Do not upload it outside of corp or if server side is already disabled.
394 if not is_in_google_corp() or datetime.datetime.now() > UPLOAD_DISABLE_TS:
[email protected]aa52d312014-08-18 20:28:52395 if verbose:
[email protected]94c64122014-08-16 02:03:55396 print (
397 'You can send the above report to [email protected] '
398 'if you need help to set up you committer git account.')
399 return True
400
401 req = urllib2.Request(
402 url=report_url,
403 data=as_bytes,
404 headers={'Content-Type': 'application/json; charset=utf-8'})
405
406 attempt = 0
407 success = False
408 while not success and attempt < 10:
409 attempt += 1
410 try:
[email protected]aa52d312014-08-18 20:28:52411 logging.warning(
412 'Attempting to upload the report to %s...',
413 urlparse.urlparse(report_url).netloc)
414 resp = urllib2.urlopen(req, timeout=5)
415 report_id = None
416 try:
417 report_id = json.load(resp)['report_id']
418 except (ValueError, TypeError, KeyError):
419 pass
420 logging.warning('Report uploaded: %s', report_id)
[email protected]94c64122014-08-16 02:03:55421 success = True
[email protected]94c64122014-08-16 02:03:55422 except (urllib2.URLError, socket.error, ssl.SSLError) as exc:
[email protected]aa52d312014-08-18 20:28:52423 logging.warning('Failed to upload the report: %s', exc)
[email protected]94c64122014-08-16 02:03:55424 return success
425
426
427def main(args):
428 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
429 parser.add_option(
430 '--running-as-hook',
431 action='store_true',
432 help='Set when invoked from gclient hook')
433 parser.add_option(
434 '--report-url',
435 default=MOTHERSHIP_URL,
436 help='URL to submit the report to')
437 parser.add_option(
438 '--verbose',
439 action='store_true',
440 help='More logging')
441 options, args = parser.parse_args()
442 if args:
443 parser.error('Unknown argument %s' % args)
444 logging.basicConfig(
445 format='%(message)s',
446 level=logging.INFO if options.verbose else logging.WARN)
447
[email protected]46b32a82014-08-19 00:37:57448 # When invoked not as a hook, always run the check.
[email protected]94c64122014-08-16 02:03:55449 if not options.running_as_hook:
[email protected]46b32a82014-08-19 00:37:57450 config = scan_configuration()
451 check_gclient_config(config)
452 check_git_config(config, options.report_url, True)
[email protected]94c64122014-08-16 02:03:55453 return 0
454
[email protected]46b32a82014-08-19 00:37:57455 # Always do nothing on bots.
456 if is_on_bot():
457 return 0
458
459 # Read current config, verify gclient solution looks correct.
[email protected]94c64122014-08-16 02:03:55460 config = scan_configuration()
[email protected]46b32a82014-08-19 00:37:57461 check_gclient_config(config)
462
463 # Do not attempt to push from non-google owned machines.
464 if not is_in_google_corp():
465 logging.info('Skipping git push check: non *.corp.google.com machine.')
466 return 0
467
468 # Skip git push check if current configuration was already checked.
[email protected]94c64122014-08-16 02:03:55469 if config == read_last_configuration():
470 logging.info('Check already performed, skipping.')
471 return 0
472
473 # Run the check. Mark configuration as checked only on success. Ignore any
474 # exceptions or errors. This check must not break gclient runhooks.
475 try:
[email protected]46b32a82014-08-19 00:37:57476 ok = check_git_config(config, options.report_url, False)
[email protected]94c64122014-08-16 02:03:55477 if ok:
478 write_last_configuration(config)
479 else:
480 logging.warning('Check failed and will be retried on the next run')
481 except Exception:
482 logging.exception('Unexpected exception when performing git access check')
483 return 0
484
485
486if __name__ == '__main__':
487 sys.exit(main(sys.argv[1:]))