# -*- coding: utf-8 -*- # # Copyright 2018 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 wrapping/dealing with a k8s-style objects.""" from __future__ import absolute_import from __future__ import division from __future__ import print_function from __future__ import unicode_literals import abc import collections from apitools.base.protorpclite import messages from googlecloudsdk.api_lib.run import condition from googlecloudsdk.core.console import console_attr import six try: # Python 3.3 and above. collections_abc = collections.abc except AttributeError: collections_abc = collections SERVING_GROUP = 'serving.knative.dev' AUTOSCALING_GROUP = 'autoscaling.knative.dev' EVENTING_GROUP = 'eventing.knative.dev' CLIENT_GROUP = 'client.knative.dev' GOOGLE_GROUP = 'cloud.googleapis.com' RUN_GROUP = 'run.googleapis.com' RUNAPPS_GROUP = 'runapps.googleapis.com' INTERNAL_GROUPS = ( CLIENT_GROUP, SERVING_GROUP, AUTOSCALING_GROUP, EVENTING_GROUP, GOOGLE_GROUP, RUN_GROUP, ) AUTHOR_ANNOTATION = SERVING_GROUP + '/creator' REGION_LABEL = GOOGLE_GROUP + '/location' CLIENT_NAME_ANNOTATION = RUN_GROUP + '/client-name' CLIENT_VERSION_ANNOTATION = RUN_GROUP + '/client-version' DESCRIPTION_ANNOTATION = RUN_GROUP + '/description' LAUNCH_STAGE_ANNOTATION = RUN_GROUP + '/launch-stage' BINAUTHZ_POLICY_ANNOTATION = RUN_GROUP + '/binary-authorization' BINAUTHZ_BREAKGLASS_ANNOTATION = RUN_GROUP + '/binary-authorization-breakglass' EXECUTION_ENVIRONMENT_ANNOTATION = RUN_GROUP + '/execution-environment' CUSTOM_AUDIENCES_ANNOTATION = RUN_GROUP + '/custom-audiences' NETWORK_INTERFACES_ANNOTATION = RUN_GROUP + '/network-interfaces' CONTAINER_DEPENDENCIES_ANNOTATION = RUN_GROUP + '/container-dependencies' GPU_TYPE_NODE_SELECTOR = RUN_GROUP + '/accelerator' MULTI_REGION_REGIONS_ANNOTATION = RUN_GROUP + '/regions' MULTI_REGION_ID_LABEL = RUN_GROUP + '/multi-region-id' GCLB_DOMAIN_NAME_ANNOTATION = RUNAPPS_GROUP + '/gclb-domain-name' THREAT_DETECTION_ANNOTATION = RUN_GROUP + '/threat-detection' def Meta(m): """Metadta class from messages module.""" if hasattr(m, 'ObjectMeta'): return m.ObjectMeta elif hasattr(m, 'K8sIoApimachineryPkgApisMetaV1ObjectMeta'): return m.K8sIoApimachineryPkgApisMetaV1ObjectMeta raise ValueError('Provided module does not have a known metadata class') def ListMeta(m): """List Metadta class from messages module.""" if hasattr(m, 'ListMeta'): return m.ListMeta elif hasattr(m, 'K8sIoApimachineryPkgApisMetaV1ListMeta'): return m.K8sIoApimachineryPkgApisMetaV1ListMeta raise ValueError('Provided module does not have a known metadata class') def MakeMeta(m, *args, **kwargs): """Make metadata message from messages module.""" return Meta(m)(*args, **kwargs) def InitializedInstance(msg_cls): """Produce an instance of msg_cls, with all sub-messages initialized. Args: msg_cls: A message-class to be instantiated. Returns: An instance of the given class, with all fields initialized blank objects. """ def Instance(field): if field.repeated: return [] return InitializedInstance(field.message_type) def IncludeField(field): return isinstance(field, messages.MessageField) args = { field.name: Instance(field) for field in msg_cls.all_fields() if IncludeField(field) } return msg_cls(**args) @six.add_metaclass(abc.ABCMeta) class KubernetesObject(object): """Base class for wrappers around Kubernetes-style Object messages. Requires subclasses to provide class-level constants KIND for the k8s Kind field, and API_CATEGORY for the k8s API Category. It infers the API version from the version of the client object. Additionally, you can set READY_CONDITION and TERMINAL_CONDITIONS to be the name of a condition that indicates readiness, and a set of conditions indicating a steady state, respectively. """ READY_CONDITION = 'Ready' @classmethod def Kind(cls, kind=None): """Returns the passed str if given, else the class KIND.""" return kind if kind is not None else cls.KIND @classmethod def ApiCategory(cls, api_category=None): """Returns the passed str if given, else the class API_CATEGORY.""" return api_category if api_category is not None else cls.API_CATEGORY @classmethod def ApiVersion(cls, api_version, api_category=None): """Returns the api version with group prefix if exists.""" if api_category is None: return api_version return '{}/{}'.format(api_category, api_version) @classmethod def SpecOnly(cls, spec, messages_mod, kind=None): """Produces a wrapped message with only the given spec. It is meant to be used as part of another message; it will error if you try to access the metadata or status. Arguments: spec: messages.Message, The spec to include messages_mod: the messages module kind: str, the resource kind Returns: A new k8s_object with only the given spec. """ msg_cls = getattr(messages_mod, cls.Kind(kind)) return cls(msg_cls(spec=spec), messages_mod, kind) @classmethod def Template(cls, template, messages_mod, kind=None): """Wraps a template object: spec and metadata only, no status.""" msg_cls = getattr(messages_mod, cls.Kind(kind)) return cls( msg_cls(spec=template.spec, metadata=template.metadata), messages_mod, kind, ) @classmethod def New(cls, client, namespace, kind=None, api_category=None): """Produces a new wrapped message of the appropriate type. All the sub-objects in it are recursively initialized to the appropriate message types, and the kind, apiVersion, and namespace set. Arguments: client: the API client to use namespace: str, The namespace to create the object in kind: str, the resource kind api_category: str, the api group of the resource Returns: The newly created wrapped message. """ api_category = cls.ApiCategory(api_category) api_version = cls.ApiVersion(getattr(client, '_VERSION'), api_category) messages_mod = client.MESSAGES_MODULE kind = cls.Kind(kind) ret = InitializedInstance(getattr(messages_mod, kind)) try: ret.kind = kind ret.apiVersion = api_version except AttributeError: # TODO(b/113172423): Workaround. Some top-level messages don't have # apiVersion and kind yet but they should pass ret.metadata.namespace = namespace return cls(ret, messages_mod, kind) def __init__(self, to_wrap, messages_mod, kind=None): msg_cls = getattr(messages_mod, self.Kind(kind)) if not isinstance(to_wrap, msg_cls): raise ValueError('Oops, trying to wrap wrong kind of message') self._m = to_wrap self._messages = messages_mod def MessagesModule(self): """Return the messages module.""" return self._messages # TODO(b/177659646): Avoid raising python build-in exceptions. def AssertFullObject(self): if not self._m.metadata: raise ValueError('This instance is spec-only.') def IsFullObject(self): return self._m.metadata # Access the "raw" k8s message parts. When subclasses want to allow mutability # they should provide their own convenience properties with setters. @property def kind(self): self.AssertFullObject() return self._m.kind @property def apiVersion(self): # pylint: disable=invalid-name self.AssertFullObject() return self._m.apiVersion @property def spec(self): return self._m.spec @property def status(self): self.AssertFullObject() return self._m.status @property def metadata(self): self.AssertFullObject() return self._m.metadata @metadata.setter def metadata(self, value): self._m.metadata = value # Alias common bits of metadata to the top level, for convenience. @property def name(self): self.AssertFullObject() return self._m.metadata.name @name.setter def name(self, value): self.AssertFullObject() self._m.metadata.name = value @property def author(self): return self.annotations.get(AUTHOR_ANNOTATION) @property def creation_timestamp(self): return self.metadata.creationTimestamp @property def namespace(self): self.AssertFullObject() return self._m.metadata.namespace @namespace.setter def namespace(self, value): self.AssertFullObject() self._m.metadata.namespace = value @property def resource_version(self): self.AssertFullObject() return self._m.metadata.resourceVersion @property def self_link(self): self.AssertFullObject() return self._m.metadata.selfLink.lstrip('/') @property def uid(self): self.AssertFullObject() return self._m.metadata.uid @property def owners(self): self.AssertFullObject() return self._m.metadata.ownerReferences @property def is_managed(self): return REGION_LABEL in self.labels @property def region(self): self.AssertFullObject() return self.labels[REGION_LABEL] @property def generation(self): self.AssertFullObject() return self._m.metadata.generation @generation.setter def generation(self, value): self._m.metadata.generation = value @property def conditions(self): return self.GetConditions() def GetConditions(self, terminal_condition=None): self.AssertFullObject() if self._m.status: c = self._m.status.conditions else: c = [] return condition.Conditions( c, terminal_condition if terminal_condition else self.READY_CONDITION, getattr(self._m.status, 'observedGeneration', None), self.generation, ) @property def annotations(self): self.AssertFullObject() return AnnotationsFromMetadata(self._messages, self._m.metadata) @property def labels(self): self.AssertFullObject() return LabelsFromMetadata(self._messages, self._m.metadata) @property def ready_condition(self): assert hasattr(self, 'READY_CONDITION') if self.conditions and self.READY_CONDITION in self.conditions: return self.conditions[self.READY_CONDITION] @property def ready(self): assert hasattr(self, 'READY_CONDITION') if self.ready_condition: return self.ready_condition['status'] @property def last_transition_time(self): assert hasattr(self, 'READY_CONDITION') if self.ready_condition: return self.ready_condition['lastTransitionTime'] def _PickSymbol(self, best, alt, encoding): """Choose the best symbol (if it's in this encoding) or an alternate.""" try: best.encode(encoding) return best except UnicodeError: return alt @property def ready_symbol(self): """Return a symbol summarizing the status of this object.""" return self.ReadySymbolAndColor()[0] def ReadySymbolAndColor(self): """Return a tuple of ready_symbol and display color for this object.""" # NB: This can be overridden by subclasses to allow symbols for more # complex reasons the object isn't ready. Ex: Service overrides it to # provide '!' for "I'm serving, but not the revision you wanted." encoding = console_attr.GetConsoleAttr().GetEncoding() if self.ready is None: return ( self._PickSymbol('\N{HORIZONTAL ELLIPSIS}', '.', encoding), 'yellow', ) elif self.ready: return self._PickSymbol('\N{HEAVY CHECK MARK}', '+', encoding), 'green' else: return 'X', 'red' def AsObjectReference(self): return self._messages.ObjectReference( kind=self.kind, namespace=self.namespace, name=self.name, uid=self.uid, apiVersion=self.apiVersion, ) def Message(self): """Return the actual message we've wrapped.""" return self._m def MakeSerializable(self): return self.Message() def MakeCondition(self, *args, **kwargs): if hasattr(self._messages, 'GoogleCloudRunV1Condition'): return self._messages.GoogleCloudRunV1Condition(*args, **kwargs) else: return getattr(self._messages, self.kind + 'Condition')(*args, **kwargs) def __eq__(self, other): if isinstance(other, type(self)): return self.Message() == other.Message() return False def __repr__(self): return '{}({})'.format(type(self).__name__, repr(self._m)) def AnnotationsFromMetadata(messages_mod, metadata): if not metadata.annotations: metadata.annotations = Meta(messages_mod).AnnotationsValue() return KeyValueListAsDictionaryWrapper( metadata.annotations.additionalProperties, Meta(messages_mod).AnnotationsValue.AdditionalProperty, key_field='key', value_field='value', ) def LabelsFromMetadata(messages_mod, metadata): if not metadata.labels: metadata.labels = Meta(messages_mod).LabelsValue() return KeyValueListAsDictionaryWrapper( metadata.labels.additionalProperties, Meta(messages_mod).LabelsValue.AdditionalProperty, key_field='key', value_field='value', ) class LazyListWrapper(collections_abc.MutableSequence): """Wraps a list that does not exist at object creation time. We sometimes have a need to allow access to a list property of a nested message, when we're not sure if all the layers above the list exist yet. We want to arrange it so that when you write to the list, all the above messages are lazily created. When you create a LazyListWrapper, you pass in a create function, which must do whatever setup you need to do, and then return the list that it creates in an underlying message. As soon as you start adding items to the LazyListWrapper, it will do the setup for you. Until then, it won't create any underlying messages. """ def __init__(self, create): self._create = create self._l = None def __getitem__(self, i): if self._l: return self._l[i] raise IndexError() def __setitem__(self, i, v): if self._l is None: self._l = self._create() self._l[i] = v def __delitem__(self, i): if self._l: del self._l[i] else: raise IndexError() def __len__(self): if self._l: return len(self._l) return 0 def insert(self, i, v): if self._l is None: self._l = self._create() self._l.insert(i, v) class ListAsDictionaryWrapper(collections_abc.MutableMapping): """Wraps repeated messages field with name in a dict-like object. Operations in these classes are O(n) for simplicity. This needs to match the live state of the underlying list of messages, including edits made by others. """ def __init__(self, to_wrap, key_field='name', filter_func=None): """Wraps list of messages to be accessible as a read-only dictionary. Arguments: to_wrap: List[Message], List of messages to treat as a dictionary. key_field: attribute to use as the keys of the dictionary filter_func: filter function to allow only considering certain messages from the wrapped list. This function should take a message as its only argument and return True if this message should be included. """ self._m = to_wrap self._key_field = key_field self._filter = filter_func or (lambda _: True) def __getitem__(self, key): """Implements evaluation of `self[key]`.""" for k, item in self.items(): if k == key: return item raise KeyError(key) def __setitem__(self, key, value): setattr(value, self._key_field, key) for index, item in enumerate(self._m): if getattr(item, self._key_field) == key: if not self._filter(item): raise KeyError(key) self._m[index] = value return self._m.append(value) def setdefault(self, key, default): for item in self._m: if getattr(item, self._key_field) == key: if not self._filter(item): raise KeyError(key) return item setattr(default, self._key_field, key) self._m.append(default) return default def __delitem__(self, key): """Implements evaluation of `del self[key]`.""" index_to_delete = None for index, item in enumerate(self._m): if getattr(item, self._key_field) == key: if self._filter(item): index_to_delete = index break if index_to_delete is None: raise KeyError(key) del self._m[index_to_delete] def __len__(self): """Implements evaluation of `len(self)`.""" return sum(1 for _ in self.items()) def __iter__(self): """Returns a generator yielding the message keys.""" return (item[0] for item in self.items()) def MakeSerializable(self): return self._m def __repr__(self): return '{}{{{}}}'.format( type(self).__name__, ', '.join('{}: {}'.format(k, v) for k, v in self.items()), ) def items(self): return ListItemsView(self, none_key='') def values(self): return ListValuesView(self) class ListItemsView(collections_abc.ItemsView): """Item iterator for ListAsDictionaryWrapper.""" def __init__(self, *args, none_key=None, **kwargs): super().__init__(*args, **kwargs) self._none_key = none_key def __iter__(self): for item in self._mapping._m: if self._mapping._filter(item): key = getattr(item, self._mapping._key_field) if key is None: key = self._none_key yield (key, item) class ListValuesView(collections_abc.ValuesView): def __contains__(self, value): for v in iter(self): if v == value: return True return False def __iter__(self): for _, value in self._mapping.items(): yield value class KeyValueListAsDictionaryWrapper(ListAsDictionaryWrapper): """Wraps repeated messages field with name and value in a dict-like object. Properties which resemble dictionaries (e.g. environment variables, build template arguments) are represented in the underlying messages fields as a list of objects, each of which has a name and value field. This class wraps that list in a dict-like object that can be used to mutate the underlying fields in a more Python-idiomatic way. """ def __init__( self, to_wrap, item_class, key_field='name', value_field='value', filter_func=None, ): """Wrap a list of messages to be accessible as a dictionary. Arguments: to_wrap: List[Message], List of messages to treat as a dictionary. item_class: type of the underlying Message objects key_field: attribute to use as the keys of the dictionary value_field: attribute to use as the values of the dictionary filter_func: filter function to allow only considering certain messages from the wrapped list. This function should take a message as its only argument and return True if this message should be included. """ super(KeyValueListAsDictionaryWrapper, self).__init__( to_wrap, key_field=key_field, filter_func=filter_func ) self._item_class = item_class self._value_field = value_field def __setitem__(self, key, value): """Implements evaluation of `self[key] = value`. Args: key: value of the key field value: value of the value field Raises: KeyError: if a message with the same key value already exists, but is hidden by the filter func, this is raised to prevent accidental overwrites """ item = super(KeyValueListAsDictionaryWrapper, self).setdefault( key, self._item_class() ) setattr(item, self._value_field, value) def setdefault(self, key, default): default_item = self._item_class(**{self._value_field: default}) item = super(KeyValueListAsDictionaryWrapper, self).setdefault( key, default_item ) return getattr(item, self._value_field) def items(self): return KeyValueListItemsView(self) class KeyValueListItemsView(ListItemsView): def __iter__(self): for key, item in super(KeyValueListItemsView, self).__iter__(): yield (key, getattr(item, self._mapping._value_field))