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. | |
Vadim Sh.
2014/08/16 01:41:57
It still is not ran at all outside corp, since att
| |
303 if not is_in_google_corp(): | |
304 if interactive: | |
305 print ( | |
306 'You can send it to chrome-git-migration@google.com if you need help ' | |
Vadim Sh.
2014/08/16 01:41:57
Is this a good email?
iannucci
2014/08/16 01:55:19
s/it/the above report
Vadim Sh.
2014/08/16 02:00:25
Done.
| |
307 'with setting up you committer git account.') | |
iannucci
2014/08/16 01:55:19
to set up your
Vadim Sh.
2014/08/16 02:00:25
Done.
| |
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 |