1# -*- coding: utf-8 -*- #
2# Copyright 2020 Google LLC. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#    http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15"""Traffic representation for printing."""
16from __future__ import absolute_import
17from __future__ import division
18from __future__ import print_function
19from __future__ import unicode_literals
20
21import operator
22
23from googlecloudsdk.api_lib.kuberun import service
24from googlecloudsdk.api_lib.kuberun import traffic
25
26import six
27
28# Human readable indicator for a missing traffic percentage or missing tags.
29_MISSING_PERCENT_OR_TAGS = '-'
30
31# String to join TrafficTarget tags referencing the same revision.
32_TAGS_JOIN_STRING = ', '
33
34
35def _FormatPercentage(percent):
36  if percent == _MISSING_PERCENT_OR_TAGS:
37    return _MISSING_PERCENT_OR_TAGS
38  else:
39    return '{}%'.format(percent)
40
41
42def _SumPercent(targets):
43  """Sums the percents of the given targets."""
44  return sum(t.percent for t in targets if t.percent)
45
46
47def SortKeyFromTarget(target):
48  """Sorted key function to order TrafficTarget objects by key.
49
50  TrafficTargets keys are one of:
51  o revisionName
52  o LATEST_REVISION_KEY
53
54  Note LATEST_REVISION_KEY is not a str so its ordering with respect
55  to revisionName keys is hard to predict.
56
57  Args:
58    target: A TrafficTarget.
59
60  Returns:
61    A value that sorts by revisionName with LATEST_REVISION_KEY
62    last.
63  """
64  key = traffic.GetKey(target)
65  if key == traffic.LATEST_REVISION_KEY:
66    result = (2, key)
67  else:
68    result = (1, key)
69  return result
70
71
72class TrafficTag(object):
73  """Contains the spec and status state for a traffic tag.
74
75  Attributes:
76    tag: The name of the tag.
77    url: The tag's URL, or an empty string if the tag does not have a URL
78      assigned yet. Defaults to an empty string.
79    inSpec: Boolean that is true if the tag is present in the spec. Defaults to
80      False.
81    inStatus: Boolean that is true if the tag is present in the status. Defaults
82      to False.
83  """
84
85  def __init__(self, tag, url='', in_spec=False, in_status=False):
86    """Returns a new TrafficTag.
87
88    Args:
89      tag: The name of the tag.
90      url: The tag's URL.
91      in_spec: Boolean that is true if the tag is present in the spec.
92      in_status: Boolean that is true if the tag is present in the status.
93    """
94    self.tag = tag
95    self.url = url
96    self.inSpec = in_spec  # pylint: disable=invalid-name
97    self.inStatus = in_status  # pylint: disable=invalid-name
98
99
100class TrafficTargetPair(object):
101  """Holder for TrafficTarget status information.
102
103  The representation of the status of traffic for a service
104  includes:
105    o User requested assignments (spec.traffic)
106    o Actual assignments (status.traffic)
107
108  Each of spec.traffic and status.traffic may contain multiple traffic targets
109  that reference the same revision, either directly by name or indirectly by
110  referencing the latest ready revision.
111
112  The spec and status traffic targets for a revision may differ after a failed
113  traffic update or during a successful one. A TrafficTargetPair holds all
114  spec and status TrafficTargets that reference the same revision by name or
115  reference the latest ready revision. Both the spec and status traffic targets
116  can be empty.
117
118  The latest revision can be included in the spec traffic targets
119  two ways
120    o by revisionName
121    o by setting latestRevision to True.
122
123  Attributes:
124    key: Either the referenced revision name or 'LATEST' if the traffic targets
125      reference the latest ready revision.
126    latestRevision: Boolean indicating if the traffic targets reference the
127      latest ready revision.
128    revisionName: The name of the revision referenced by these traffic targets.
129    specPercent: The percent of traffic allocated to the referenced revision in
130      the service's spec.
131    statusPercent: The percent of traffic allocated to the referenced revision
132      in the service's status.
133    specTags: Tags assigned to the referenced revision in the service's spec as
134      a comma and space separated string.
135    statusTags: Tags assigned to the referenced revision in the service's status
136      as a comma and space separated string.
137    urls: A list of urls that directly address the referenced revision.
138    tags: A list of TrafficTag objects containing both the spec and status state
139      for each traffic tag.
140    displayPercent: Human-readable representation of the current percent
141      assigned to the referenced revision.
142    displayRevisionId: Human-readable representation of the name of the
143      referenced revision.
144    displayTags: Human-readable representation of the current tags assigned to
145      the referenced revision.
146    serviceUrl: The main URL for the service.
147  """
148
149  # This class has lower camel case public attribute names to implement our
150  # desired style for json and yaml property names in structured output.
151  #
152  # This class gets passed to gcloud's printer to produce the output of
153  # `gcloud run services update-traffic`. When users specify --format=yaml or
154  # --format=json, the public attributes of this class get automatically
155  # converted to fields in the resulting json or yaml output, with names
156  # determined by this class's attribute names. We want the json and yaml output
157  # to have lower camel case property names.
158
159  def __init__(self,
160               spec_targets,
161               status_targets,
162               revision_name,
163               latest,
164               service_url=''):
165    """Creates a new TrafficTargetPair.
166
167    Args:
168      spec_targets: A list of spec TrafficTargets that all reference the same
169        revision, either by name or the latest ready.
170      status_targets: A list of status TrafficTargets that all reference the
171        same revision, either by name or the latest ready.
172      revision_name: The name of the revision referenced by the traffic targets.
173      latest: A boolean indicating if these traffic targets reference the latest
174        ready revision.
175      service_url: The main URL for the service. Optional.
176
177    Returns:
178      A new TrafficTargetPair instance.
179    """
180    self._spec_targets = spec_targets
181    self._status_targets = status_targets
182    self._revision_name = revision_name
183    self._latest = latest
184    self._service_url = service_url
185    self._tags = None
186
187  @property
188  def latestRevision(self):  # pylint: disable=invalid-name
189    """Returns true if the traffic targets reference the latest revision."""
190    return self._latest
191
192  @property
193  def revisionName(self):  # pylint: disable=invalid-name
194    return self._revision_name
195
196  @property
197  def specPercent(self):  # pylint: disable=invalid-name
198    if self._spec_targets:
199      return six.text_type(_SumPercent(self._spec_targets))
200    else:
201      return _MISSING_PERCENT_OR_TAGS
202
203  @property
204  def statusPercent(self):  # pylint: disable=invalid-name
205    if self._status_targets:
206      return six.text_type(_SumPercent(self._status_targets))
207    else:
208      return _MISSING_PERCENT_OR_TAGS
209
210  @property
211  def specTags(self):  # pylint: disable=invalid-name
212    spec_tags = _TAGS_JOIN_STRING.join(
213        sorted(t.tag for t in self._spec_targets if t.tag))
214    return spec_tags if spec_tags else _MISSING_PERCENT_OR_TAGS
215
216  @property
217  def statusTags(self):  # pylint: disable=invalid-name
218    status_tags = _TAGS_JOIN_STRING.join(
219        sorted(t.tag for t in self._status_targets if t.tag))
220    return status_tags if status_tags else _MISSING_PERCENT_OR_TAGS
221
222  @property
223  def urls(self):
224    return sorted(t.url for t in self._status_targets if t.url)
225
226  @property
227  def tags(self):
228    if self._tags is None:
229      self._ExtractTags()
230    return self._tags
231
232  def _ExtractTags(self):
233    """Extracts the traffic tag state from spec and status into TrafficTags."""
234    tags = {}
235    for spec_target in self._spec_targets:
236      if not spec_target.tag:
237        continue
238      tags[spec_target.tag] = TrafficTag(spec_target.tag, in_spec=True)
239    for status_target in self._status_targets:
240      if not status_target.tag:
241        continue
242      if status_target.tag in tags:
243        tag = tags[status_target.tag]
244      else:
245        tag = tags.setdefault(status_target.tag, TrafficTag(status_target.tag))
246      tag.url = status_target.url if status_target.url is not None else ''
247      tag.inStatus = True
248    self._tags = sorted(tags.values(), key=operator.attrgetter('tag'))
249
250  @property
251  def displayPercent(self):  # pylint: disable=invalid-name
252    """Returns human readable revision percent."""
253    if self.statusPercent == self.specPercent:
254      return _FormatPercentage(self.statusPercent)
255    else:
256      return '{:4} (currently {})'.format(
257          _FormatPercentage(self.specPercent),
258          _FormatPercentage(self.statusPercent))
259
260  @property
261  def displayRevisionId(self):  # pylint: disable=invalid-name
262    """Returns human readable revision identifier."""
263    if self.latestRevision:
264      return '%s (currently %s)' % (traffic.GetKey(self), self.revisionName)
265    else:
266      return self.revisionName
267
268  @property
269  def displayTags(self):  # pylint: disable=invalid-name
270    spec_tags = self.specTags
271    status_tags = self.statusTags
272    if spec_tags == status_tags:
273      return status_tags if status_tags != _MISSING_PERCENT_OR_TAGS else ''
274    else:
275      return '{} (currently {})'.format(spec_tags, status_tags)
276
277  @property
278  def serviceUrl(self):  # pylint: disable=invalid-name
279    """The main URL for the service."""
280    return self._service_url
281
282
283def GetTrafficTargetPairs(spec_traffic,
284                          status_traffic,
285                          latest_ready_revision_name,
286                          service_url=''):
287  """Returns a list of TrafficTargetPairs for a Service.
288
289  Given the spec and status traffic targets wrapped in a TrafficTargets instance
290  for a sevice, this function pairs up all spec and status traffic targets that
291  reference the same revision (either by name or the latest ready revision) into
292  TrafficTargetPairs. This allows the caller to easily see any differences
293  between the spec and status traffic.
294
295  Args:
296    spec_traffic: A dictionary of name->traffic.TrafficTarget for the spec
297      traffic.
298    status_traffic: A dictionary of name->traffic.TrafficTarget for the status
299      traffic.
300    latest_ready_revision_name: The name of the service's latest ready revision.
301    service_url: The main URL for the service. Optional.
302
303  Returns:
304    A list of TrafficTargetPairs representing the current state of the service's
305    traffic assignments. The TrafficTargetPairs are sorted by revision name,
306    with targets referencing the latest ready revision at the end.
307  """
308  # Copy spec and status traffic to dictionaries to allow mapping
309  # traffic.LATEST_REVISION_KEY to the same targets as
310  # latest_ready_revision_name without modifying the underlying protos during
311  # a read-only operation. These dictionaries map revision name (or "LATEST"
312  # for the latest ready revision) to a list of TrafficTarget protos.
313  spec_dict = dict(spec_traffic)
314  status_dict = dict(status_traffic)
315
316  result = []
317  for k in set(spec_dict).union(status_dict):
318    spec_targets = spec_dict.get(k, [])
319    status_targets = status_dict.get(k, [])
320    if k == traffic.LATEST_REVISION_KEY:
321      revision_name = latest_ready_revision_name
322      latest = True
323    else:
324      revision_name = k
325      latest = False
326
327    result.append(
328        TrafficTargetPair(spec_targets, status_targets, revision_name, latest,
329                          service_url))
330  return sorted(result, key=SortKeyFromTarget)
331
332
333def GetTrafficTargetPairsDict(service_dict):
334  """Returns a list of TrafficTargetPairs for a Service as python dictionary.
335
336  Delegates to GetTrafficTargetPairs().
337
338  Args:
339    service_dict: python dict-like object representing a Service unmarshalled
340      from json
341  """
342  svc = service.Service(service_dict)
343  return GetTrafficTargetPairs(svc.spec_traffic, svc.status_traffic,
344                               svc.latest_ready_revision, svc.url)
345