382 lines
15 KiB
Python
382 lines
15 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2019 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.
|
|
"""Wrapper for Cloud Run TrafficTargets messages."""
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import operator
|
|
|
|
from googlecloudsdk.api_lib.run import traffic
|
|
|
|
import six
|
|
|
|
|
|
# Human readable indicator for a missing traffic percentage or missing tags.
|
|
_MISSING_PERCENT_OR_TAGS = '-'
|
|
|
|
# String to join TrafficTarget tags referencing the same revision.
|
|
_TAGS_JOIN_STRING = ', '
|
|
|
|
|
|
def _FormatPercentage(percent):
|
|
if percent == _MISSING_PERCENT_OR_TAGS:
|
|
return _MISSING_PERCENT_OR_TAGS
|
|
else:
|
|
return '{}%'.format(percent)
|
|
|
|
|
|
def _SumPercent(targets):
|
|
"""Sums the percents of the given targets."""
|
|
return sum(t.percent for t in targets if t.percent)
|
|
|
|
|
|
class TrafficTag(object):
|
|
"""Contains the spec and status state for a traffic tag.
|
|
|
|
Attributes:
|
|
tag: The name of the tag.
|
|
url: The tag's URL, or an empty string if the tag does not have a URL
|
|
assigned yet. Defaults to an empty string.
|
|
inSpec: Boolean that is true if the tag is present in the spec. Defaults to
|
|
False.
|
|
inStatus: Boolean that is true if the tag is present in the status. Defaults
|
|
to False.
|
|
"""
|
|
|
|
def __init__(self, tag, url='', in_spec=False, in_status=False):
|
|
"""Returns a new TrafficTag.
|
|
|
|
Args:
|
|
tag: The name of the tag.
|
|
url: The tag's URL.
|
|
in_spec: Boolean that is true if the tag is present in the spec.
|
|
in_status: Boolean that is true if the tag is present in the status.
|
|
"""
|
|
self.tag = tag
|
|
self.url = url
|
|
self.inSpec = in_spec # pylint: disable=invalid-name
|
|
self.inStatus = in_status # pylint: disable=invalid-name
|
|
|
|
|
|
class TrafficTargetPair(object):
|
|
"""Holder for TrafficTarget status information.
|
|
|
|
The representation of the status of traffic for a service
|
|
includes:
|
|
o User requested assignments (spec.traffic)
|
|
o Actual assignments (status.traffic)
|
|
|
|
Each of spec.traffic and status.traffic may contain multiple traffic targets
|
|
that reference the same revision, either directly by name or indirectly by
|
|
referencing the latest ready revision.
|
|
|
|
The spec and status traffic targets for a revision may differ after a failed
|
|
traffic update or during a successful one. A TrafficTargetPair holds all
|
|
spec and status TrafficTargets that reference the same revision by name or
|
|
reference the latest ready revision. Both the spec and status traffic targets
|
|
can be empty.
|
|
|
|
The latest revision can be included in the spec traffic targets
|
|
two ways
|
|
o by revisionName
|
|
o by setting latestRevision to True.
|
|
|
|
Managed cloud run provides a single combined status traffic target
|
|
for both spec entries with:
|
|
o revisionName set to the latest revision's name
|
|
o percent set to combined percentage for both spec entries
|
|
o latestRevision not set
|
|
|
|
In this case both spec targets are paired with the combined status
|
|
target and a status_percent_override value is used to allocate the
|
|
combined traffic.
|
|
|
|
Attributes:
|
|
key: Either the referenced revision name or 'LATEST' if the traffic targets
|
|
reference the latest ready revision.
|
|
latestRevision: Boolean indicating if the traffic targets reference the
|
|
latest ready revision.
|
|
revisionName: The name of the revision referenced by these traffic targets.
|
|
specPercent: The percent of traffic allocated to the referenced revision
|
|
in the service's spec.
|
|
statusPercent: The percent of traffic allocated to the referenced revision
|
|
in the service's status.
|
|
specTags: Tags assigned to the referenced revision in the service's spec as
|
|
a comma and space separated string.
|
|
statusTags: Tags assigned to the referenced revision in the service's status
|
|
as a comma and space separated string.
|
|
urls: A list of urls that directly address the referenced revision.
|
|
tags: A list of TrafficTag objects containing both the spec and status
|
|
state for each traffic tag.
|
|
displayPercent: Human-readable representation of the current percent
|
|
assigned to the referenced revision.
|
|
displayRevisionId: Human-readable representation of the name of the
|
|
referenced revision.
|
|
displayTags: Human-readable representation of the current tags assigned to
|
|
the referenced revision.
|
|
serviceUrl: The main URL for the service.
|
|
"""
|
|
# This class has lower camel case public attribute names to implement our
|
|
# desired style for json and yaml property names in structured output.
|
|
#
|
|
# This class gets passed to gcloud's printer to produce the output of
|
|
# `gcloud run services update-traffic`. When users specify --format=yaml or
|
|
# --format=json, the public attributes of this class get automatically
|
|
# converted to fields in the resulting json or yaml output, with names
|
|
# determined by this class's attribute names. We want the json and yaml output
|
|
# to have lower camel case property names.
|
|
|
|
def __init__(
|
|
self, spec_targets, status_targets, revision_name, latest,
|
|
status_percent_override, service_url=''):
|
|
"""Creates a new TrafficTargetPair.
|
|
|
|
Args:
|
|
spec_targets: A list of spec TrafficTargets that all reference the same
|
|
revision, either by name or the latest ready.
|
|
status_targets: A list of status TrafficTargets that all reference the
|
|
same revision, either by name or the latest ready.
|
|
revision_name: The name of the revision referenced by the traffic targets.
|
|
latest: A boolean indicating if these traffic targets reference the latest
|
|
ready revision.
|
|
status_percent_override: Percent to use for the status percent of this
|
|
TrafficTargetPair, overriding the values in status_targets.
|
|
service_url: The main URL for the service. Optional.
|
|
|
|
Returns:
|
|
A new TrafficTargetPair instance.
|
|
"""
|
|
self._spec_targets = spec_targets
|
|
self._status_targets = status_targets
|
|
self._revision_name = revision_name
|
|
self._latest = latest
|
|
self._status_percent_override = status_percent_override
|
|
self._service_url = service_url
|
|
self._tags = None
|
|
|
|
@property
|
|
def key(self):
|
|
return (traffic.LATEST_REVISION_KEY
|
|
if self.latestRevision else traffic.GetKey(self))
|
|
|
|
@property
|
|
def latestRevision(self): # pylint: disable=invalid-name
|
|
"""Returns true if the traffic targets reference the latest revision."""
|
|
return self._latest
|
|
|
|
@property
|
|
def revisionName(self): # pylint: disable=invalid-name
|
|
return self._revision_name
|
|
|
|
@property
|
|
def specPercent(self): # pylint: disable=invalid-name
|
|
if self._spec_targets:
|
|
return six.text_type(_SumPercent(self._spec_targets))
|
|
else:
|
|
return _MISSING_PERCENT_OR_TAGS
|
|
|
|
@property
|
|
def statusPercent(self): # pylint: disable=invalid-name
|
|
if self._status_percent_override is not None:
|
|
return six.text_type(self._status_percent_override)
|
|
elif self._status_targets:
|
|
return six.text_type(_SumPercent(self._status_targets))
|
|
else:
|
|
return _MISSING_PERCENT_OR_TAGS
|
|
|
|
@property
|
|
def specTags(self): # pylint: disable=invalid-name
|
|
spec_tags = _TAGS_JOIN_STRING.join(
|
|
sorted(t.tag for t in self._spec_targets if t.tag))
|
|
return spec_tags if spec_tags else _MISSING_PERCENT_OR_TAGS
|
|
|
|
@property
|
|
def statusTags(self): # pylint: disable=invalid-name
|
|
status_tags = _TAGS_JOIN_STRING.join(
|
|
sorted(t.tag for t in self._status_targets if t.tag))
|
|
return status_tags if status_tags else _MISSING_PERCENT_OR_TAGS
|
|
|
|
@property
|
|
def urls(self):
|
|
return sorted(t.url for t in self._status_targets if t.url)
|
|
|
|
@property
|
|
def tags(self):
|
|
if self._tags is None:
|
|
self._ExtractTags()
|
|
return self._tags
|
|
|
|
def _ExtractTags(self):
|
|
"""Extracts the traffic tag state from spec and status into TrafficTags."""
|
|
tags = {}
|
|
for spec_target in self._spec_targets:
|
|
if not spec_target.tag:
|
|
continue
|
|
tags[spec_target.tag] = TrafficTag(spec_target.tag, in_spec=True)
|
|
for status_target in self._status_targets:
|
|
if not status_target.tag:
|
|
continue
|
|
if status_target.tag in tags:
|
|
tag = tags[status_target.tag]
|
|
else:
|
|
tag = tags.setdefault(status_target.tag, TrafficTag(status_target.tag))
|
|
tag.url = status_target.url if status_target.url is not None else ''
|
|
tag.inStatus = True
|
|
self._tags = sorted(tags.values(), key=operator.attrgetter('tag'))
|
|
|
|
@property
|
|
def displayPercent(self): # pylint: disable=invalid-name
|
|
"""Returns human readable revision percent."""
|
|
if self.statusPercent == self.specPercent:
|
|
return _FormatPercentage(self.statusPercent)
|
|
else:
|
|
return '{:4} (currently {})'.format(
|
|
_FormatPercentage(self.specPercent),
|
|
_FormatPercentage(self.statusPercent))
|
|
|
|
@property
|
|
def displayRevisionId(self): # pylint: disable=invalid-name
|
|
"""Returns human readable revision identifier."""
|
|
if self.latestRevision:
|
|
return '%s (currently %s)' % (traffic.GetKey(self),
|
|
self.revisionName)
|
|
else:
|
|
return self.revisionName
|
|
|
|
@property
|
|
def displayTags(self): # pylint: disable=invalid-name
|
|
spec_tags = self.specTags
|
|
status_tags = self.statusTags
|
|
if spec_tags == status_tags:
|
|
return status_tags if status_tags != _MISSING_PERCENT_OR_TAGS else ''
|
|
else:
|
|
return '{} (currently {})'.format(spec_tags, status_tags)
|
|
|
|
@property
|
|
def serviceUrl(self): # pylint: disable=invalid-name
|
|
"""The main URL for the service."""
|
|
return self._service_url
|
|
|
|
|
|
def _SplitManagedLatestStatusTarget(spec_dict, status_dict, is_platform_managed,
|
|
latest_ready_revision_name):
|
|
"""Splits the fully-managed latest status target.
|
|
|
|
For managed the status target for the latest revision is
|
|
included by revisionName only and may hold the combined traffic
|
|
percent for both latestRevisionName and latestRevision spec targets.
|
|
Here we adjust keys in status_dict to match with spec_dict.
|
|
|
|
Args:
|
|
spec_dict: Dictionary mapping revision name or 'LATEST' to the spec
|
|
traffic target referencing that revision.
|
|
status_dict: Dictionary mapping revision name or 'LATEST' to the status
|
|
traffic target referencing that revision. Modified by this function.
|
|
is_platform_managed: Boolean indicating if the current platform is Cloud Run
|
|
fully-managed.
|
|
latest_ready_revision_name: The name of the latest ready revision.
|
|
|
|
Returns:
|
|
Optionally, the id of the list of status targets containing the combined
|
|
traffic referencing the latest ready revision by name and by latest.
|
|
"""
|
|
combined_status_targets_id = None
|
|
if (is_platform_managed and traffic.LATEST_REVISION_KEY in spec_dict and
|
|
traffic.LATEST_REVISION_KEY not in status_dict and
|
|
latest_ready_revision_name in status_dict):
|
|
latest_status_targets = status_dict[latest_ready_revision_name]
|
|
status_dict[traffic.LATEST_REVISION_KEY] = latest_status_targets
|
|
if latest_ready_revision_name in spec_dict:
|
|
combined_status_targets_id = id(latest_status_targets)
|
|
else:
|
|
del status_dict[latest_ready_revision_name]
|
|
return combined_status_targets_id
|
|
|
|
|
|
def _PercentOverride(key, spec_dict, status_targets,
|
|
combined_status_targets_id):
|
|
"""Computes the optional override percent to apply to the status percent."""
|
|
percent_override = None
|
|
if id(status_targets) == combined_status_targets_id:
|
|
spec_by_latest_percent = _SumPercent(spec_dict[traffic.LATEST_REVISION_KEY])
|
|
status_percent = _SumPercent(status_targets)
|
|
status_by_latest_percent = min(spec_by_latest_percent, status_percent)
|
|
if key == traffic.LATEST_REVISION_KEY:
|
|
percent_override = status_by_latest_percent
|
|
else:
|
|
percent_override = status_percent - status_by_latest_percent
|
|
return percent_override
|
|
|
|
|
|
def GetTrafficTargetPairs(spec_traffic, status_traffic, is_platform_managed,
|
|
latest_ready_revision_name, service_url=''):
|
|
"""Returns a list of TrafficTargetPairs for a Service.
|
|
|
|
Given the spec and status traffic targets wrapped in a TrafficTargets instance
|
|
for a sevice, this function pairs up all spec and status traffic targets that
|
|
reference the same revision (either by name or the latest ready revision) into
|
|
TrafficTargetPairs. This allows the caller to easily see any differences
|
|
between the spec and status traffic.
|
|
|
|
For fully-managed Cloud Run, the status target for the latest revision is
|
|
included by revisionName only and may hold the combined traffic
|
|
percent for both latestRevisionName and latestRevision spec targets. This
|
|
function splits the fully-managed status target for the latest revision into
|
|
a target for the percent allocated to the latest revision by name and a target
|
|
for the percent allocated to the latest revision because it is latest.
|
|
|
|
Args:
|
|
spec_traffic: A traffic.TrafficTargets instance wrapping the spec traffic.
|
|
status_traffic: A traffic.TrafficTargets instance wrapping the status
|
|
traffic.
|
|
is_platform_managed: Boolean indicating whether the current platform is
|
|
fully-managed or Anthos/GKE.
|
|
latest_ready_revision_name: The name of the service's latest ready revision.
|
|
service_url: The main URL for the service. Optional.
|
|
Returns:
|
|
A list of TrafficTargetPairs representing the current state of the service's
|
|
traffic assignments. The TrafficTargetPairs are sorted by revision name,
|
|
with targets referencing the latest ready revision at the end.
|
|
"""
|
|
# Copy spec and status traffic to dictionaries to allow mapping
|
|
# traffic.LATEST_REVISION_KEY to the same targets as
|
|
# latest_ready_revision_name without modifying the underlying protos during
|
|
# a read-only operation. These dictionaries map revision name (or "LATEST"
|
|
# for the latest ready revision) to a list of TrafficTarget protos.
|
|
spec_dict = dict(spec_traffic)
|
|
status_dict = dict(status_traffic)
|
|
|
|
combined_status_targets_id = _SplitManagedLatestStatusTarget(
|
|
spec_dict, status_dict, is_platform_managed, latest_ready_revision_name)
|
|
result = []
|
|
for k in set(spec_dict).union(status_dict):
|
|
spec_targets = spec_dict.get(k, [])
|
|
status_targets = status_dict.get(k, [])
|
|
percent_override = _PercentOverride(k, spec_dict, status_targets,
|
|
combined_status_targets_id)
|
|
if k == traffic.LATEST_REVISION_KEY:
|
|
revision_name = latest_ready_revision_name
|
|
latest = True
|
|
else:
|
|
revision_name = k
|
|
latest = False
|
|
|
|
result.append(
|
|
TrafficTargetPair(spec_targets, status_targets, revision_name, latest,
|
|
percent_override, service_url))
|
|
return sorted(result, key=traffic.SortKeyFromTarget)
|