blob: 90bb23d84bdc4ddda1374727730139245324dbc2 [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
7credentials are configured correctly. It also attempts to fix misconfigurations
8if possible.
9
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
15collect information about misconfigured Git accounts (to fix them).
16
17When invoked manually will do the access test and submit the report regardless
18of where it is running.
19"""
20
21import contextlib
[email protected]aa52d312014-08-18 20:28:5222import errno
[email protected]94c64122014-08-16 02:03:5523import getpass
24import json
25import logging
26import netrc
27import optparse
28import os
29import shutil
30import socket
31import ssl
32import subprocess
33import sys
34import tempfile
35import time
36import urllib2
[email protected]aa52d312014-08-18 20:28:5237import urlparse
[email protected]94c64122014-08-16 02:03:5538
39
40# Absolute path to src/ directory.
41REPO_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.
45CHECKER_VERSION = 0
46
47# URL to POST json with results to.
48MOTHERSHIP_URL = (
49 'https://chromium-git-access.appspot.com/'
50 'git_access/api/v1/reports/access_check')
51
52# Repository to push test commits to.
53TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test'
54
55# Possible chunks of git push response in case .netrc is misconfigured.
56BAD_ACL_ERRORS = (
57 '(prohibited by Gerrit)',
[email protected]aa52d312014-08-18 20:28:5258 'does not match your user account',
[email protected]94c64122014-08-16 02:03:5559 'Invalid user name or password',
60)
61
62
63def is_on_bot():
64 """True when running under buildbot."""
65 return os.environ.get('CHROME_HEADLESS') == '1'
66
67
68def 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
77def is_using_git():
78 """True if git checkout is used."""
79 return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects'))
80
81
82def is_using_svn():
83 """True if svn checkout is used."""
84 return os.path.exists(os.path.join(REPO_ROOT, '.svn'))
85
86
87def read_git_config(prop):
[email protected]aa52d312014-08-18 20:28:5288 """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]94c64122014-08-16 02:03:55101
102
103def 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
116def get_git_version():
117 """Returns version of git or None if git is not available."""
[email protected]aa52d312014-08-18 20:28:52118 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]94c64122014-08-16 02:03:55126
127
128def 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
171def last_configuration_path():
172 """Path to store last checked configuration."""
173 if is_using_git():
[email protected]aa52d312014-08-18 20:28:52174 return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55175 elif is_using_svn():
[email protected]aa52d312014-08-18 20:28:52176 return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55177 else:
[email protected]aa52d312014-08-18 20:28:52178 return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json')
[email protected]94c64122014-08-16 02:03:55179
180
181def 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
190def 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
200def 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
212class Runner(object):
213 """Runs a bunch of commands in some directory, collects logs from them."""
214
[email protected]aa52d312014-08-18 20:28:52215 def __init__(self, cwd, verbose):
[email protected]94c64122014-08-16 02:03:55216 self.cwd = cwd
[email protected]aa52d312014-08-18 20:28:52217 self.verbose = verbose
[email protected]94c64122014-08-16 02:03:55218 self.log = []
219
220 def run(self, cmd):
[email protected]aa52d312014-08-18 20:28:52221 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]94c64122014-08-16 02:03:55244
245
[email protected]aa52d312014-08-18 20:28:52246def check_git_access(conf, report_url, verbose):
[email protected]94c64122014-08-16 02:03:55247 """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]aa52d312014-08-18 20:28:52259 verbose,
[email protected]94c64122014-08-16 02:03:55260 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]aa52d312014-08-18 20:28:52273 runner = Runner(tmp, verbose)
[email protected]94c64122014-08-16 02:03:55274 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]aa52d312014-08-18 20:28:52302 verbose,
[email protected]94c64122014-08-16 02:03:55303 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
309def upload_report(
[email protected]aa52d312014-08-18 20:28:52310 conf, report_url, verbose, push_works, push_log, push_duration_ms):
[email protected]94c64122014-08-16 02:03:55311 """Posts report to the server, returns True if server accepted it.
312
[email protected]aa52d312014-08-18 20:28:52313 Uploads the report only if script is running in Google corp network. Otherwise
314 just prints the report.
[email protected]94c64122014-08-16 02:03:55315 """
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]aa52d312014-08-18 20:28:52324 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]94c64122014-08-16 02:03:55331 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]aa52d312014-08-18 20:28:52336 if verbose:
[email protected]94c64122014-08-16 02:03:55337 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]aa52d312014-08-18 20:28:52352 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]94c64122014-08-16 02:03:55362 success = True
[email protected]94c64122014-08-16 02:03:55363 except (urllib2.URLError, socket.error, ssl.SSLError) as exc:
[email protected]aa52d312014-08-18 20:28:52364 logging.warning('Failed to upload the report: %s', exc)
[email protected]94c64122014-08-16 02:03:55365 return success
366
367
368def 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
419if __name__ == '__main__':
420 sys.exit(main(sys.argv[1:]))