gsutil.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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. import argparse
  7. import base64
  8. import contextlib
  9. import hashlib
  10. import json
  11. import os
  12. import shutil
  13. import subprocess
  14. import sys
  15. import tempfile
  16. import urllib.request
  17. import gclient_utils
  18. GSUTIL_URL = 'https://storage.googleapis.com/pub/'
  19. API_URL = 'https://www.googleapis.com/storage/v1/b/pub/o/'
  20. THIS_DIR = os.path.dirname(os.path.abspath(__file__))
  21. DEFAULT_BIN_DIR = os.path.join(THIS_DIR, 'external_bin', 'gsutil')
  22. IS_WINDOWS = os.name == 'nt'
  23. VERSION = '4.68'
  24. # Google OAuth Context required by gsutil.
  25. LUCI_AUTH_SCOPES = [
  26. 'https://www.googleapis.com/auth/devstorage.full_control',
  27. 'https://www.googleapis.com/auth/userinfo.email',
  28. ]
  29. # Prefer LUCI auth mechanism over .boto config.
  30. PREFER_LUCI_AUTH = True
  31. # Platforms unsupported by luci-auth.
  32. LUCI_AUTH_UNSUPPORTED_PLATFORMS = ['aix', 'zos']
  33. class InvalidGsutilError(Exception):
  34. pass
  35. def download_gsutil(version, target_dir):
  36. """Downloads gsutil into the target_dir."""
  37. filename = 'gsutil_%s.zip' % version
  38. target_filename = os.path.join(target_dir, filename)
  39. # Get md5 hash of the remote file from the metadata.
  40. metadata_url = '%s%s' % (API_URL, filename)
  41. metadata = json.load(urllib.request.urlopen(metadata_url))
  42. remote_md5 = base64.b64decode(metadata['md5Hash'])
  43. # Calculate the md5 hash of the local file.
  44. def calc_local_md5():
  45. assert os.path.exists(target_filename)
  46. md5 = hashlib.md5()
  47. with open(target_filename, 'rb') as f:
  48. while chunk := f.read(1024 * 1024):
  49. md5.update(chunk)
  50. return md5.digest()
  51. # Use the existing file if it has the correct md5 hash.
  52. if os.path.exists(target_filename):
  53. if calc_local_md5() == remote_md5:
  54. return target_filename
  55. os.remove(target_filename)
  56. # Download the file.
  57. url = '%s%s' % (GSUTIL_URL, filename)
  58. urllib.request.urlretrieve(url, target_filename)
  59. # Check if the file was downloaded correctly.
  60. if calc_local_md5() != remote_md5:
  61. raise InvalidGsutilError(f'Downloaded gsutil from {url} has wrong md5')
  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. os.makedirs(target, exist_ok=True)
  82. import lockfile
  83. import zipfile
  84. with lockfile.lock(bin_dir, timeout=30):
  85. # Check if gsutil is ready (another process may have had lock).
  86. if not clean and os.path.isfile(gsutil_flag):
  87. return gsutil_bin
  88. with temporary_directory(target) as instance_dir:
  89. download_dir = os.path.join(instance_dir, 'd')
  90. target_zip_filename = gclient_utils.exponential_backoff_retry(
  91. lambda: download_gsutil(version, instance_dir),
  92. name='download_gsutil')
  93. with zipfile.ZipFile(target_zip_filename, 'r') as target_zip:
  94. target_zip.extractall(download_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. shutil.move(download_dir, bin_dir)
  104. # Final check that the gsutil bin exists. This should never fail.
  105. if not os.path.isfile(gsutil_bin):
  106. raise InvalidGsutilError()
  107. # Drop a flag file.
  108. with open(gsutil_flag, 'w') as f:
  109. f.write('This flag file is dropped by gsutil.py')
  110. return gsutil_bin
  111. def _is_luci_context():
  112. """Returns True if the script is run within luci-context"""
  113. if os.getenv('SWARMING_HEADLESS') == '1':
  114. return True
  115. luci_context_env = os.getenv('LUCI_CONTEXT')
  116. if not luci_context_env:
  117. return False
  118. try:
  119. with open(luci_context_env) as f:
  120. luci_context_json = json.load(f)
  121. return 'local_auth' in luci_context_json
  122. except (ValueError, FileNotFoundError):
  123. return False
  124. def _is_luci_auth_supported_platform():
  125. """Returns True if luci-auth is supported in the current platform."""
  126. return not any(map(sys.platform.startswith,
  127. LUCI_AUTH_UNSUPPORTED_PLATFORMS))
  128. def luci_context(cmd, fallback=True):
  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. luci_not_logged_in = b'Not logged in.' in p.stderr
  133. if luci_not_logged_in and fallback:
  134. return _run_subprocess(cmd, interactive=True)
  135. if not luci_not_logged_in:
  136. _print_subprocess_result(p)
  137. return p
  138. def luci_login():
  139. """Helper to run `luci-auth login`."""
  140. # luci-auth requires interactive shell.
  141. return _luci_auth_cmd('login', interactive=True)
  142. def _luci_auth_cmd(luci_cmd, wrapped_cmds=None, interactive=False):
  143. """Helper to call luci-auth command."""
  144. cmd = ['luci-auth', luci_cmd, '-scopes', ' '.join(LUCI_AUTH_SCOPES)]
  145. if wrapped_cmds:
  146. cmd += ['--'] + wrapped_cmds
  147. return _run_subprocess(cmd, interactive)
  148. def _run_subprocess(cmd, interactive=False, env=None):
  149. """Wrapper to run the given command within a subprocess."""
  150. kwargs = {'shell': IS_WINDOWS}
  151. if env:
  152. kwargs['env'] = dict(os.environ, **env)
  153. if not interactive:
  154. kwargs['stdout'] = subprocess.PIPE
  155. kwargs['stderr'] = subprocess.PIPE
  156. return subprocess.run(cmd, **kwargs)
  157. def _print_subprocess_result(p):
  158. """Prints the subprocess result to stdout & stderr."""
  159. if p.stdout:
  160. sys.stdout.buffer.write(p.stdout)
  161. if p.stderr:
  162. sys.stderr.buffer.write(p.stderr)
  163. def get_boto_path():
  164. """Returns the path to a .boto file if it's present in the default path."""
  165. env_var = os.getenv('BOTO_CONFIG') or os.getenv('AWS_CREDENTIAL_FILE')
  166. if env_var:
  167. return env_var
  168. home_boto = os.path.join(os.path.expanduser('~'), '.boto')
  169. if os.path.isfile(home_boto):
  170. return home_boto
  171. return ""
  172. def run_gsutil(target, args, clean=False):
  173. # Redirect gsutil config calls to luci-auth.
  174. if 'config' in args:
  175. return luci_login().returncode
  176. gsutil_bin = ensure_gsutil(VERSION, target, clean)
  177. args_opt = ['-o', 'GSUtil:software_update_check_period=0']
  178. if sys.platform == 'darwin':
  179. # We are experiencing problems with multiprocessing on MacOS where
  180. # gsutil.py may hang. This behavior is documented in gsutil codebase,
  181. # and recommendation is to set GSUtil:parallel_process_count=1.
  182. # https://github.com/GoogleCloudPlatform/gsutil/blob/06efc9dc23719fab4fd5fadb506d252bbd3fe0dd/gslib/command.py#L1331
  183. # https://github.com/GoogleCloudPlatform/gsutil/issues/1100
  184. args_opt.extend(['-o', 'GSUtil:parallel_process_count=1'])
  185. if sys.platform == 'cygwin':
  186. # This script requires Windows Python, so invoke with depot_tools'
  187. # Python.
  188. def winpath(path):
  189. stdout = subprocess.check_output(['cygpath', '-w', path])
  190. return stdout.strip().decode('utf-8', 'replace')
  191. cmd = ['python.bat', winpath(__file__)]
  192. cmd.extend(args)
  193. sys.exit(subprocess.call(cmd))
  194. assert sys.platform != 'cygwin'
  195. cmd = [
  196. 'vpython3', '-vpython-spec',
  197. os.path.join(THIS_DIR, 'gsutil.vpython3'), '--', gsutil_bin
  198. ] + args_opt + args
  199. boto_path = get_boto_path()
  200. # Try luci-auth early even if a boto file exists.
  201. if PREFER_LUCI_AUTH and boto_path:
  202. # Skip wrapping commands if luci-auth is already being used or if the
  203. # platform is unsupported by luci-auth.
  204. if _is_luci_context() or not _is_luci_auth_supported_platform():
  205. return _run_subprocess(cmd, interactive=True).returncode
  206. # Wrap gsutil with luci-auth context. If not logged in and boto is
  207. # present don't fallback to normal execution, fallback to normal
  208. # flow below.
  209. p = luci_context(cmd, fallback=False).returncode
  210. if not p:
  211. return p
  212. # When .boto is present, try without additional wrappers and handle specific
  213. # errors.
  214. if boto_path:
  215. # Display a warning about using .boto files.
  216. if PREFER_LUCI_AUTH:
  217. separator = '*' * 80
  218. print(
  219. '\n' + separator + '\n' +
  220. 'Warning: You are using a .boto file for authentication, '
  221. 'this method is deprecated.\n' + f'({boto_path})\n\n' +
  222. 'Next time please run `gsutil.py config` to use luci-auth.\n\n'
  223. + 'Falling back to .boto authentication method.\n' + separator +
  224. '\n',
  225. file=sys.stderr)
  226. p = _run_subprocess(cmd)
  227. # Notify user that their .boto file might be outdated.
  228. if b'Your credentials are invalid.' in p.stderr:
  229. # Make sure this error message is visible when invoked by gclient
  230. # runhooks
  231. separator = '*' * 80
  232. print(
  233. '\n' + separator + '\n' +
  234. 'Warning: You might have an outdated .boto file. If this issue '
  235. 'persists after running `gsutil.py config`, try removing your '
  236. '.boto, usually located in your home directory.\n' + separator +
  237. '\n',
  238. file=sys.stderr)
  239. _print_subprocess_result(p)
  240. return p.returncode
  241. # Skip wrapping commands if luci-auth is already being used or if the
  242. # platform is unsupported by luci-auth.
  243. if _is_luci_context() or not _is_luci_auth_supported_platform():
  244. return _run_subprocess(cmd, interactive=True).returncode
  245. # Wrap gsutil with luci-auth context.
  246. return luci_context(cmd).returncode
  247. def parse_args():
  248. bin_dir = os.environ.get('DEPOT_TOOLS_GSUTIL_BIN_DIR', DEFAULT_BIN_DIR)
  249. # Help is disabled as it conflicts with gsutil -h, which controls headers.
  250. parser = argparse.ArgumentParser(add_help=False)
  251. parser.add_argument(
  252. '--clean',
  253. action='store_true',
  254. help='Clear any existing gsutil package, forcing a new download.')
  255. parser.add_argument(
  256. '--target',
  257. default=bin_dir,
  258. help='The target directory to download/store a gsutil version in. '
  259. '(default is %(default)s).')
  260. # These two args exist for backwards-compatibility but are no-ops.
  261. parser.add_argument('--force-version',
  262. default=VERSION,
  263. help='(deprecated, this flag has no effect)')
  264. parser.add_argument('--fallback',
  265. help='(deprecated, this flag has no effect)')
  266. parser.add_argument('args', nargs=argparse.REMAINDER)
  267. args, extras = parser.parse_known_args()
  268. if args.args and args.args[0] == '--':
  269. args.args.pop(0)
  270. if extras:
  271. args.args = extras + args.args
  272. return args
  273. def main():
  274. args = parse_args()
  275. return run_gsutil(args.target, args.args, clean=args.clean)
  276. if __name__ == '__main__':
  277. sys.exit(main())