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