1# -*- coding: utf-8 -*- 2# Copyright 2011 Google Inc. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15"""Implementation of update command for updating gsutil.""" 16 17from __future__ import absolute_import 18 19import os 20import shutil 21import signal 22import stat 23import tarfile 24import tempfile 25import textwrap 26 27import gslib 28from gslib.command import Command 29from gslib.cs_api_map import ApiSelector 30from gslib.exception import CommandException 31from gslib.sig_handling import RegisterSignalHandler 32from gslib.util import CERTIFICATE_VALIDATION_ENABLED 33from gslib.util import CompareVersions 34from gslib.util import GetBotoConfigFileList 35from gslib.util import GSUTIL_PUB_TARBALL 36from gslib.util import IS_CYGWIN 37from gslib.util import IS_WINDOWS 38from gslib.util import LookUpGsutilVersion 39from gslib.util import RELEASE_NOTES_URL 40 41 42_SYNOPSIS = """ 43 gsutil update [-f] [-n] [url] 44""" 45 46_DETAILED_HELP_TEXT = (""" 47<B>SYNOPSIS</B> 48""" + _SYNOPSIS + """ 49 50 51<B>DESCRIPTION</B> 52 The gsutil update command downloads the latest gsutil release, checks its 53 version, and offers to let you update to it if it differs from the version 54 you're currently running. 55 56 Once you say "Y" to the prompt of whether to install the update, the gsutil 57 update command locates where the running copy of gsutil is installed, 58 unpacks the new version into an adjacent directory, moves the previous version 59 aside, moves the new version to where the previous version was installed, 60 and removes the moved-aside old version. Because of this, users are cautioned 61 not to store data in the gsutil directory, since that data will be lost 62 when you update gsutil. (Some users change directories into the gsutil 63 directory to run the command. We advise against doing that, for this reason.) 64 Note also that the gsutil update command will refuse to run if it finds user 65 data in the gsutil directory. 66 67 By default gsutil update will retrieve the new code from 68 %s, but you can optionally specify a URL to use 69 instead. This is primarily used for distributing pre-release versions of 70 the code to a small group of early test users. 71 72 Note: gsutil periodically checks whether a more recent software update is 73 available. By default this check is performed every 30 days; you can change 74 (or disable) this check by editing the software_update_check_period variable 75 in the .boto config file. Note also that gsutil will only check for software 76 updates if stdin, stdout, and stderr are all connected to a TTY, to avoid 77 interfering with cron jobs, streaming transfers, and other cases where gsutil 78 input or output are redirected from/to files or pipes. Software update 79 periodic checks are also disabled by the gsutil -q option (see 80 'gsutil help options') 81 82 83<B>OPTIONS</B> 84 -f Forces the update command to offer to let you update, even if you 85 have the most current copy already. This can be useful if you have 86 a corrupted local copy. 87 88 -n Causes update command to run without prompting [Y/n] whether to 89 continue if an update is available. 90""" % GSUTIL_PUB_TARBALL) 91 92 93class UpdateCommand(Command): 94 """Implementation of gsutil update command.""" 95 96 # Command specification. See base class for documentation. 97 command_spec = Command.CreateCommandSpec( 98 'update', 99 command_name_aliases=['refresh'], 100 usage_synopsis=_SYNOPSIS, 101 min_args=0, 102 max_args=1, 103 supported_sub_args='fn', 104 file_url_ok=True, 105 provider_url_ok=False, 106 urls_start_arg=0, 107 gs_api_support=[ApiSelector.XML, ApiSelector.JSON], 108 gs_default_api=ApiSelector.JSON, 109 ) 110 # Help specification. See help_provider.py for documentation. 111 help_spec = Command.HelpSpec( 112 help_name='update', 113 help_name_aliases=['refresh'], 114 help_type='command_help', 115 help_one_line_summary='Update to the latest gsutil release', 116 help_text=_DETAILED_HELP_TEXT, 117 subcommand_help_text={}, 118 ) 119 120 def _DisallowUpdataIfDataInGsutilDir(self): 121 """Disallows the update command if files not in the gsutil distro are found. 122 123 This prevents users from losing data if they are in the habit of running 124 gsutil from the gsutil directory and leaving data in that directory. 125 126 This will also detect someone attempting to run gsutil update from a git 127 repo, since the top-level directory will contain git files and dirs (like 128 .git) that are not distributed with gsutil. 129 130 Raises: 131 CommandException: if files other than those distributed with gsutil found. 132 """ 133 # Manifest includes recursive-includes of gslib. Directly add 134 # those to the list here so we will skip them in os.listdir() loop without 135 # having to build deeper handling of the MANIFEST file here. Also include 136 # 'third_party', which isn't present in manifest but gets added to the 137 # gsutil distro by the gsutil submodule configuration; and the MANIFEST.in 138 # and CHANGES.md files. 139 manifest_lines = ['gslib', 'third_party', 'MANIFEST.in', 'CHANGES.md'] 140 141 try: 142 with open(os.path.join(gslib.GSUTIL_DIR, 'MANIFEST.in'), 'r') as fp: 143 for line in fp: 144 if line.startswith('include '): 145 manifest_lines.append(line.split()[-1]) 146 except IOError: 147 self.logger.warn('MANIFEST.in not found in %s.\nSkipping user data ' 148 'check.\n', gslib.GSUTIL_DIR) 149 return 150 151 # Look just at top-level directory. We don't try to catch data dropped into 152 # subdirs (like gslib) because that would require deeper parsing of 153 # MANFFEST.in, and most users who drop data into gsutil dir do so at the top 154 # level directory. 155 for filename in os.listdir(gslib.GSUTIL_DIR): 156 if filename.endswith('.pyc'): 157 # Ignore compiled code. 158 continue 159 if filename not in manifest_lines: 160 raise CommandException('\n'.join(textwrap.wrap( 161 'A file (%s) that is not distributed with gsutil was found in ' 162 'the gsutil directory. The update command cannot run with user ' 163 'data in the gsutil directory.' % 164 os.path.join(gslib.GSUTIL_DIR, filename)))) 165 166 def _ExplainIfSudoNeeded(self, tf, dirs_to_remove): 167 """Explains what to do if sudo needed to update gsutil software. 168 169 Happens if gsutil was previously installed by a different user (typically if 170 someone originally installed in a shared file system location, using sudo). 171 172 Args: 173 tf: Opened TarFile. 174 dirs_to_remove: List of directories to remove. 175 176 Raises: 177 CommandException: if errors encountered. 178 """ 179 # If running under Windows or Cygwin we don't need (or have) sudo. 180 if IS_CYGWIN or IS_WINDOWS: 181 return 182 183 user_id = os.getuid() 184 if os.stat(gslib.GSUTIL_DIR).st_uid == user_id: 185 return 186 187 # Won't fail - this command runs after main startup code that insists on 188 # having a config file. 189 config_file_list = GetBotoConfigFileList() 190 config_files = ' '.join(config_file_list) 191 self._CleanUpUpdateCommand(tf, dirs_to_remove) 192 193 # Pick current protection of each boto config file for command that restores 194 # protection (rather than fixing at 600) to support use cases like how GCE 195 # installs a service account with an /etc/boto.cfg file protected to 644. 196 chmod_cmds = [] 197 for config_file in config_file_list: 198 mode = oct(stat.S_IMODE((os.stat(config_file)[stat.ST_MODE]))) 199 chmod_cmds.append('\n\tsudo chmod %s %s' % (mode, config_file)) 200 201 raise CommandException('\n'.join(textwrap.wrap( 202 'Since it was installed by a different user previously, you will need ' 203 'to update using the following commands. You will be prompted for your ' 204 'password, and the install will run as "root". If you\'re unsure what ' 205 'this means please ask your system administrator for help:')) + ( 206 '\n\tsudo chmod 0644 %s\n\tsudo env BOTO_CONFIG="%s" %s update' 207 '%s') % (config_files, config_files, self.gsutil_path, 208 ' '.join(chmod_cmds)), informational=True) 209 210 # This list is checked during gsutil update by doing a lowercased 211 # slash-left-stripped check. For example "/Dev" would match the "dev" entry. 212 unsafe_update_dirs = [ 213 'applications', 'auto', 'bin', 'boot', 'desktop', 'dev', 214 'documents and settings', 'etc', 'export', 'home', 'kernel', 'lib', 215 'lib32', 'library', 'lost+found', 'mach_kernel', 'media', 'mnt', 'net', 216 'null', 'network', 'opt', 'private', 'proc', 'program files', 'python', 217 'root', 'sbin', 'scripts', 'srv', 'sys', 'system', 'tmp', 'users', 'usr', 218 'var', 'volumes', 'win', 'win32', 'windows', 'winnt', 219 ] 220 221 def _EnsureDirsSafeForUpdate(self, dirs): 222 """Raises Exception if any of dirs is known to be unsafe for gsutil update. 223 224 This provides a fail-safe check to ensure we don't try to overwrite 225 or delete any important directories. (That shouldn't happen given the 226 way we construct tmp dirs, etc., but since the gsutil update cleanup 227 uses shutil.rmtree() it's prudent to add extra checks.) 228 229 Args: 230 dirs: List of directories to check. 231 232 Raises: 233 CommandException: If unsafe directory encountered. 234 """ 235 for d in dirs: 236 if not d: 237 d = 'null' 238 if d.lstrip(os.sep).lower() in self.unsafe_update_dirs: 239 raise CommandException('EnsureDirsSafeForUpdate: encountered unsafe ' 240 'directory (%s); aborting update' % d) 241 242 def _CleanUpUpdateCommand(self, tf, dirs_to_remove): 243 """Cleans up temp files etc. from running update command. 244 245 Args: 246 tf: Opened TarFile, or None if none currently open. 247 dirs_to_remove: List of directories to remove. 248 249 """ 250 if tf: 251 tf.close() 252 self._EnsureDirsSafeForUpdate(dirs_to_remove) 253 for directory in dirs_to_remove: 254 try: 255 shutil.rmtree(directory) 256 except OSError: 257 # Ignore errors while attempting to remove old dirs under Windows. They 258 # happen because of Windows exclusive file locking, and the update 259 # actually succeeds but just leaves the old versions around in the 260 # user's temp dir. 261 if not IS_WINDOWS: 262 raise 263 264 def RunCommand(self): 265 """Command entry point for the update command.""" 266 267 if gslib.IS_PACKAGE_INSTALL: 268 raise CommandException( 269 'The update command is only available for gsutil installed from a ' 270 'tarball. If you installed gsutil via another method, use the same ' 271 'method to update it.') 272 273 if os.environ.get('CLOUDSDK_WRAPPER') == '1': 274 raise CommandException( 275 'The update command is disabled for Cloud SDK installs. Please run ' 276 '"gcloud components update" to update it. Note: the Cloud SDK ' 277 'incorporates updates to the underlying tools approximately every 2 ' 278 'weeks, so if you are attempting to update to a recently created ' 279 'release / pre-release of gsutil it may not yet be available via ' 280 'the Cloud SDK.') 281 282 https_validate_certificates = CERTIFICATE_VALIDATION_ENABLED 283 if not https_validate_certificates: 284 raise CommandException( 285 'Your boto configuration has https_validate_certificates = False.\n' 286 'The update command cannot be run this way, for security reasons.') 287 288 self._DisallowUpdataIfDataInGsutilDir() 289 290 force_update = False 291 no_prompt = False 292 if self.sub_opts: 293 for o, unused_a in self.sub_opts: 294 if o == '-f': 295 force_update = True 296 if o == '-n': 297 no_prompt = True 298 299 dirs_to_remove = [] 300 tmp_dir = tempfile.mkdtemp() 301 dirs_to_remove.append(tmp_dir) 302 os.chdir(tmp_dir) 303 304 if not no_prompt: 305 self.logger.info('Checking for software update...') 306 if self.args: 307 update_from_url_str = self.args[0] 308 if not update_from_url_str.endswith('.tar.gz'): 309 raise CommandException( 310 'The update command only works with tar.gz files.') 311 for i, result in enumerate(self.WildcardIterator(update_from_url_str)): 312 if i > 0: 313 raise CommandException( 314 'Invalid update URL. Must name a single .tar.gz file.') 315 storage_url = result.storage_url 316 if storage_url.IsFileUrl() and not storage_url.IsDirectory(): 317 if not force_update: 318 raise CommandException( 319 ('"update" command does not support "file://" URLs without the ' 320 '-f option.')) 321 elif not (storage_url.IsCloudUrl() and storage_url.IsObject()): 322 raise CommandException( 323 'Invalid update object URL. Must name a single .tar.gz file.') 324 else: 325 update_from_url_str = GSUTIL_PUB_TARBALL 326 327 # Try to retrieve version info from tarball metadata; failing that; download 328 # the tarball and extract the VERSION file. The version lookup will fail 329 # when running the update system test, because it retrieves the tarball from 330 # a temp file rather than a cloud URL (files lack the version metadata). 331 tarball_version = LookUpGsutilVersion(self.gsutil_api, update_from_url_str) 332 if tarball_version: 333 tf = None 334 else: 335 tf = self._FetchAndOpenGsutilTarball(update_from_url_str) 336 tf.extractall() 337 with open(os.path.join('gsutil', 'VERSION'), 'r') as ver_file: 338 tarball_version = ver_file.read().strip() 339 340 if not force_update and gslib.VERSION == tarball_version: 341 self._CleanUpUpdateCommand(tf, dirs_to_remove) 342 if self.args: 343 raise CommandException('You already have %s installed.' % 344 update_from_url_str, informational=True) 345 else: 346 raise CommandException('You already have the latest gsutil release ' 347 'installed.', informational=True) 348 349 if not no_prompt: 350 (_, major) = CompareVersions(tarball_version, gslib.VERSION) 351 if major: 352 print('\n'.join(textwrap.wrap( 353 'This command will update to the "%s" version of gsutil at %s. ' 354 'NOTE: This a major new version, so it is strongly recommended ' 355 'that you review the release note details at %s before updating to ' 356 'this version, especially if you use gsutil in scripts.' 357 % (tarball_version, gslib.GSUTIL_DIR, RELEASE_NOTES_URL)))) 358 else: 359 print('This command will update to the "%s" version of\ngsutil at %s' 360 % (tarball_version, gslib.GSUTIL_DIR)) 361 self._ExplainIfSudoNeeded(tf, dirs_to_remove) 362 363 if no_prompt: 364 answer = 'y' 365 else: 366 answer = raw_input('Proceed? [y/N] ') 367 if not answer or answer.lower()[0] != 'y': 368 self._CleanUpUpdateCommand(tf, dirs_to_remove) 369 raise CommandException('Not running update.', informational=True) 370 371 if not tf: 372 tf = self._FetchAndOpenGsutilTarball(update_from_url_str) 373 374 # Ignore keyboard interrupts during the update to reduce the chance someone 375 # hitting ^C leaves gsutil in a broken state. 376 RegisterSignalHandler(signal.SIGINT, signal.SIG_IGN) 377 378 # gslib.GSUTIL_DIR lists the path where the code should end up (like 379 # /usr/local/gsutil), which is one level down from the relative path in the 380 # tarball (since the latter creates files in ./gsutil). So, we need to 381 # extract at the parent directory level. 382 gsutil_bin_parent_dir = os.path.normpath( 383 os.path.join(gslib.GSUTIL_DIR, '..')) 384 385 # Extract tarball to a temporary directory in a sibling to GSUTIL_DIR. 386 old_dir = tempfile.mkdtemp(dir=gsutil_bin_parent_dir) 387 new_dir = tempfile.mkdtemp(dir=gsutil_bin_parent_dir) 388 dirs_to_remove.append(old_dir) 389 dirs_to_remove.append(new_dir) 390 self._EnsureDirsSafeForUpdate(dirs_to_remove) 391 try: 392 tf.extractall(path=new_dir) 393 except Exception, e: 394 self._CleanUpUpdateCommand(tf, dirs_to_remove) 395 raise CommandException('Update failed: %s.' % e) 396 397 # For enterprise mode (shared/central) installation, users with 398 # different user/group than the installation user/group must be 399 # able to run gsutil so we need to do some permissions adjustments 400 # here. Since enterprise mode is not not supported for Windows 401 # users, we can skip this step when running on Windows, which 402 # avoids the problem that Windows has no find or xargs command. 403 if not IS_WINDOWS: 404 # Make all files and dirs in updated area owner-RW and world-R, and make 405 # all directories owner-RWX and world-RX. 406 for dirname, subdirs, filenames in os.walk(new_dir): 407 for filename in filenames: 408 fd = os.open(os.path.join(dirname, filename), os.O_RDONLY) 409 os.fchmod(fd, stat.S_IWRITE | stat.S_IRUSR | 410 stat.S_IRGRP | stat.S_IROTH) 411 os.close(fd) 412 for subdir in subdirs: 413 fd = os.open(os.path.join(dirname, subdir), os.O_RDONLY) 414 os.fchmod(fd, stat.S_IRWXU | stat.S_IXGRP | stat.S_IXOTH | 415 stat.S_IRGRP | stat.S_IROTH) 416 os.close(fd) 417 418 # Make main gsutil script owner-RWX and world-RX. 419 fd = os.open(os.path.join(new_dir, 'gsutil', 'gsutil'), os.O_RDONLY) 420 os.fchmod(fd, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | 421 stat.S_IROTH | stat.S_IXOTH) 422 os.close(fd) 423 424 # Move old installation aside and new into place. 425 os.rename(gslib.GSUTIL_DIR, os.path.join(old_dir, 'old')) 426 os.rename(os.path.join(new_dir, 'gsutil'), gslib.GSUTIL_DIR) 427 self._CleanUpUpdateCommand(tf, dirs_to_remove) 428 RegisterSignalHandler(signal.SIGINT, signal.SIG_DFL) 429 self.logger.info('Update complete.') 430 return 0 431 432 def _FetchAndOpenGsutilTarball(self, update_from_url_str): 433 self.command_runner.RunNamedCommand( 434 'cp', [update_from_url_str, 'file://gsutil.tar.gz'], self.headers, 435 self.debug, skip_update_check=True) 436 # Note: tf is closed in _CleanUpUpdateCommand. 437 tf = tarfile.open('gsutil.tar.gz') 438 tf.errorlevel = 1 # So fatal tarball unpack errors raise exceptions. 439 return tf 440