Home | History | Annotate | Download | only in commands
      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 
     17 from __future__ import absolute_import
     18 
     19 import os
     20 import shutil
     21 import signal
     22 import stat
     23 import tarfile
     24 import tempfile
     25 import textwrap
     26 
     27 import gslib
     28 from gslib.command import Command
     29 from gslib.cs_api_map import ApiSelector
     30 from gslib.exception import CommandException
     31 from gslib.sig_handling import RegisterSignalHandler
     32 from gslib.util import CERTIFICATE_VALIDATION_ENABLED
     33 from gslib.util import CompareVersions
     34 from gslib.util import GetBotoConfigFileList
     35 from gslib.util import GSUTIL_PUB_TARBALL
     36 from gslib.util import IS_CYGWIN
     37 from gslib.util import IS_WINDOWS
     38 from gslib.util import LookUpGsutilVersion
     39 from 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 
     93 class 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