421 lines
12 KiB
Python
421 lines
12 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2014 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.
|
|
"""Utility functions that don't belong in the other utility modules."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import argparse
|
|
import io
|
|
import re
|
|
|
|
from googlecloudsdk.api_lib.compute import constants
|
|
from googlecloudsdk.api_lib.compute import exceptions
|
|
from googlecloudsdk.calliope import exceptions as calliope_exceptions
|
|
from googlecloudsdk.command_lib.compute import exceptions as compute_exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core.console import console_io
|
|
from googlecloudsdk.core.resource import resource_printer
|
|
import ipaddr
|
|
import six
|
|
|
|
COMPUTE_ALPHA_API_VERSION = 'alpha'
|
|
COMPUTE_BETA_API_VERSION = 'beta'
|
|
COMPUTE_GA_API_VERSION = 'v1'
|
|
|
|
WARN_IF_DISK_SIZE_IS_TOO_SMALL = (
|
|
'You have selected a disk size of under [%sGB]. This may result in '
|
|
'poor I/O performance. For more information, see: '
|
|
'https://developers.google.com/compute/docs/disks#performance.')
|
|
|
|
|
|
class InstanceNotReadyError(exceptions.Error):
|
|
"""The user is attempting to perform an operation on a not-ready instance."""
|
|
|
|
|
|
class InvalidUserError(exceptions.Error):
|
|
"""The user provided an invalid username."""
|
|
|
|
|
|
class MissingDependencyError(exceptions.Error):
|
|
"""An external dependency is missing."""
|
|
|
|
|
|
class TimeoutError(exceptions.Error):
|
|
"""The user command timed out."""
|
|
|
|
|
|
class WrongInstanceTypeError(exceptions.Error):
|
|
"""The instance type is not appropriate for this command."""
|
|
|
|
|
|
class ImageNotFoundError(exceptions.Error):
|
|
"""The image resource could not be found."""
|
|
|
|
|
|
class IncorrectX509FormError(exceptions.Error):
|
|
"""The X509 should be in binary DER form."""
|
|
|
|
|
|
def ZoneNameToRegionName(zone_name):
|
|
"""Converts zone name to region name: 'us-central1-a' -> 'us-central1'."""
|
|
return zone_name.rsplit('-', 1)[0]
|
|
|
|
|
|
def CollectionToResourceType(collection):
|
|
"""Converts a collection to a resource type: 'compute.disks' -> 'disks'."""
|
|
return collection.split('.', 1)[1]
|
|
|
|
|
|
def _GetApiNameFromCollection(collection):
|
|
"""Converts a collection to an api: 'compute.disks' -> 'compute'."""
|
|
return collection.split('.', 1)[0]
|
|
|
|
|
|
def GetApiCollection(resource_type):
|
|
"""Coverts a resource type to a collection."""
|
|
return 'compute.' + resource_type
|
|
|
|
|
|
def NormalizeGoogleStorageUri(uri):
|
|
"""Converts gs:// to http:// if uri begins with gs:// else returns uri."""
|
|
if uri and uri.startswith('gs://'):
|
|
return 'http://storage.googleapis.com/' + uri[len('gs://'):]
|
|
else:
|
|
return uri
|
|
|
|
|
|
def CamelCaseToOutputFriendly(string):
|
|
"""Converts camel case text into output friendly text.
|
|
|
|
Args:
|
|
string: The string to convert.
|
|
|
|
Returns:
|
|
The string converted from CamelCase to output friendly text.
|
|
|
|
Examples:
|
|
'camelCase' -> 'camel case'
|
|
'CamelCase' -> 'camel case'
|
|
'camelTLA' -> 'camel tla'
|
|
"""
|
|
return re.sub('([A-Z]+)', r' \1', string).strip().lower()
|
|
|
|
|
|
def ConstructList(title, items):
|
|
"""Returns a string displaying the items and a title."""
|
|
buf = io.StringIO()
|
|
use_yaml = False
|
|
for item in items:
|
|
if ShouldUseYaml(item):
|
|
use_yaml = True
|
|
break
|
|
if use_yaml:
|
|
fmt = 'yaml'
|
|
resource_printer.Print(items, fmt, out=buf)
|
|
if title:
|
|
return '{}\n{}'.format(title, buf.getvalue())
|
|
else:
|
|
fmt = 'list[title="{title}",always-display-title]'.format(title=title)
|
|
resource_printer.Print(sorted(set(items)), fmt, out=buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def RaiseToolException(problems, error_message=None):
|
|
"""Raises a ToolException with the given list of problems."""
|
|
RaiseException(problems, calliope_exceptions.ToolException, error_message)
|
|
|
|
|
|
def RaiseException(problems, exception, error_message=None):
|
|
"""Raises the provided exception with the given list of problems."""
|
|
errors = []
|
|
for _, error in problems:
|
|
errors.append(error)
|
|
|
|
raise exception(
|
|
ConstructList(error_message or 'Some requests did not succeed:',
|
|
ParseErrors(errors)))
|
|
|
|
|
|
def ParseErrors(errors):
|
|
"""Parses errors to prepare the right error contents."""
|
|
filtered_errors = []
|
|
for error in errors:
|
|
if not hasattr(error, 'message'):
|
|
filtered_errors.append(error)
|
|
elif IsQuotaExceededError(error):
|
|
filtered_errors.append(CreateQuotaExceededMsg(error))
|
|
elif ShouldUseYaml(error):
|
|
filtered_errors.append(error)
|
|
else:
|
|
filtered_errors.append(error.message)
|
|
return filtered_errors
|
|
|
|
|
|
def CreateQuotaExceededMsg(error):
|
|
"""Constructs message to show for quota exceeded error."""
|
|
if (not hasattr(error, 'errorDetails')
|
|
or not error.errorDetails
|
|
or not error.errorDetails[0].quotaInfo):
|
|
return error.message
|
|
details = error.errorDetails[0].quotaInfo
|
|
msg = '{}\n\tmetric name = {}\n\tlimit name = {}\n\tlimit = {}\n'.format(
|
|
error.message, details.metricName, details.limitName, details.limit
|
|
)
|
|
# TODO(b/280371101): remove 'hasattr' condition once published to v1
|
|
if hasattr(details, 'futureLimit') and details.futureLimit:
|
|
msg += '\tfuture limit = {}\n\trollout status = {}\n'.format(
|
|
details.futureLimit, 'in progress'
|
|
)
|
|
if details.dimensions:
|
|
dim = io.StringIO()
|
|
resource_printer.Print(details.dimensions, 'yaml', out=dim)
|
|
msg += '\tdimensions = {}'.format(dim.getvalue())
|
|
if hasattr(details, 'futureLimit') and details.futureLimit:
|
|
msg += (
|
|
'The future limit is the new default quota that will be available after'
|
|
' a service rollout completes. For more about the rollout process, see'
|
|
' the documentation: '
|
|
'https://cloud.google.com/compute/docs/quota-rollout.'
|
|
)
|
|
else:
|
|
msg += (
|
|
'Try your request in another zone, or view documentation on how to'
|
|
' increase quotas: https://cloud.google.com/compute/quotas.'
|
|
)
|
|
return msg
|
|
|
|
|
|
# TODO(b/32637269) delete and clean up uses of scope_name.
|
|
def PromptForDeletion(refs, scope_name=None, prompt_title=None):
|
|
"""Prompts the user to confirm deletion of resources."""
|
|
if not refs:
|
|
return
|
|
resource_type = CollectionToResourceType(refs[0].Collection())
|
|
resource_name = CamelCaseToOutputFriendly(resource_type)
|
|
prompt_list = []
|
|
for ref in refs:
|
|
if scope_name:
|
|
ref_scope_name = scope_name
|
|
elif hasattr(ref, 'region'):
|
|
ref_scope_name = 'region'
|
|
else:
|
|
ref_scope_name = None
|
|
if ref_scope_name:
|
|
item = '[{0}] in [{1}]'.format(ref.Name(), getattr(ref, ref_scope_name))
|
|
else:
|
|
item = '[{0}]'.format(ref.Name())
|
|
prompt_list.append(item)
|
|
|
|
PromptForDeletionHelper(resource_name, prompt_list, prompt_title=prompt_title)
|
|
|
|
|
|
def PromptForDeletionHelper(resource_name, prompt_list, prompt_title=None):
|
|
prompt_title = (prompt_title or
|
|
'The following {0} will be deleted:'.format(resource_name))
|
|
prompt_message = ConstructList(prompt_title, prompt_list)
|
|
if not console_io.PromptContinue(message=prompt_message):
|
|
raise calliope_exceptions.ToolException('Deletion aborted by user.')
|
|
|
|
|
|
def BytesToGb(size):
|
|
"""Converts a disk size in bytes to GB."""
|
|
if not size:
|
|
return None
|
|
|
|
if size % constants.BYTES_IN_ONE_GB != 0:
|
|
raise compute_exceptions.ArgumentError(
|
|
'Disk size must be a multiple of 1 GB. Did you mean [{0}GB]?'
|
|
.format(size // constants.BYTES_IN_ONE_GB + 1))
|
|
|
|
return size // constants.BYTES_IN_ONE_GB
|
|
|
|
|
|
def BytesToMb(size):
|
|
"""Converts a disk size in bytes to MB."""
|
|
if not size:
|
|
return None
|
|
|
|
if size % constants.BYTES_IN_ONE_MB != 0:
|
|
raise compute_exceptions.ArgumentError(
|
|
'Disk size must be a multiple of 1 MB. Did you mean [{0}MB]?'
|
|
.format(size // constants.BYTES_IN_ONE_MB + 1))
|
|
|
|
return size // constants.BYTES_IN_ONE_MB
|
|
|
|
|
|
def WarnIfDiskSizeIsTooSmall(size_gb, disk_type):
|
|
"""Writes a warning message if the given disk size is too small."""
|
|
if not size_gb:
|
|
return
|
|
|
|
if disk_type and (constants.DISK_TYPE_PD_BALANCED in disk_type or
|
|
constants.DISK_TYPE_PD_SSD in disk_type or
|
|
constants.DISK_TYPE_PD_EXTREME in disk_type):
|
|
warning_threshold_gb = constants.SSD_DISK_PERFORMANCE_WARNING_GB
|
|
elif disk_type and (constants.DISK_TYPE_HD_EXTREME in disk_type or
|
|
constants.DISK_TYPE_HD_BALANCED in disk_type or
|
|
constants.DISK_TYPE_HD_THROUGHPUT in disk_type):
|
|
# When disk type is hyperdisk, we don't show the warning.
|
|
warning_threshold_gb = 0
|
|
else:
|
|
warning_threshold_gb = constants.STANDARD_DISK_PERFORMANCE_WARNING_GB
|
|
|
|
if size_gb < warning_threshold_gb:
|
|
log.warning(
|
|
WARN_IF_DISK_SIZE_IS_TOO_SMALL,
|
|
warning_threshold_gb)
|
|
|
|
|
|
def WarnIfPartialRequestFail(problems):
|
|
errors = []
|
|
for _, message in problems:
|
|
errors.append(six.text_type(message))
|
|
|
|
log.warning(ConstructList('Some requests did not succeed.', errors))
|
|
|
|
|
|
def IsValidIPV4(ip):
|
|
"""Accepts an ipv4 address in string form and returns True if valid."""
|
|
match = re.match(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$', ip)
|
|
if not match:
|
|
return False
|
|
|
|
octets = [int(x) for x in match.groups()]
|
|
|
|
# first octet must not be 0
|
|
if octets[0] == 0:
|
|
return False
|
|
|
|
for n in octets:
|
|
if n < 0 or n > 255:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def IPV4Argument(value):
|
|
"""Argparse argument type that checks for a valid ipv4 address."""
|
|
if not IsValidIPV4(value):
|
|
raise argparse.ArgumentTypeError("invalid ipv4 value: '{0}'".format(value))
|
|
|
|
return value
|
|
|
|
|
|
def IsValidIPV4Range(value):
|
|
"""Accepts an ipv4 range in string form and returns True if valid."""
|
|
parts = value.split('/')
|
|
if len(parts) != 2:
|
|
return False
|
|
address, mask = parts[0], parts[1]
|
|
|
|
if not IsValidIPV4(address):
|
|
return False
|
|
|
|
try:
|
|
return 0 < int(mask) <= 32
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def IPV4RangeArgument(value):
|
|
"""Argparse argument type that checks for a valid ipv4 range."""
|
|
if not IsValidIPV4Range(value):
|
|
raise argparse.ArgumentTypeError(
|
|
"invalid ipv4 range value: '{0}'".format(value)
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
def IsValidIPV6(ip):
|
|
"""Accepts an ipv6 address in string form and returns True if valid."""
|
|
try:
|
|
ipaddr.IPv6Address(ip)
|
|
except ipaddr.AddressValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def IPV6Argument(value):
|
|
"""Argparse argument type that checks for a valid ipv6 address."""
|
|
if not IsValidIPV6(value):
|
|
raise argparse.ArgumentTypeError("invalid ipv6 value: '{0}'".format(value))
|
|
|
|
return value
|
|
|
|
|
|
def IPArgument(value):
|
|
"""Argparse argument type that checks for a valid ipv4 or ipv6 address."""
|
|
if not IsValidIPV4(value) and not IsValidIPV6(value):
|
|
raise argparse.ArgumentTypeError("invalid ip value: '{0}'".format(value))
|
|
|
|
return value
|
|
|
|
|
|
def MakeGetUriFunc():
|
|
return lambda x: x['selfLink']
|
|
|
|
|
|
def GetListPager(client, request, get_value_fn):
|
|
"""Returns the paged results for request from client.
|
|
|
|
Args:
|
|
client: The client object.
|
|
request: The request.
|
|
get_value_fn: Called to extract a value from an additionlProperties list
|
|
item.
|
|
|
|
Returns:
|
|
The list of request results.
|
|
"""
|
|
|
|
def _GetNextListPage():
|
|
response = client.AggregatedList(request)
|
|
items = []
|
|
for item in response.items.additionalProperties:
|
|
items += get_value_fn(item)
|
|
return items, response.nextPageToken
|
|
|
|
results, next_page_token = _GetNextListPage()
|
|
while next_page_token:
|
|
request.pageToken = next_page_token
|
|
page, next_page_token = _GetNextListPage()
|
|
results += page
|
|
return results
|
|
|
|
|
|
def ShouldUseYaml(error):
|
|
if hasattr(
|
|
error,
|
|
'code') and (error.code == 'ZONE_RESOURCE_POOL_EXHAUSTED_WITH_DETAILS' or
|
|
error.code == 'ZONE_RESOURCE_POOL_EXHAUSTED' or
|
|
error.code == 'QUOTA_EXCEEDED'):
|
|
return True
|
|
return False
|
|
|
|
|
|
def IsQuotaExceededError(error):
|
|
return hasattr(error, 'code') and error.code == 'QUOTA_EXCEEDED'
|
|
|
|
|
|
def JsonErrorHasDetails(data):
|
|
try:
|
|
error = data.get('error')
|
|
return 'details' in error.keys()
|
|
except (KeyError, AttributeError):
|
|
return False
|