# -*- coding: utf-8 -*- # # Copyright 2013 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Exceptions that can be thrown by calliope tools. The exceptions in this file, and those that extend them, can be thrown by the Run() function in calliope tools without worrying about stack traces littering the screen in CLI mode. In interpreter mode, they are not caught from within calliope. """ from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import errno from functools import wraps import os import sys from googlecloudsdk.api_lib.util import exceptions as api_exceptions from googlecloudsdk.core import exceptions as core_exceptions from googlecloudsdk.core import log from googlecloudsdk.core import properties from googlecloudsdk.core.console import console_attr from googlecloudsdk.core.console import console_attr_os from googlecloudsdk.core.credentials import exceptions as creds_exceptions import six def NewErrorFromCurrentException(error, *args): """Creates a new error based on the current exception being handled. If no exception is being handled, a new error with the given args is created. If there is a current exception, the original exception is first logged (to file only). A new error is then created with the same args as the current one. Args: error: The new error to create. *args: The standard args taken by the constructor of Exception for the new exception that is created. If None, the args from the exception currently being handled will be used. Returns: The generated error exception. """ (_, current_exception, _) = sys.exc_info() # Log original exception details and traceback to the log file if we are # currently handling an exception. if current_exception: file_logger = log.file_only_logger file_logger.error( 'Handling the source of a tool exception, original details follow.' ) file_logger.exception(current_exception) if args: return error(*args) elif current_exception: return error(*current_exception.args) return error('An unknown error has occurred') # TODO(b/32328530): Remove ToolException when the last ref is gone class ToolException(core_exceptions.Error): """ToolException is for Run methods to throw for non-code-bug errors. Attributes: command_name: The dotted group and command name for the command that threw this exception. This value is set by calliope. """ @staticmethod def FromCurrent(*args): return NewErrorFromCurrentException(ToolException, *args) class ExitCodeNoError(core_exceptions.Error): """A special exception for exit codes without error messages. If this exception is raised, it's identical in behavior to returning from the command code, except the overall exit code will be different. """ class FailedSubCommand(core_exceptions.Error): """Exception capturing a subcommand which did sys.exit(code).""" def __init__(self, cmd, code): super(FailedSubCommand, self).__init__( 'Failed command: [{0}] with exit code [{1}]'.format( ' '.join(cmd), code ), exit_code=code, ) class DryRunError(core_exceptions.Error): """Raised when a dry run request is made.""" def __init__(self, request): super().__init__() self.request = request def RaiseErrorInsteadOf(error, *error_types): """A decorator that re-raises as an error. If any of the error_types are raised in the decorated function, this decorator will re-raise as an error. Args: error: Exception, The new exception to raise. *error_types: [Exception], A list of exception types that this decorator will watch for. Returns: The decorated function. """ def Wrap(func): """Wrapper function for the decorator.""" @wraps(func) def TryFunc(*args, **kwargs): try: return func(*args, **kwargs) except error_types: core_exceptions.reraise(NewErrorFromCurrentException(error)) return TryFunc return Wrap def _TruncateToLineWidth(string, align, width, fill=''): """Truncate string to line width, right aligning at align. Examples (assuming a screen width of 10): >>> _TruncateToLineWidth('foo', 0) 'foo' >>> # Align to the beginning. Should truncate the end. ... _TruncateToLineWidth('0123456789abcdef', 0) '0123456789' >>> _TruncateToLineWidth('0123456789abcdef', 0, fill='...') '0123456...' >>> # Align to the end. Should truncate the beginning. ... _TruncateToLineWidth('0123456789abcdef', 16) '6789abcdef' >>> _TruncateToLineWidth('0123456789abcdef', 16, fill='...') '...9abcdef' >>> # Align to the middle (note: the index is toward the end of the string, ... # because this function right-aligns to the given index). ... # Should truncate the begnining and end. ... _TruncateToLineWidth('0123456789abcdef', 12) '23456789ab' >>> _TruncateToLineWidth('0123456789abcdef', 12, fill='...') '...5678...' Args: string: string to truncate align: index to right-align to width: maximum length for the resulting string fill: if given, indicate truncation with this string. Must be shorter than terminal width / 2. Returns: str, the truncated string Raises: ValueError, if provided fill is too long for the terminal. """ if len(fill) >= width // 2: # Either the caller provided a fill that's way too long, or the user has a # terminal that's way too narrow. In either case, we aren't going to be able # to make this look nice, but we don't want to throw an error because that # will mask the original error. log.warning('Screen not wide enough to display correct error message.') return string if len(string) <= width: return string if align > width: string = fill + string[align - width + len(fill) :] if len(string) <= width: return string string = string[: width - len(fill)] + fill return string _MARKER = '^ invalid character' def _NonAsciiIndex(s): """Returns the index of the first non-ascii char in s, -1 if all ascii.""" if isinstance(s, six.text_type): for i, c in enumerate(s): try: c.encode('ascii') except (AttributeError, UnicodeError): return i else: for i, b in enumerate(s): try: b.decode('ascii') except (AttributeError, UnicodeError): return i return -1 # pylint: disable=g-doc-bad-indent def _FormatNonAsciiMarkerString(args): r"""Format a string that will mark the first non-ASCII character it contains. Example: >>> args = ['command.py', '--foo=\xce\x94'] >>> _FormatNonAsciiMarkerString(args) == ( ... 'command.py --foo=\u0394\n' ... ' ^ invalid character' ... ) True Args: args: The arg list for the command executed Returns: unicode, a properly formatted string with two lines, the second of which indicates the non-ASCII character in the first. Raises: ValueError: if the given string is all ASCII characters """ # pos is the position of the first non-ASCII character in ' '.join(args) pos = 0 for arg in args: first_non_ascii_index = _NonAsciiIndex(arg) if first_non_ascii_index >= 0: pos += first_non_ascii_index break # this arg was all ASCII; add 1 for the ' ' between args pos += len(arg) + 1 else: raise ValueError( 'The command line is composed entirely of ASCII characters.' ) # Make a string that, when printed in parallel, will point to the non-ASCII # character marker_string = ' ' * pos + _MARKER # Make sure that this will still print out nicely on an odd-sized screen align = len(marker_string) args_string = ' '.join([console_attr.SafeText(arg) for arg in args]) width, _ = console_attr_os.GetTermSize() fill = '...' if width < len(_MARKER) + len(fill): # It's hopeless to try to wrap this and make it look nice. Preserve it in # full for logs and so on. return '\n'.join((args_string, marker_string)) # If len(args_string) < width < len(marker_string) (ex:) # # args_string = 'command BAD' # marker_string = ' ^ invalid character' # width = len('----------------') # # then the truncation can give a result like the following: # # args_string = 'command BAD' # marker_string = ' ^ invalid character' # # (This occurs when args_string is short enough to not be truncated, but # marker_string is long enough to be truncated.) # # ljust args_string to make it as long as marker_string before passing to # _TruncateToLineWidth, which will yield compatible truncations. rstrip at the # end to get rid of the new trailing spaces. formatted_args_string = _TruncateToLineWidth( args_string.ljust(align), align, width, fill=fill ).rstrip() formatted_marker_string = _TruncateToLineWidth(marker_string, align, width) return '\n'.join((formatted_args_string, formatted_marker_string)) class InvalidCharacterInArgException(ToolException): """InvalidCharacterInArgException is for non-ASCII CLI arguments.""" def __init__(self, args, invalid_arg): self.invalid_arg = invalid_arg cmd = os.path.basename(args[0]) if cmd.endswith('.py'): cmd = cmd[:-3] args = [cmd] + args[1:] super(InvalidCharacterInArgException, self).__init__( 'Failed to read command line argument [{0}] because it does ' 'not appear to be valid 7-bit ASCII.\n\n' '{1}'.format( console_attr.SafeText(self.invalid_arg), _FormatNonAsciiMarkerString(args), ) ) class BadArgumentException(ToolException): """For arguments that are wrong for reason hard to summarize.""" def __init__(self, argument_name, message): super(BadArgumentException, self).__init__( 'Invalid value for [{0}]: {1}'.format(argument_name, message) ) self.argument_name = argument_name # TODO(b/35938745): Eventually use api_exceptions.HttpException exclusively. class HttpException(api_exceptions.HttpException): """HttpException is raised whenever the Http response status code != 200. See api_lib.util.exceptions.HttpException for full documentation. """ class InvalidArgumentException(ToolException): """InvalidArgumentException is for malformed arguments.""" def __init__(self, parameter_name, message): super(InvalidArgumentException, self).__init__( 'Invalid value for [{0}]: {1}'.format(parameter_name, message) ) self.parameter_name = parameter_name class ConflictingArgumentsException(ToolException): """ConflictingArgumentsException arguments that are mutually exclusive.""" def __init__(self, *parameter_names): super(ConflictingArgumentsException, self).__init__( 'arguments not allowed simultaneously: ' + ', '.join(parameter_names) ) self.parameter_names = parameter_names class UnknownArgumentException(ToolException): """UnknownArgumentException is for arguments with unexpected values.""" def __init__(self, parameter_name, message): super(UnknownArgumentException, self).__init__( 'Unknown value for [{0}]: {1}'.format(parameter_name, message) ) self.parameter_name = parameter_name class RequiredArgumentException(ToolException): """An exception for when a usually optional argument is required in this case.""" def __init__(self, parameter_name, message): super(RequiredArgumentException, self).__init__( 'Missing required argument [{0}]: {1}'.format(parameter_name, message) ) self.parameter_name = parameter_name class OneOfArgumentsRequiredException(ToolException): """An exception for when one of usually optional arguments is required.""" def __init__(self, parameters, message): super(OneOfArgumentsRequiredException, self).__init__( 'One of arguments [{0}] is required: {1}'.format( ', '.join(parameters), message ) ) self.parameters = parameters class MinimumArgumentException(ToolException): """An exception for when one of several arguments is required.""" def __init__(self, parameter_names, message=None): if message: message = ': {}'.format(message) else: message = '' super(MinimumArgumentException, self).__init__( 'One of [{0}] must be supplied{1}.'.format( ', '.join(['{0}'.format(p) for p in parameter_names]), message ) ) class BadFileException(ToolException): """BadFileException is for problems reading or writing a file.""" # In general, lower level libraries should be catching exceptions and re-raising # exceptions that extend core.Error so nice error messages come out. There are # some error classes that want to be handled as recoverable errors, but cannot # import the core_exceptions module (and therefore the Error class) for various # reasons (e.g. circular dependencies). To work around this, we keep a list of # known "friendly" error types, which we handle in the same way as core.Error. # Additionally, we provide an alternate exception class to convert the errors # to which may add additional information. We use strings here so that we don't # have to import all these libraries all the time, just to be able to handle the # errors when they come up. Only add errors here if there is no other way to # handle them. _KNOWN_ERRORS = { # Raised for "TooManyRequests" or 500s error codes. 'apitools.base.py.exceptions.BadStatusCodeError': ( core_exceptions.NetworkIssueError ), 'apitools.base.py.exceptions.HttpError': HttpException, 'apitools.base.py.exceptions.RequestError': ( core_exceptions.NetworkIssueError ), 'apitools.base.py.exceptions.RetryAfterError': ( core_exceptions.NetworkIssueError ), 'apitools.base.py.exceptions.TransferRetryError': ( core_exceptions.NetworkIssueError ), 'google.auth.exceptions.GoogleAuthError': ( creds_exceptions.TokenRefreshError ), 'googlecloudsdk.calliope.parser_errors.ArgumentError': lambda x: None, 'googlecloudsdk.core.util.files.Error': lambda x: None, 'httplib.ResponseNotReady': core_exceptions.NetworkIssueError, 'httplib.BadStatusLine': core_exceptions.NetworkIssueError, 'httplib.IncompleteRead': core_exceptions.NetworkIssueError, # Same error but different location on PY3. 'http.client.ResponseNotReady': core_exceptions.NetworkIssueError, 'http.client.BadStatusLine': core_exceptions.NetworkIssueError, 'http.client.IncompleteRead': core_exceptions.NetworkIssueError, 'oauth2client.client.AccessTokenRefreshError': ( creds_exceptions.TokenRefreshError ), 'ssl.SSLError': core_exceptions.NetworkIssueError, 'socket.error': core_exceptions.NetworkIssueError, 'socket.timeout': core_exceptions.NetworkIssueError, 'urllib3.exceptions.PoolError': core_exceptions.NetworkIssueError, 'urllib3.exceptions.ProtocolError': core_exceptions.NetworkIssueError, 'urllib3.exceptions.SSLError': core_exceptions.NetworkIssueError, 'urllib3.exceptions.TimeoutError': core_exceptions.NetworkIssueError, 'builtins.ConnectionAbortedError': core_exceptions.NetworkIssueError, 'builtins.ConnectionRefusedError': core_exceptions.NetworkIssueError, 'builtins.ConnectionResetError': core_exceptions.NetworkIssueError, } def _GetExceptionName(cls): """Returns the exception name used as index into _KNOWN_ERRORS from type.""" return cls.__module__ + '.' + cls.__name__ _SOCKET_ERRNO_NAMES = frozenset({ 'EADDRINUSE', 'EADDRNOTAVAIL', 'EAFNOSUPPORT', 'EBADMSG', 'ECOMM', 'ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'EDESTADDRREQ', 'EHOSTDOWN', 'EHOSTUNREACH', 'EISCONN', 'EMSGSIZE', 'EMULTIHOP', 'ENETDOWN', 'ENETRESET', 'ENETUNREACH', 'ENOBUFS', 'ENOPROTOOPT', 'ENOTCONN', 'ENOTSOCK', 'ENOTUNIQ', 'EOPNOTSUPP', 'EPFNOSUPPORT', 'EPROTO', 'EPROTONOSUPPORT', 'EPROTOTYPE', 'EREMCHG', 'EREMOTEIO', 'ESHUTDOWN', 'ESOCKTNOSUPPORT', 'ETIMEDOUT', 'ETOOMANYREFS', }) def _IsSocketError(exc): """Returns True if exc is a socket error exception.""" # I've a feeling we're not in python 2 anymore. PEP 3151 eliminated module # specific exceptions in favor of builtin exceptions like OSError. Good # for some things, bad for others. For instance, this brittle errno check # for "network" errors. We use names because errnos are system dependent. return any( getattr(errno, name, -1) == exc.errno for name in _SOCKET_ERRNO_NAMES ) def ConvertKnownError(exc): """Convert the given exception into an alternate type if it is known. Searches backwards through Exception type hierarchy until it finds a match. Args: exc: Exception, the exception to convert. Returns: (exception, bool), exception is None if this is not a known type, otherwise a new exception that should be logged. The boolean is True if the error should be printed, or False to just exit without printing. """ if isinstance(exc, ExitCodeNoError): return exc, False elif isinstance(exc, core_exceptions.Error): return exc, True known_err = None classes = [type(exc)] processed = set([]) # To avoid circular dependencies while classes: cls = classes.pop(0) processed.add(cls) name = _GetExceptionName(cls) if name == 'builtins.OSError' and _IsSocketError(exc): known_err = core_exceptions.NetworkIssueError else: known_err = _KNOWN_ERRORS.get(name) if known_err: break bases = [ bc for bc in cls.__bases__ if bc not in processed and issubclass(bc, Exception) ] classes.extend([base for base in bases if base is not Exception]) if not known_err: # This is not a known error type return None, True # If there is no known exception just return the original exception. new_exc = known_err(exc) return (new_exc, True) if new_exc else (exc, True) def HandleError(exc, command_path, known_error_handler=None): """Handles an error that occurs during command execution. It calls ConvertKnownError to convert exceptions to known types before processing. If it is a known type, it is printed nicely as as error. If not, it is raised as a crash. Args: exc: Exception, The original exception that occurred. command_path: str, The name of the command that failed (for error reporting). known_error_handler: f(): A function to report the current exception as a known error. """ known_exc, print_error = ConvertKnownError(exc) if known_exc: _LogKnownError(known_exc, command_path, print_error) # Uncaught errors will be handled in gcloud_main. if known_error_handler: known_error_handler() if properties.VALUES.core.print_handled_tracebacks.GetBool(): core_exceptions.reraise(exc) _Exit(known_exc) else: # Make sure any uncaught exceptions still make it into the log file. log.debug(console_attr.SafeText(exc), exc_info=sys.exc_info()) core_exceptions.reraise(exc) class HttpExceptionAdditionalHelp(object): """Additional help text generator when specific HttpException was raised. Attributes: known_exc: googlecloudsdk.api_lib.util.exceptions.HttpException, The exception to handle. error_msg_signature: string, The signature message to determine the nature of the error. additional_help: string, The additional help to print if error_msg_signature appears in the exception error message. """ def __init__(self, known_exc, error_msg_signature, additional_help): self.known_exc = known_exc self.error_msg_signature = error_msg_signature self.additional_help = additional_help def Extend(self, msg): """Appends the additional help to the given msg.""" if self.error_msg_signature in self.known_exc.message: return '{0}\n\n{1}'.format( msg, console_attr.SafeText(self.additional_help) ) else: return msg def _BuildMissingServiceUsePermissionAdditionalHelp(known_exc): """Additional help when missing the 'serviceusage.services.use' permission. Args: known_exc: googlecloudsdk.api_lib.util.exceptions.HttpException, The exception to handle. Returns: A HttpExceptionAdditionalHelp object. """ error_message_signature = ( 'Grant the caller the Owner or Editor role, or a ' 'custom role with the serviceusage.services.use permission' ) help_message = ( 'If you want to invoke the command from a project different ' 'from the target resource project, use `--billing-project` ' 'or `{}` property.'.format(properties.VALUES.billing.quota_project) ) return HttpExceptionAdditionalHelp( known_exc, error_message_signature, help_message ) def _BuildMissingAuthScopesAdditionalHelp(known_exc): """Additional help when missing authentication scopes. When authenticated using user credentials and service account credentials locally, the requested scopes (googlecloudsdk.core.config.CLOUDSDK_SCOPES) should be enough to run gcloud commands. If users run gcloud from a GCE VM, the scopes of the default service account is customizable during vm creation. It is possible that the default service account does not have required scopes. Args: known_exc: googlecloudsdk.api_lib.util.exceptions.HttpException, The exception to handle. Returns: A HttpExceptionAdditionalHelp object. """ error_message_signature = 'Request had insufficient authentication scopes' help_message = ( 'If you are in a compute engine VM, it is likely that the specified' ' scopes during VM creation are not enough to run this command.\nSee' ' https://cloud.google.com/compute/docs/access/service-accounts#accesscopesiam' ' for more information about access scopes.\nSee' ' https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#changeserviceaccountandscopes' ' for how to update access scopes of the VM.' ) return HttpExceptionAdditionalHelp( known_exc, error_message_signature, help_message ) def _LogKnownError(known_exc, command_path, print_error): """Logs the error message of the known exception.""" msg = '({0}) {1}'.format( console_attr.SafeText(command_path), console_attr.SafeText(known_exc) ) if isinstance(known_exc, api_exceptions.HttpException): service_use_help = _BuildMissingServiceUsePermissionAdditionalHelp( known_exc ) auth_scopes_help = _BuildMissingAuthScopesAdditionalHelp(known_exc) msg = service_use_help.Extend(msg) msg = auth_scopes_help.Extend(msg) log.debug(msg, exc_info=sys.exc_info()) if print_error: log.error(msg) def _Exit(exc): """This method exists so we can mock this out during testing to not exit.""" # exit_code won't be defined in the KNOWN_ERRORs classes sys.exit(getattr(exc, 'exit_code', 1))