[PDNCF] Python 3.12 compatibility
[platform/framework/web/chromium-efl.git] / tools / check_git_config.py
1 #!/usr/bin/env python
2 # Copyright 2014 The Chromium Authors
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 verifies that gclient solution is
8 configured to use git checkout.
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.
16 """
17
18 from __future__ import print_function
19
20 import contextlib
21 import datetime
22 import errno
23 import getpass
24 import json
25 import logging
26 import netrc
27 import optparse
28 import os
29 import pprint
30 import shutil
31 import socket
32 import ssl
33 import subprocess
34 import sys
35 import tempfile
36 import time
37 import urllib2
38 import urlparse
39
40
41 # Absolute path to src/ directory.
42 REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
43
44 # Absolute path to a file with gclient solutions.
45 GCLIENT_CONFIG = os.path.join(os.path.dirname(REPO_ROOT), '.gclient')
46
47 # Incremented whenever some changes to scrip logic are made. Change in version
48 # will cause the check to be rerun on next gclient runhooks invocation.
49 CHECKER_VERSION = 1
50
51 # Do not attempt to upload a report after this date.
52 UPLOAD_DISABLE_TS = datetime.datetime(2014, 10, 1)
53
54 # URL to POST json with results to.
55 MOTHERSHIP_URL = (
56     'https://chromium-git-access.appspot.com/'
57     'git_access/api/v1/reports/access_check')
58
59 # Repository to push test commits to.
60 TEST_REPO_URL = 'https://chromium.googlesource.com/a/playground/access_test'
61
62 # Git-compatible gclient solution.
63 GOOD_GCLIENT_SOLUTION = {
64   'name': 'src',
65   'deps_file': 'DEPS',
66   'managed': False,
67   'url': 'https://chromium.googlesource.com/chromium/src.git',
68 }
69
70 # Possible chunks of git push response in case .netrc is misconfigured.
71 BAD_ACL_ERRORS = (
72   '(prohibited by Gerrit)',
73   'does not match your user account',
74   'Git repository not found',
75   'Invalid user name or password',
76   'Please make sure you have the correct access rights',
77 )
78
79 # Git executable to call.
80 GIT_EXE = 'git.bat' if sys.platform == 'win32' else 'git'
81
82
83 def is_on_bot():
84   """True when running under buildbot."""
85   return os.environ.get('CHROME_HEADLESS') == '1'
86
87
88 def is_in_google_corp():
89   """True when running in google corp network."""
90   try:
91     return socket.getfqdn().endswith('.corp.google.com')
92   except socket.error:
93     logging.exception('Failed to get FQDN')
94     return False
95
96
97 def is_using_git():
98   """True if git checkout is used."""
99   return os.path.exists(os.path.join(REPO_ROOT, '.git', 'objects'))
100
101
102 def is_using_svn():
103   """True if svn checkout is used."""
104   return os.path.exists(os.path.join(REPO_ROOT, '.svn'))
105
106
107 def read_git_config(prop):
108   """Reads git config property of src.git repo.
109
110   Returns empty string in case of errors.
111   """
112   try:
113     proc = subprocess.Popen(
114         [GIT_EXE, 'config', prop], stdout=subprocess.PIPE, cwd=REPO_ROOT)
115     out, _ = proc.communicate()
116     return out.strip().decode('utf-8')
117   except OSError as exc:
118     if exc.errno != errno.ENOENT:
119       logging.exception('Unexpected error when calling git')
120     return ''
121
122
123 def read_netrc_user(netrc_obj, host):
124   """Reads 'user' field of a host entry in netrc.
125
126   Returns empty string if netrc is missing, or host is not there.
127   """
128   if not netrc_obj:
129     return ''
130   entry = netrc_obj.authenticators(host)
131   if not entry:
132     return ''
133   return entry[0]
134
135
136 def get_git_version():
137   """Returns version of git or None if git is not available."""
138   try:
139     proc = subprocess.Popen([GIT_EXE, '--version'], stdout=subprocess.PIPE)
140     out, _ = proc.communicate()
141     return out.strip() if proc.returncode == 0 else ''
142   except OSError as exc:
143     if exc.errno != errno.ENOENT:
144       logging.exception('Unexpected error when calling git')
145     return ''
146
147
148 def read_gclient_solution():
149   """Read information about 'src' gclient solution from .gclient file.
150
151   Returns tuple:
152     (url, deps_file, managed)
153     or
154     (None, None, None) if no such solution.
155   """
156   try:
157     env = {}
158     execfile(GCLIENT_CONFIG, env, env)
159     for sol in (env.get('solutions') or []):
160       if sol.get('name') == 'src':
161         return sol.get('url'), sol.get('deps_file'), sol.get('managed')
162     return None, None, None
163   except Exception:
164     logging.exception('Failed to read .gclient solution')
165     return None, None, None
166
167
168 def read_git_insteadof(host):
169   """Reads relevant insteadOf config entries."""
170   try:
171     proc = subprocess.Popen([GIT_EXE, 'config', '-l'], stdout=subprocess.PIPE)
172     out, _ = proc.communicate()
173     lines = []
174     for line in out.strip().split('\n'):
175       line = line.lower()
176       if 'insteadof=' in line and host in line:
177         lines.append(line)
178     return '\n'.join(lines)
179   except OSError as exc:
180     if exc.errno != errno.ENOENT:
181       logging.exception('Unexpected error when calling git')
182     return ''
183
184
185 def scan_configuration():
186   """Scans local environment for git related configuration values."""
187   # Git checkout?
188   is_git = is_using_git()
189
190   # On Windows HOME should be set.
191   if 'HOME' in os.environ:
192     netrc_path = os.path.join(
193         os.environ['HOME'],
194         '_netrc' if sys.platform.startswith('win') else '.netrc')
195   else:
196     netrc_path = None
197
198   # Netrc exists?
199   is_using_netrc = netrc_path and os.path.exists(netrc_path)
200
201   # Read it.
202   netrc_obj = None
203   if is_using_netrc:
204     try:
205       netrc_obj = netrc.netrc(netrc_path)
206     except Exception:
207       logging.exception('Failed to read netrc from %s', netrc_path)
208       netrc_obj = None
209
210   # Read gclient 'src' solution.
211   gclient_url, gclient_deps, gclient_managed = read_gclient_solution()
212
213   return {
214     'checker_version': CHECKER_VERSION,
215     'is_git': is_git,
216     'is_home_set': 'HOME' in os.environ,
217     'is_using_netrc': is_using_netrc,
218     'netrc_file_mode': os.stat(netrc_path).st_mode if is_using_netrc else 0,
219     'git_version': get_git_version(),
220     'platform': sys.platform,
221     'username': getpass.getuser(),
222     'git_user_email': read_git_config('user.email') if is_git else '',
223     'git_user_name': read_git_config('user.name') if is_git else '',
224     'git_insteadof': read_git_insteadof('chromium.googlesource.com'),
225     'chromium_netrc_email':
226         read_netrc_user(netrc_obj, 'chromium.googlesource.com'),
227     'chrome_internal_netrc_email':
228         read_netrc_user(netrc_obj, 'chrome-internal.googlesource.com'),
229     'gclient_deps': gclient_deps,
230     'gclient_managed': gclient_managed,
231     'gclient_url': gclient_url,
232   }
233
234
235 def last_configuration_path():
236   """Path to store last checked configuration."""
237   if is_using_git():
238     return os.path.join(REPO_ROOT, '.git', 'check_git_push_access_conf.json')
239   elif is_using_svn():
240     return os.path.join(REPO_ROOT, '.svn', 'check_git_push_access_conf.json')
241   else:
242     return os.path.join(REPO_ROOT, '.check_git_push_access_conf.json')
243
244
245 def read_last_configuration():
246   """Reads last checked configuration if it exists."""
247   try:
248     with open(last_configuration_path(), 'r') as f:
249       return json.load(f)
250   except (IOError, ValueError):
251     return None
252
253
254 def write_last_configuration(conf):
255   """Writes last checked configuration to a file."""
256   try:
257     with open(last_configuration_path(), 'w') as f:
258       json.dump(conf, f, indent=2, sort_keys=True)
259   except IOError:
260     logging.exception('Failed to write JSON to %s', path)
261
262
263 @contextlib.contextmanager
264 def temp_directory():
265   """Creates a temp directory, then nukes it."""
266   tmp = tempfile.mkdtemp()
267   try:
268     yield tmp
269   finally:
270     try:
271       shutil.rmtree(tmp)
272     except (OSError, IOError):
273       logging.exception('Failed to remove temp directory %s', tmp)
274
275
276 class Runner(object):
277   """Runs a bunch of commands in some directory, collects logs from them."""
278
279   def __init__(self, cwd, verbose):
280     self.cwd = cwd
281     self.verbose = verbose
282     self.log = []
283
284   def run(self, cmd):
285     self.append_to_log('> ' + ' '.join(cmd))
286     retcode = -1
287     try:
288       proc = subprocess.Popen(
289           cmd,
290           stdout=subprocess.PIPE,
291           stderr=subprocess.STDOUT,
292           cwd=self.cwd)
293       out, _ = proc.communicate()
294       out = out.strip()
295       retcode = proc.returncode
296     except OSError as exc:
297       out = str(exc)
298     if retcode:
299       out += '\n(exit code: %d)' % retcode
300     self.append_to_log(out)
301     return retcode
302
303   def append_to_log(self, text):
304     if text:
305       self.log.append(text)
306       if self.verbose:
307         logging.warning(text)
308
309
310 def check_git_config(conf, report_url, verbose):
311   """Attempts to push to a git repository, reports results to a server.
312
313   Returns True if the check finished without incidents (push itself may
314   have failed) and should NOT be retried on next invocation of the hook.
315   """
316   # Don't even try to push if netrc is not configured.
317   if not conf['chromium_netrc_email']:
318     return upload_report(
319         conf,
320         report_url,
321         verbose,
322         push_works=False,
323         push_log='',
324         push_duration_ms=0)
325
326   # Ref to push to, each user has its own ref.
327   ref = 'refs/push-test/%s' % conf['chromium_netrc_email']
328
329   push_works = False
330   flake = False
331   started = time.time()
332   try:
333     logging.warning('Checking push access to the git repository...')
334     with temp_directory() as tmp:
335       # Prepare a simple commit on a new timeline.
336       runner = Runner(tmp, verbose)
337       runner.run([GIT_EXE, 'init', '.'])
338       if conf['git_user_name']:
339         runner.run([GIT_EXE, 'config', 'user.name', conf['git_user_name']])
340       if conf['git_user_email']:
341         runner.run([GIT_EXE, 'config', 'user.email', conf['git_user_email']])
342       with open(os.path.join(tmp, 'timestamp'), 'w') as f:
343         f.write(str(int(time.time() * 1000)))
344       runner.run([GIT_EXE, 'add', 'timestamp'])
345       runner.run([GIT_EXE, 'commit', '-m', 'Push test.'])
346       # Try to push multiple times if it fails due to issues other than ACLs.
347       attempt = 0
348       while attempt < 5:
349         attempt += 1
350         logging.info('Pushing to %s %s', TEST_REPO_URL, ref)
351         ret = runner.run(
352             [GIT_EXE, 'push', TEST_REPO_URL, 'HEAD:%s' % ref, '-f'])
353         if not ret:
354           push_works = True
355           break
356         if any(x in runner.log[-1] for x in BAD_ACL_ERRORS):
357           push_works = False
358           break
359   except Exception:
360     logging.exception('Unexpected exception when pushing')
361     flake = True
362
363   if push_works:
364     logging.warning('Git push works!')
365   else:
366     logging.warning(
367         'Git push doesn\'t work, which is fine if you are not a committer.')
368
369   uploaded = upload_report(
370       conf,
371       report_url,
372       verbose,
373       push_works=push_works,
374       push_log='\n'.join(runner.log),
375       push_duration_ms=int((time.time() - started) * 1000))
376   return uploaded and not flake
377
378
379 def check_gclient_config(conf):
380   """Shows warning if gclient solution is not properly configured for git."""
381   # Ignore configs that do not have 'src' solution at all.
382   if not conf['gclient_url']:
383     return
384   current = {
385     'name': 'src',
386     'deps_file': conf['gclient_deps'] or 'DEPS',
387     'managed': conf['gclient_managed'] or False,
388     'url': conf['gclient_url'],
389   }
390   # After depot_tools r291592 both DEPS and .DEPS.git are valid.
391   good = GOOD_GCLIENT_SOLUTION.copy()
392   good['deps_file'] = current['deps_file']
393   if current == good:
394     return
395   # Show big warning if url or deps_file is wrong.
396   if current['url'] != good['url'] or current['deps_file'] != good['deps_file']:
397     print('-' * 80)
398     print('Your gclient solution is not set to use supported git workflow!')
399     print()
400     print('Your \'src\' solution (in %s):' % GCLIENT_CONFIG)
401     print(pprint.pformat(current, indent=2))
402     print()
403     print('Correct \'src\' solution to use git:')
404     print(pprint.pformat(good, indent=2))
405     print()
406     print('Please update your .gclient file ASAP.')
407     print('-' * 80)
408   # Show smaller (additional) warning about managed workflow.
409   if current['managed']:
410     print('-' * 80)
411     print('You are using managed gclient mode with git, which was deprecated '
412           'on 8/22/13:')
413     print('https://groups.google.com/a/chromium.org/'
414           'forum/#!topic/chromium-dev/n9N5N3JL2_U')
415     print()
416     print('It is strongly advised to switch to unmanaged mode. For more '
417           'information about managed mode and reasons for its deprecation see:')
418     print(
419         'http://www.chromium.org/developers/how-tos/get-the-code/gclient-managed-mode'
420     )
421     print()
422     print('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)
426
427
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.
431
432   Uploads the report only if script is running in Google corp network. Otherwise
433   just prints the report.
434   """
435   report = conf.copy()
436   report.update(
437       push_works=push_works,
438       push_log=push_log,
439       push_duration_ms=push_duration_ms)
440
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)
445
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
453
454   req = urllib2.Request(
455       url=report_url,
456       data=as_bytes,
457       headers={'Content-Type': 'application/json; charset=utf-8'})
458
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
478
479
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)
500
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
507
508   # Always do nothing on bots.
509   if is_on_bot():
510     return 0
511
512   # Read current config, verify gclient solution looks correct.
513   config = scan_configuration()
514   check_gclient_config(config)
515
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
520
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
525
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
537
538
539 if __name__ == '__main__':
540   sys.exit(main(sys.argv[1:]))