# -*- 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__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.*)/' 'services/(?P.*)/' 'versions/(?P.*)') 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 //') parts = [None] * (cls._RESOURCE_PATH_PARTS - len(parts)) + parts return cls(*parts) @classmethod def FromVersionResource(cls, version, service): """Convert appengine__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))