427 lines
16 KiB
Python
427 lines
16 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2015 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.
|
|
|
|
"""Utilities for dealing with version resources."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import re
|
|
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
from googlecloudsdk.api_lib.app import env
|
|
from googlecloudsdk.api_lib.app import metric_names
|
|
from googlecloudsdk.api_lib.app import operations_util
|
|
from googlecloudsdk.core import exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import metrics
|
|
from googlecloudsdk.core.util import retry
|
|
from googlecloudsdk.core.util import text
|
|
from googlecloudsdk.core.util import times
|
|
import six
|
|
from six.moves import map # pylint: disable=redefined-builtin
|
|
|
|
|
|
class VersionValidationError(exceptions.Error):
|
|
pass
|
|
|
|
|
|
class VersionsDeleteError(exceptions.Error):
|
|
pass
|
|
|
|
|
|
class Version(object):
|
|
"""Value class representing a version resource.
|
|
|
|
This wrapper around appengine_<API-version>_messages.Version is necessary
|
|
because Versions don't have traffic split, project, or last_deployed_time as a
|
|
datetime object.
|
|
"""
|
|
|
|
# The smallest allowed traffic split is 1e-3. Because of floating point
|
|
# peculiarities, we use 1e-4 as our max allowed epsilon when testing whether a
|
|
# version is receiving all traffic.
|
|
_ALL_TRAFFIC_EPSILON = 1e-4
|
|
|
|
_RESOURCE_PATH_PARTS = 3 # project/service/version
|
|
|
|
# This is the name in the Version resource from the API
|
|
_VERSION_NAME_PATTERN = ('apps/(?P<project>.*)/'
|
|
'services/(?P<service>.*)/'
|
|
'versions/(?P<version>.*)')
|
|
|
|
def __init__(self,
|
|
project,
|
|
service,
|
|
version_id,
|
|
traffic_split=None,
|
|
last_deployed_time=None,
|
|
environment=None,
|
|
version_resource=None,
|
|
service_account=None):
|
|
self.project = project
|
|
self.service = service
|
|
self.id = version_id
|
|
self.version = version_resource
|
|
self.traffic_split = traffic_split
|
|
self.last_deployed_time = last_deployed_time
|
|
self.environment = environment
|
|
self.service_account = service_account
|
|
|
|
@classmethod
|
|
def FromResourcePath(cls, path):
|
|
parts = path.split('/')
|
|
if not 0 < len(parts) <= cls._RESOURCE_PATH_PARTS:
|
|
raise VersionValidationError('[{0}] is not a valid resource path. '
|
|
'Expected <project>/<service>/<version>')
|
|
|
|
parts = [None] * (cls._RESOURCE_PATH_PARTS - len(parts)) + parts
|
|
return cls(*parts)
|
|
|
|
@classmethod
|
|
def FromVersionResource(cls, version, service):
|
|
"""Convert appengine_<API-version>_messages.Version into wrapped Version."""
|
|
project, service_id, _ = re.match(cls._VERSION_NAME_PATTERN,
|
|
version.name).groups()
|
|
traffic_split = service and service.split.get(version.id, 0.0)
|
|
last_deployed = None
|
|
try:
|
|
if version.createTime:
|
|
last_deployed_dt = times.ParseDateTime(version.createTime).replace(
|
|
microsecond=0)
|
|
last_deployed = times.LocalizeDateTime(last_deployed_dt)
|
|
except ValueError:
|
|
pass
|
|
if version.env == 'flexible':
|
|
environment = env.FLEX
|
|
elif version.vm:
|
|
environment = env.MANAGED_VMS
|
|
else:
|
|
environment = env.STANDARD
|
|
return cls(project, service_id, version.id, traffic_split=traffic_split,
|
|
last_deployed_time=last_deployed, environment=environment,
|
|
version_resource=version)
|
|
|
|
def IsReceivingAllTraffic(self):
|
|
return abs(self.traffic_split - 1.0) < self._ALL_TRAFFIC_EPSILON
|
|
|
|
def GetVersionResource(self, api_client):
|
|
"""Attempts to load the Version resource for this version.
|
|
|
|
Returns the cached Version resource if it exists. Otherwise, attempts to
|
|
load it from the server. Errors are logged and ignored.
|
|
|
|
Args:
|
|
api_client: An AppengineApiClient.
|
|
|
|
Returns:
|
|
The Version resource, or None if it could not be loaded.
|
|
"""
|
|
if not self.version:
|
|
try:
|
|
self.version = api_client.GetVersionResource(self.service, self.id)
|
|
if not self.version:
|
|
log.info('Failed to retrieve resource for version [{0}]'.format(self))
|
|
except apitools_exceptions.Error as e:
|
|
# Log and drop the exception so we don't introduce a new failure mode
|
|
# into the app deployment flow. If we find this isn't happening very
|
|
# often, we could choose to propagate the error.
|
|
log.warning('Error retrieving Version resource [{0}]: {1}'
|
|
.format(six.text_type(self), six.text_type(e)))
|
|
return self.version
|
|
|
|
def __eq__(self, other):
|
|
return (type(other) is Version and
|
|
self.project == other.project and
|
|
self.service == other.service and
|
|
self.id == other.id)
|
|
|
|
def __ne__(self, other):
|
|
return not self == other
|
|
|
|
def __cmp__(self, other):
|
|
return cmp((self.project, self.service, self.id),
|
|
(other.project, other.service, other.id))
|
|
|
|
def __str__(self):
|
|
return '{0}/{1}/{2}'.format(self.project, self.service, self.id)
|
|
|
|
|
|
def _ValidateServicesAreSubset(filtered_versions, all_versions):
|
|
"""Validate that each version in filtered_versions is also in all_versions.
|
|
|
|
Args:
|
|
filtered_versions: list of Version representing a filtered subset of
|
|
all_versions.
|
|
all_versions: list of Version representing all versions in the current
|
|
project.
|
|
|
|
Raises:
|
|
VersionValidationError: If a service or version is not found.
|
|
"""
|
|
for version in filtered_versions:
|
|
if version.service not in [v.service for v in all_versions]:
|
|
raise VersionValidationError(
|
|
'Service [{0}] not found.'.format(version.service))
|
|
if version not in all_versions:
|
|
raise VersionValidationError(
|
|
'Version [{0}/{1}] not found.'.format(version.service,
|
|
version.id))
|
|
|
|
|
|
def ParseVersionResourcePaths(paths, project):
|
|
"""Parse the list of resource paths specifying versions.
|
|
|
|
Args:
|
|
paths: The list of resource paths by which to filter.
|
|
project: The current project. Used for validation.
|
|
|
|
Returns:
|
|
list of Version
|
|
|
|
Raises:
|
|
VersionValidationError: If not all versions are valid resource paths for the
|
|
current project.
|
|
"""
|
|
versions = list(map(Version.FromResourcePath, paths))
|
|
|
|
for version in versions:
|
|
if not (version.project or version.service):
|
|
raise VersionValidationError('If you provide a resource path as an '
|
|
'argument, all arguments must be resource '
|
|
'paths.')
|
|
if version.project and version.project != project:
|
|
raise VersionValidationError(
|
|
'All versions must be in the current project.')
|
|
version.project = project
|
|
return versions
|
|
|
|
|
|
def GetMatchingVersions(all_versions, versions, service):
|
|
"""Return a list of versions to act on based on user arguments.
|
|
|
|
Args:
|
|
all_versions: list of Version representing all services in the project.
|
|
versions: list of string, version names to filter for.
|
|
If empty, match all versions.
|
|
service: string or None, service name. If given, only match versions in the
|
|
given service.
|
|
|
|
Returns:
|
|
list of matching Version
|
|
|
|
Raises:
|
|
VersionValidationError: If an improper combination of arguments is given.
|
|
"""
|
|
filtered_versions = all_versions
|
|
if service:
|
|
if service not in [v.service for v in all_versions]:
|
|
raise VersionValidationError('Service [{0}] not found.'.format(service))
|
|
filtered_versions = [v for v in all_versions if v.service == service]
|
|
|
|
if versions:
|
|
filtered_versions = [v for v in filtered_versions if v.id in versions]
|
|
|
|
return filtered_versions
|
|
|
|
|
|
def DeleteVersions(api_client, versions):
|
|
"""Delete the given version of the given services."""
|
|
errors = {}
|
|
for version in versions:
|
|
version_path = '{0}/{1}'.format(version.service, version.id)
|
|
try:
|
|
operations_util.CallAndCollectOpErrors(
|
|
api_client.DeleteVersion, version.service, version.id)
|
|
except operations_util.MiscOperationError as err:
|
|
errors[version_path] = six.text_type(err)
|
|
|
|
if errors:
|
|
printable_errors = {}
|
|
for version_path, error_msg in errors.items():
|
|
printable_errors[version_path] = '[{0}]: {1}'.format(version_path,
|
|
error_msg)
|
|
raise VersionsDeleteError(
|
|
'Issue deleting {0}: [{1}]\n\n'.format(
|
|
text.Pluralize(len(printable_errors), 'version'),
|
|
', '.join(list(printable_errors.keys()))) +
|
|
'\n\n'.join(list(printable_errors.values())))
|
|
|
|
|
|
def PromoteVersion(all_services, new_version, api_client, stop_previous_version,
|
|
wait_for_stop_version):
|
|
"""Promote the new version to receive all traffic.
|
|
|
|
First starts the new version if it is not running.
|
|
|
|
Additionally, stops the previous version if stop_previous_version is True and
|
|
it is possible to stop the previous version.
|
|
|
|
Args:
|
|
all_services: {str, Service}, A mapping of service id to Service objects
|
|
for all services in the app.
|
|
new_version: Version, The version to promote.
|
|
api_client: appengine_api_client.AppengineApiClient to use to make requests.
|
|
stop_previous_version: bool, True to stop the previous version which was
|
|
receiving all traffic, if any.
|
|
wait_for_stop_version: bool, indicating whether to wait for stop operation
|
|
to finish.
|
|
"""
|
|
old_default_version = None
|
|
if stop_previous_version:
|
|
# Grab the list of versions before we promote, since we need to
|
|
# figure out what the previous default version was
|
|
old_default_version = _GetPreviousVersion(
|
|
all_services, new_version, api_client)
|
|
|
|
# If the new version is stopped, try to start it.
|
|
new_version_resource = new_version.GetVersionResource(api_client)
|
|
status_enum = api_client.messages.Version.ServingStatusValueValuesEnum
|
|
if (new_version_resource and
|
|
new_version_resource.servingStatus == status_enum.STOPPED):
|
|
# start new version
|
|
log.status.Print('Starting version [{0}] before promoting it.'
|
|
.format(new_version))
|
|
api_client.StartVersion(new_version.service, new_version.id, block=True)
|
|
|
|
_SetDefaultVersion(new_version, api_client)
|
|
|
|
if old_default_version:
|
|
_StopPreviousVersionIfApplies(old_default_version, api_client,
|
|
wait_for_stop_version)
|
|
|
|
|
|
def GetUri(version):
|
|
return version.version.versionUrl
|
|
|
|
|
|
def _GetPreviousVersion(all_services, new_version, api_client):
|
|
"""Get the previous default version of which new_version is replacing.
|
|
|
|
If there is no such version, return None.
|
|
|
|
Args:
|
|
all_services: {str, Service}, A mapping of service id to Service objects
|
|
for all services in the app.
|
|
new_version: Version, The version to promote.
|
|
api_client: appengine_api_client.AppengineApiClient, The client for talking
|
|
to the App Engine Admin API.
|
|
|
|
Returns:
|
|
Version, The previous version or None.
|
|
"""
|
|
service = all_services.get(new_version.service, None)
|
|
if not service:
|
|
return None
|
|
for old_version in api_client.ListVersions([service]):
|
|
# Make sure not to stop the just-deployed version!
|
|
# This can happen with a new service, or with a deployment over
|
|
# an existing version.
|
|
if (old_version.IsReceivingAllTraffic() and
|
|
old_version.id != new_version.id):
|
|
return old_version
|
|
|
|
|
|
def _SetDefaultVersion(new_version, api_client):
|
|
"""Sets the given version as the default.
|
|
|
|
Args:
|
|
new_version: Version, The version to promote.
|
|
api_client: appengine_api_client.AppengineApiClient to use to make requests.
|
|
"""
|
|
metrics.CustomTimedEvent(metric_names.SET_DEFAULT_VERSION_API_START)
|
|
# Retry it if we get a service not found error.
|
|
def ShouldRetry(exc_type, unused_exc_value, unused_traceback, unused_state):
|
|
return issubclass(exc_type, apitools_exceptions.HttpError)
|
|
|
|
try:
|
|
retryer = retry.Retryer(max_retrials=3, exponential_sleep_multiplier=2)
|
|
retryer.RetryOnException(
|
|
api_client.SetDefaultVersion, [new_version.service, new_version.id],
|
|
should_retry_if=ShouldRetry, sleep_ms=1000)
|
|
except retry.MaxRetrialsException as e:
|
|
(unused_result, exc_info) = e.last_result
|
|
if exc_info:
|
|
exceptions.reraise(exc_info[1], tb=exc_info[2])
|
|
else:
|
|
# This shouldn't happen, but if we don't have the exception info for some
|
|
# reason, just convert the MaxRetrialsException.
|
|
raise exceptions.InternalError()
|
|
metrics.CustomTimedEvent(metric_names.SET_DEFAULT_VERSION_API)
|
|
|
|
|
|
def _StopPreviousVersionIfApplies(old_default_version, api_client,
|
|
wait_for_stop_version):
|
|
"""Stop the previous default version if applicable.
|
|
|
|
Cases where a version will not be stopped:
|
|
|
|
* If the previous default version is not serving, there is no need to stop it.
|
|
* If the previous default version is an automatically scaled standard
|
|
environment app, it cannot be stopped.
|
|
|
|
Args:
|
|
old_default_version: Version, The old default version to stop.
|
|
api_client: appengine_api_client.AppengineApiClient to use to make requests.
|
|
wait_for_stop_version: bool, indicating whether to wait for stop operation
|
|
to finish.
|
|
"""
|
|
version_object = old_default_version.version
|
|
status_enum = api_client.messages.Version.ServingStatusValueValuesEnum
|
|
if version_object.servingStatus != status_enum.SERVING:
|
|
log.info(
|
|
'Previous default version [{0}] not serving, so not stopping '
|
|
'it.'.format(old_default_version))
|
|
return
|
|
is_standard = not (version_object.vm or version_object.env == 'flex' or
|
|
version_object.env == 'flexible')
|
|
if (is_standard and not version_object.basicScaling and
|
|
not version_object.manualScaling):
|
|
log.info(
|
|
'Previous default version [{0}] is an automatically scaled '
|
|
'standard environment app, so not stopping it.'.format(
|
|
old_default_version))
|
|
return
|
|
|
|
log.status.Print('Stopping version [{0}].'.format(old_default_version))
|
|
try:
|
|
# Block only if wait_for_stop_version is true.
|
|
# Waiting for stop the previous version to finish adds a long time
|
|
# (reports of 2.5 minutes) to deployment. The risk is that if we don't wait,
|
|
# the operation might fail and leave an old version running. But the time
|
|
# savings is substantial.
|
|
operations_util.CallAndCollectOpErrors(
|
|
api_client.StopVersion,
|
|
service_name=old_default_version.service,
|
|
version_id=old_default_version.id,
|
|
block=wait_for_stop_version)
|
|
except operations_util.MiscOperationError as err:
|
|
log.warning('Error stopping version [{0}]: {1}'.format(old_default_version,
|
|
six.text_type(err)))
|
|
log.warning('Version [{0}] is still running and you must stop or delete it '
|
|
'yourself in order to turn it off. (If you do not, you may be '
|
|
'charged.)'.format(old_default_version))
|
|
else:
|
|
if not wait_for_stop_version:
|
|
# TODO(b/318248525): Switch to refer to `gcloud app operations wait` when
|
|
# available
|
|
log.status.Print(
|
|
'Sent request to stop version [{0}]. This operation may take some time '
|
|
'to complete. If you would like to verify that it succeeded, run:\n'
|
|
' $ gcloud app versions describe -s {0.service} {0.id}\n'
|
|
'until it shows that the version has stopped.'.format(
|
|
old_default_version))
|