Home | History | Annotate | Download | only in gslib
      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 """Class that runs a named gsutil command."""
     16 
     17 from __future__ import absolute_import
     18 
     19 import difflib
     20 import logging
     21 import os
     22 import pkgutil
     23 import sys
     24 import textwrap
     25 import time
     26 
     27 import boto
     28 from boto.storage_uri import BucketStorageUri
     29 import gslib
     30 from gslib.cloud_api_delegator import CloudApiDelegator
     31 from gslib.command import Command
     32 from gslib.command import CreateGsutilLogger
     33 from gslib.command import GetFailureCount
     34 from gslib.command import OLD_ALIAS_MAP
     35 from gslib.command import ShutDownGsutil
     36 import gslib.commands
     37 from gslib.cs_api_map import ApiSelector
     38 from gslib.cs_api_map import GsutilApiClassMapFactory
     39 from gslib.cs_api_map import GsutilApiMapFactory
     40 from gslib.exception import CommandException
     41 from gslib.gcs_json_api import GcsJsonApi
     42 from gslib.no_op_credentials import NoOpCredentials
     43 from gslib.tab_complete import MakeCompleter
     44 from gslib.util import CheckMultiprocessingAvailableAndInit
     45 from gslib.util import CompareVersions
     46 from gslib.util import GetGsutilVersionModifiedTime
     47 from gslib.util import GSUTIL_PUB_TARBALL
     48 from gslib.util import IsRunningInteractively
     49 from gslib.util import LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE
     50 from gslib.util import LookUpGsutilVersion
     51 from gslib.util import RELEASE_NOTES_URL
     52 from gslib.util import SECONDS_PER_DAY
     53 from gslib.util import UTF8
     54 
     55 
     56 def HandleArgCoding(args):
     57   """Handles coding of command-line args.
     58 
     59   Args:
     60     args: array of command-line args.
     61 
     62   Returns:
     63     array of command-line args.
     64 
     65   Raises:
     66     CommandException: if errors encountered.
     67   """
     68   # Python passes arguments from the command line as byte strings. To
     69   # correctly interpret them, we decode ones other than -h and -p args (which
     70   # will be passed as headers, and thus per HTTP spec should not be encoded) as
     71   # utf-8. The exception is x-goog-meta-* headers, which are allowed to contain
     72   # non-ASCII content (and hence, should be decoded), per
     73   # https://developers.google.com/storage/docs/gsutil/addlhelp/WorkingWithObjectMetadata
     74   processing_header = False
     75   for i in range(len(args)):
     76     arg = args[i]
     77     # Commands like mv can run this function twice; don't decode twice.
     78     try:
     79       decoded = arg if isinstance(arg, unicode) else arg.decode(UTF8)
     80     except UnicodeDecodeError:
     81       raise CommandException('\n'.join(textwrap.wrap(
     82           'Invalid encoding for argument (%s). Arguments must be decodable as '
     83           'Unicode. NOTE: the argument printed above replaces the problematic '
     84           'characters with a hex-encoded printable representation. For more '
     85           'details (including how to convert to a gsutil-compatible encoding) '
     86           'see `gsutil help encoding`.' % repr(arg))))
     87     if processing_header:
     88       if arg.lower().startswith('x-goog-meta'):
     89         args[i] = decoded
     90       else:
     91         try:
     92           # Try to encode as ASCII to check for invalid header values (which
     93           # can't be sent over HTTP).
     94           decoded.encode('ascii')
     95         except UnicodeEncodeError:
     96           # Raise the CommandException using the decoded value because
     97           # _OutputAndExit function re-encodes at the end.
     98           raise CommandException(
     99               'Invalid non-ASCII header value (%s).\nOnly ASCII characters are '
    100               'allowed in headers other than x-goog-meta- headers' % decoded)
    101     else:
    102       args[i] = decoded
    103     processing_header = (arg in ('-h', '-p'))
    104   return args
    105 
    106 
    107 class CommandRunner(object):
    108   """Runs gsutil commands and does some top-level argument handling."""
    109 
    110   def __init__(self, bucket_storage_uri_class=BucketStorageUri,
    111                gsutil_api_class_map_factory=GsutilApiClassMapFactory,
    112                command_map=None):
    113     """Instantiates a CommandRunner.
    114 
    115     Args:
    116       bucket_storage_uri_class: Class to instantiate for cloud StorageUris.
    117                                 Settable for testing/mocking.
    118       gsutil_api_class_map_factory: Creates map of cloud storage interfaces.
    119                                     Settable for testing/mocking.
    120       command_map: Map of command names to their implementations for
    121                    testing/mocking. If not set, the map is built dynamically.
    122     """
    123     self.bucket_storage_uri_class = bucket_storage_uri_class
    124     self.gsutil_api_class_map_factory = gsutil_api_class_map_factory
    125     if command_map:
    126       self.command_map = command_map
    127     else:
    128       self.command_map = self._LoadCommandMap()
    129 
    130   def _LoadCommandMap(self):
    131     """Returns dict mapping each command_name to implementing class."""
    132     # Import all gslib.commands submodules.
    133     for _, module_name, _ in pkgutil.iter_modules(gslib.commands.__path__):
    134       __import__('gslib.commands.%s' % module_name)
    135 
    136     command_map = {}
    137     # Only include Command subclasses in the dict.
    138     for command in Command.__subclasses__():
    139       command_map[command.command_spec.command_name] = command
    140       for command_name_aliases in command.command_spec.command_name_aliases:
    141         command_map[command_name_aliases] = command
    142     return command_map
    143 
    144   def _ConfigureCommandArgumentParserArguments(
    145       self, parser, arguments, gsutil_api):
    146     """Configures an argument parser with the given arguments.
    147 
    148     Args:
    149       parser: argparse parser object.
    150       arguments: array of CommandArgument objects.
    151       gsutil_api: gsutil Cloud API instance to use.
    152     Raises:
    153       RuntimeError: if argument is configured with unsupported completer
    154     """
    155     for command_argument in arguments:
    156       action = parser.add_argument(
    157           *command_argument.args, **command_argument.kwargs)
    158       if command_argument.completer:
    159         action.completer = MakeCompleter(command_argument.completer, gsutil_api)
    160 
    161   def ConfigureCommandArgumentParsers(self, subparsers):
    162     """Configures argparse arguments and argcomplete completers for commands.
    163 
    164     Args:
    165       subparsers: argparse object that can be used to add parsers for
    166                   subcommands (called just 'commands' in gsutil)
    167     """
    168 
    169     # This should match the support map for the "ls" command.
    170     support_map = {
    171         'gs': [ApiSelector.XML, ApiSelector.JSON],
    172         's3': [ApiSelector.XML]
    173     }
    174     default_map = {
    175         'gs': ApiSelector.JSON,
    176         's3': ApiSelector.XML
    177     }
    178     gsutil_api_map = GsutilApiMapFactory.GetApiMap(
    179         self.gsutil_api_class_map_factory, support_map, default_map)
    180 
    181     logger = CreateGsutilLogger('tab_complete')
    182     gsutil_api = CloudApiDelegator(
    183         self.bucket_storage_uri_class, gsutil_api_map,
    184         logger, debug=0)
    185 
    186     for command in set(self.command_map.values()):
    187       command_parser = subparsers.add_parser(
    188           command.command_spec.command_name, add_help=False)
    189       if isinstance(command.command_spec.argparse_arguments, dict):
    190         subcommand_parsers = command_parser.add_subparsers()
    191         subcommand_argument_dict = command.command_spec.argparse_arguments
    192         for subcommand, arguments in subcommand_argument_dict.iteritems():
    193           subcommand_parser = subcommand_parsers.add_parser(
    194               subcommand, add_help=False)
    195           self._ConfigureCommandArgumentParserArguments(
    196               subcommand_parser, arguments, gsutil_api)
    197       else:
    198         self._ConfigureCommandArgumentParserArguments(
    199             command_parser, command.command_spec.argparse_arguments, gsutil_api)
    200 
    201   def RunNamedCommand(self, command_name, args=None, headers=None, debug=0,
    202                       trace_token=None, parallel_operations=False,
    203                       skip_update_check=False, logging_filters=None,
    204                       do_shutdown=True):
    205     """Runs the named command.
    206 
    207     Used by gsutil main, commands built atop other commands, and tests.
    208 
    209     Args:
    210       command_name: The name of the command being run.
    211       args: Command-line args (arg0 = actual arg, not command name ala bash).
    212       headers: Dictionary containing optional HTTP headers to pass to boto.
    213       debug: Debug level to pass in to boto connection (range 0..3).
    214       trace_token: Trace token to pass to the underlying API.
    215       parallel_operations: Should command operations be executed in parallel?
    216       skip_update_check: Set to True to disable checking for gsutil updates.
    217       logging_filters: Optional list of logging.Filters to apply to this
    218                        command's logger.
    219       do_shutdown: Stop all parallelism framework workers iff this is True.
    220 
    221     Raises:
    222       CommandException: if errors encountered.
    223 
    224     Returns:
    225       Return value(s) from Command that was run.
    226     """
    227     command_changed_to_update = False
    228     if (not skip_update_check and
    229         self.MaybeCheckForAndOfferSoftwareUpdate(command_name, debug)):
    230       command_name = 'update'
    231       command_changed_to_update = True
    232       args = ['-n']
    233 
    234     if not args:
    235       args = []
    236 
    237     # Include api_version header in all commands.
    238     api_version = boto.config.get_value('GSUtil', 'default_api_version', '1')
    239     if not headers:
    240       headers = {}
    241     headers['x-goog-api-version'] = api_version
    242 
    243     if command_name not in self.command_map:
    244       close_matches = difflib.get_close_matches(
    245           command_name, self.command_map.keys(), n=1)
    246       if close_matches:
    247         # Instead of suggesting a deprecated command alias, suggest the new
    248         # name for that command.
    249         translated_command_name = (
    250             OLD_ALIAS_MAP.get(close_matches[0], close_matches)[0])
    251         print >> sys.stderr, 'Did you mean this?'
    252         print >> sys.stderr, '\t%s' % translated_command_name
    253       elif command_name == 'update' and gslib.IS_PACKAGE_INSTALL:
    254         sys.stderr.write(
    255             'Update command is not supported for package installs; '
    256             'please instead update using your package manager.')
    257 
    258       raise CommandException('Invalid command "%s".' % command_name)
    259     if '--help' in args:
    260       new_args = [command_name]
    261       original_command_class = self.command_map[command_name]
    262       subcommands = original_command_class.help_spec.subcommand_help_text.keys()
    263       for arg in args:
    264         if arg in subcommands:
    265           new_args.append(arg)
    266           break  # Take the first match and throw away the rest.
    267       args = new_args
    268       command_name = 'help'
    269 
    270     args = HandleArgCoding(args)
    271 
    272     command_class = self.command_map[command_name]
    273     command_inst = command_class(
    274         self, args, headers, debug, trace_token, parallel_operations,
    275         self.bucket_storage_uri_class, self.gsutil_api_class_map_factory,
    276         logging_filters, command_alias_used=command_name)
    277     return_code = command_inst.RunCommand()
    278 
    279     if CheckMultiprocessingAvailableAndInit().is_available and do_shutdown:
    280       ShutDownGsutil()
    281     if GetFailureCount() > 0:
    282       return_code = 1
    283     if command_changed_to_update:
    284       # If the command changed to update, the user's original command was
    285       # not executed.
    286       return_code = 1
    287       print '\n'.join(textwrap.wrap(
    288           'Update was successful. Exiting with code 1 as the original command '
    289           'issued prior to the update was not executed and should be re-run.'))
    290     return return_code
    291 
    292   def MaybeCheckForAndOfferSoftwareUpdate(self, command_name, debug):
    293     """Checks the last time we checked for an update and offers one if needed.
    294 
    295     Offer is made if the time since the last update check is longer
    296     than the configured threshold offers the user to update gsutil.
    297 
    298     Args:
    299       command_name: The name of the command being run.
    300       debug: Debug level to pass in to boto connection (range 0..3).
    301 
    302     Returns:
    303       True if the user decides to update.
    304     """
    305     # Don't try to interact with user if:
    306     # - gsutil is not connected to a tty (e.g., if being run from cron);
    307     # - user is running gsutil -q
    308     # - user is running the config command (which could otherwise attempt to
    309     #   check for an update for a user running behind a proxy, who has not yet
    310     #   configured gsutil to go through the proxy; for such users we need the
    311     #   first connection attempt to be made by the gsutil config command).
    312     # - user is running the version command (which gets run when using
    313     #   gsutil -D, which would prevent users with proxy config problems from
    314     #   sending us gsutil -D output).
    315     # - user is running the update command (which could otherwise cause an
    316     #   additional note that an update is available when user is already trying
    317     #   to perform an update);
    318     # - user specified gs_host (which could be a non-production different
    319     #   service instance, in which case credentials won't work for checking
    320     #   gsutil tarball).
    321     # - user is using a Cloud SDK install (which should only be updated via
    322     #   gcloud components update)
    323     logger = logging.getLogger()
    324     gs_host = boto.config.get('Credentials', 'gs_host', None)
    325     if (not IsRunningInteractively()
    326         or command_name in ('config', 'update', 'ver', 'version')
    327         or not logger.isEnabledFor(logging.INFO)
    328         or gs_host
    329         or os.environ.get('CLOUDSDK_WRAPPER') == '1'):
    330       return False
    331 
    332     software_update_check_period = boto.config.getint(
    333         'GSUtil', 'software_update_check_period', 30)
    334     # Setting software_update_check_period to 0 means periodic software
    335     # update checking is disabled.
    336     if software_update_check_period == 0:
    337       return False
    338 
    339     cur_ts = int(time.time())
    340     if not os.path.isfile(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE):
    341       # Set last_checked_ts from date of VERSION file, so if the user installed
    342       # an old copy of gsutil it will get noticed (and an update offered) the
    343       # first time they try to run it.
    344       last_checked_ts = GetGsutilVersionModifiedTime()
    345       with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'w') as f:
    346         f.write(str(last_checked_ts))
    347     else:
    348       try:
    349         with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'r') as f:
    350           last_checked_ts = int(f.readline())
    351       except (TypeError, ValueError):
    352         return False
    353 
    354     if (cur_ts - last_checked_ts
    355         > software_update_check_period * SECONDS_PER_DAY):
    356       # Create a credential-less gsutil API to check for the public
    357       # update tarball.
    358       gsutil_api = GcsJsonApi(self.bucket_storage_uri_class, logger,
    359                               credentials=NoOpCredentials(), debug=debug)
    360 
    361       cur_ver = LookUpGsutilVersion(gsutil_api, GSUTIL_PUB_TARBALL)
    362       with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'w') as f:
    363         f.write(str(cur_ts))
    364       (g, m) = CompareVersions(cur_ver, gslib.VERSION)
    365       if m:
    366         print '\n'.join(textwrap.wrap(
    367             'A newer version of gsutil (%s) is available than the version you '
    368             'are running (%s). NOTE: This is a major new version, so it is '
    369             'strongly recommended that you review the release note details at '
    370             '%s before updating to this version, especially if you use gsutil '
    371             'in scripts.' % (cur_ver, gslib.VERSION, RELEASE_NOTES_URL)))
    372         if gslib.IS_PACKAGE_INSTALL:
    373           return False
    374         print
    375         answer = raw_input('Would you like to update [y/N]? ')
    376         return answer and answer.lower()[0] == 'y'
    377       elif g:
    378         print '\n'.join(textwrap.wrap(
    379             'A newer version of gsutil (%s) is available than the version you '
    380             'are running (%s). A detailed log of gsutil release changes is '
    381             'available at %s if you would like to read them before updating.'
    382             % (cur_ver, gslib.VERSION, RELEASE_NOTES_URL)))
    383         if gslib.IS_PACKAGE_INSTALL:
    384           return False
    385         print
    386         answer = raw_input('Would you like to update [Y/n]? ')
    387         return not answer or answer.lower()[0] != 'n'
    388     return False
    389