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