gsutil.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. #!/usr/bin/env python3
  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. """Run a pinned gsutil."""
  6. from __future__ import print_function
  7. import argparse
  8. import base64
  9. import contextlib
  10. import hashlib
  11. import json
  12. import os
  13. import shutil
  14. import subprocess
  15. import sys
  16. import tempfile
  17. import time
  18. import urllib.request
  19. import zipfile
  20. GSUTIL_URL = 'https://storage.googleapis.com/pub/'
  21. API_URL = 'https://www.googleapis.com/storage/v1/b/pub/o/'
  22. THIS_DIR = os.path.dirname(os.path.abspath(__file__))
  23. DEFAULT_BIN_DIR = os.path.join(THIS_DIR, 'external_bin', 'gsutil')
  24. IS_WINDOWS = os.name == 'nt'
  25. VERSION = '4.68'
  26. # Google OAuth Context required by gsutil.
  27. LUCI_AUTH_SCOPES = [
  28. 'https://www.googleapis.com/auth/devstorage.full_control',
  29. 'https://www.googleapis.com/auth/userinfo.email',
  30. ]
  31. class InvalidGsutilError(Exception):
  32. pass
  33. def download_gsutil(version, target_dir):
  34. """Downloads gsutil into the target_dir."""
  35. filename = 'gsutil_%s.zip' % version
  36. target_filename = os.path.join(target_dir, filename)
  37. # Check if the target exists already.
  38. if os.path.exists(target_filename):
  39. md5_calc = hashlib.md5()
  40. with open(target_filename, 'rb') as f:
  41. while True:
  42. buf = f.read(4096)
  43. if not buf:
  44. break
  45. md5_calc.update(buf)
  46. local_md5 = md5_calc.hexdigest()
  47. metadata_url = '%s%s' % (API_URL, filename)
  48. metadata = json.load(urllib.request.urlopen(metadata_url))
  49. remote_md5 = base64.b64decode(metadata['md5Hash']).decode('utf-8')
  50. if local_md5 == remote_md5:
  51. return target_filename
  52. os.remove(target_filename)
  53. # Do the download.
  54. url = '%s%s' % (GSUTIL_URL, filename)
  55. u = urllib.request.urlopen(url)
  56. with open(target_filename, 'wb') as f:
  57. while True:
  58. buf = u.read(4096)
  59. if not buf:
  60. break
  61. f.write(buf)
  62. return target_filename
  63. @contextlib.contextmanager
  64. def temporary_directory(base):
  65. tmpdir = tempfile.mkdtemp(prefix='t', dir=base)
  66. try:
  67. yield tmpdir
  68. finally:
  69. if os.path.isdir(tmpdir):
  70. shutil.rmtree(tmpdir)
  71. def ensure_gsutil(version, target, clean):
  72. bin_dir = os.path.join(target, 'gsutil_%s' % version)
  73. gsutil_bin = os.path.join(bin_dir, 'gsutil', 'gsutil')
  74. gsutil_flag = os.path.join(bin_dir, 'gsutil', 'install.flag')
  75. # We assume that if gsutil_flag exists, then we have a good version
  76. # of the gsutil package.
  77. if not clean and os.path.isfile(gsutil_flag):
  78. # Everything is awesome! we're all done here.
  79. return gsutil_bin
  80. if not os.path.exists(target):
  81. try:
  82. os.makedirs(target)
  83. except FileExistsError:
  84. # Another process is prepping workspace, so let's check if
  85. # gsutil_bin is present. If after several checks it's still not,
  86. # continue with downloading gsutil.
  87. delay = 2 # base delay, in seconds
  88. for _ in range(3): # make N attempts
  89. # sleep first as it's not expected to have file ready just yet.
  90. time.sleep(delay)
  91. delay *= 1.5 # next delay increased by that factor
  92. if os.path.isfile(gsutil_bin):
  93. return gsutil_bin
  94. with temporary_directory(target) as instance_dir:
  95. # Clean up if we're redownloading a corrupted gsutil.
  96. cleanup_path = os.path.join(instance_dir, 'clean')
  97. try:
  98. os.rename(bin_dir, cleanup_path)
  99. except (OSError, IOError):
  100. cleanup_path = None
  101. if cleanup_path:
  102. shutil.rmtree(cleanup_path)
  103. download_dir = os.path.join(instance_dir, 'd')
  104. target_zip_filename = download_gsutil(version, instance_dir)
  105. with zipfile.ZipFile(target_zip_filename, 'r') as target_zip:
  106. target_zip.extractall(download_dir)
  107. shutil.move(download_dir, bin_dir)
  108. # Final check that the gsutil bin exists. This should never fail.
  109. if not os.path.isfile(gsutil_bin):
  110. raise InvalidGsutilError()
  111. # Drop a flag file.
  112. with open(gsutil_flag, 'w') as f:
  113. f.write('This flag file is dropped by gsutil.py')
  114. return gsutil_bin
  115. def _is_luci_context():
  116. """Returns True if the script is run within luci-context"""
  117. if os.getenv('SWARMING_HEADLESS') == '1':
  118. return True
  119. luci_context_env = os.getenv('LUCI_CONTEXT')
  120. if not luci_context_env:
  121. return False
  122. try:
  123. with open(luci_context_env) as f:
  124. luci_context_json = json.load(f)
  125. return 'local_auth' in luci_context_json
  126. except (ValueError, FileNotFoundError):
  127. return False
  128. def luci_context(cmd):
  129. """Helper to call`luci-auth context`."""
  130. p = _luci_auth_cmd('context', wrapped_cmds=cmd)
  131. # If luci-auth is not logged in, fallback to normal execution.
  132. if b'Not logged in.' in p.stderr:
  133. return _run_subprocess(cmd, interactive=True)
  134. _print_subprocess_result(p)
  135. return p
  136. def luci_login():
  137. """Helper to run `luci-auth login`."""
  138. # luci-auth requires interactive shell.
  139. return _luci_auth_cmd('login', interactive=True)
  140. def _luci_auth_cmd(luci_cmd, wrapped_cmds=None, interactive=False):
  141. """Helper to call luci-auth command."""
  142. cmd = ['luci-auth', luci_cmd, '-scopes', ' '.join(LUCI_AUTH_SCOPES)]
  143. if wrapped_cmds:
  144. cmd += ['--'] + wrapped_cmds
  145. return _run_subprocess(cmd, interactive)
  146. def _run_subprocess(cmd, interactive=False, env=None):
  147. """Wrapper to run the given command within a subprocess."""
  148. kwargs = {'shell': IS_WINDOWS}
  149. if env:
  150. kwargs['env'] = dict(os.environ, **env)
  151. if not interactive:
  152. kwargs['stdout'] = subprocess.PIPE
  153. kwargs['stderr'] = subprocess.PIPE
  154. return subprocess.run(cmd, **kwargs)
  155. def _print_subprocess_result(p):
  156. """Prints the subprocess result to stdout & stderr."""
  157. if p.stdout:
  158. sys.stdout.buffer.write(p.stdout)
  159. if p.stderr:
  160. sys.stderr.buffer.write(p.stderr)
  161. def is_boto_present():
  162. """Returns true if the .boto file is present in the default path."""
  163. return os.getenv('BOTO_CONFIG') or os.getenv(
  164. 'AWS_CREDENTIAL_FILE') or os.path.isfile(
  165. os.path.join(os.path.expanduser('~'), '.boto'))
  166. def run_gsutil(target, args, clean=False):
  167. # Redirect gsutil config calls to luci-auth.
  168. if 'config' in args:
  169. return luci_login().returncode
  170. gsutil_bin = ensure_gsutil(VERSION, target, clean)
  171. args_opt = ['-o', 'GSUtil:software_update_check_period=0']
  172. if sys.platform == 'darwin':
  173. # We are experiencing problems with multiprocessing on MacOS where
  174. # gsutil.py may hang. This behavior is documented in gsutil codebase,
  175. # and recommendation is to set GSUtil:parallel_process_count=1.
  176. # https://github.com/GoogleCloudPlatform/gsutil/blob/06efc9dc23719fab4fd5fadb506d252bbd3fe0dd/gslib/command.py#L1331
  177. # https://github.com/GoogleCloudPlatform/gsutil/issues/1100
  178. args_opt.extend(['-o', 'GSUtil:parallel_process_count=1'])
  179. if sys.platform == 'cygwin':
  180. # This script requires Windows Python, so invoke with depot_tools'
  181. # Python.
  182. def winpath(path):
  183. stdout = subprocess.check_output(['cygpath', '-w', path])
  184. return stdout.strip().decode('utf-8', 'replace')
  185. cmd = ['python.bat', winpath(__file__)]
  186. cmd.extend(args)
  187. sys.exit(subprocess.call(cmd))
  188. assert sys.platform != 'cygwin'
  189. cmd = [
  190. 'vpython3', '-vpython-spec',
  191. os.path.join(THIS_DIR, 'gsutil.vpython3'), '--', gsutil_bin
  192. ] + args_opt + args
  193. # When .boto is present, try without additional wrappers and handle specific
  194. # errors.
  195. if is_boto_present():
  196. p = _run_subprocess(cmd)
  197. # Notify user that their .boto file might be outdated.
  198. if b'Your credentials are invalid.' in p.stderr:
  199. # Make sure this error message is visible when invoked by gclient
  200. # runhooks
  201. separator = '*' * 80
  202. print(
  203. '\n' + separator + '\n' +
  204. 'Warning: You might have an outdated .boto file. If this issue '
  205. 'persists after running `gsutil.py config`, try removing your '
  206. '.boto, usually located in your home directory.\n' + separator +
  207. '\n',
  208. file=sys.stderr)
  209. _print_subprocess_result(p)
  210. return p.returncode
  211. # Skip wrapping commands if luci-auth is already being
  212. if _is_luci_context():
  213. return _run_subprocess(cmd, interactive=True).returncode
  214. # Wrap gsutil with luci-auth context.
  215. return luci_context(cmd).returncode
  216. def parse_args():
  217. bin_dir = os.environ.get('DEPOT_TOOLS_GSUTIL_BIN_DIR', DEFAULT_BIN_DIR)
  218. # Help is disabled as it conflicts with gsutil -h, which controls headers.
  219. parser = argparse.ArgumentParser(add_help=False)
  220. parser.add_argument(
  221. '--clean',
  222. action='store_true',
  223. help='Clear any existing gsutil package, forcing a new download.')
  224. parser.add_argument(
  225. '--target',
  226. default=bin_dir,
  227. help='The target directory to download/store a gsutil version in. '
  228. '(default is %(default)s).')
  229. # These two args exist for backwards-compatibility but are no-ops.
  230. parser.add_argument('--force-version',
  231. default=VERSION,
  232. help='(deprecated, this flag has no effect)')
  233. parser.add_argument('--fallback',
  234. help='(deprecated, this flag has no effect)')
  235. parser.add_argument('args', nargs=argparse.REMAINDER)
  236. args, extras = parser.parse_known_args()
  237. if args.args and args.args[0] == '--':
  238. args.args.pop(0)
  239. if extras:
  240. args.args = extras + args.args
  241. return args
  242. def main():
  243. args = parse_args()
  244. return run_gsutil(args.target, args.args, clean=args.clean)
  245. if __name__ == '__main__':
  246. sys.exit(main())