# # 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. import functools import inspect import six from rally.common.plugin import discover from rally.common.plugin import meta from rally import exceptions from rally.task import atomic def service(service_name, service_type, version, client_name=None): """Mark class as an implementation of partial service APIs. :param service_name: name of the service (e.g. Nova) :type service_name: str :param service_type: type of the service (e.g. Compute) :type service_type: str :param version: version of service (e.g. 2.1) :type version: str :param client_name: name of client for service. If None, service_name will be used instead. :type client_name: str """ def wrapper(cls): cls._meta_init() cls._meta_set("name", service_name.lower()) cls._meta_set("type", service_type.lower()) cls._meta_set("version", str(version)) cls._meta_set("client_name", client_name or service_name) return cls return wrapper def compat_layer(original_impl): """Set class which should be unified to common interface :param original_impl: implementation of specific service API :type original_impl: cls """ def wrapper(cls): cls._meta_init() cls._meta_set("impl", original_impl) return cls return wrapper def should_be_overridden(func): """Mark method which should be overridden by subclasses.""" func.require_impl = True return func def method_wrapper(func): """Wraps service's methods with some magic 1) Each service method should not be called with positional arguments, since it can lead mistakes in wrong order while writing version compatible code. We had such situation in KeystoneWrapper (see https://review.opendev.org/#/c/309470/ ): .. code-block:: python class IdentityService(Service): def add_role(self, role_id, user_id, project_id): self._impl(role_id, user_id, project_id) class KeystoneServiceV2(Service): def add_role(self, user_id, role_id, project_id): pass class KeystoneServiceV3(Service): def add_role(self, role_id, user_id, project_id): pass Explanation of example: The signature of add_role method is different in KeystoneServiceV2 and KeystoneServiceV3. Since IdentityService uses positional arguments to make call to self._impl.add_role, we have swapped values of role_id and user_id in case of KeystoneServiceV2. Original code and idea are taken from `positional` library. 2) We do not need keep atomics for some actions, for example for inner actions (until we start to support them). Previously, we used "atomic_action" argument with `if atomic_action` checks inside each method. To reduce number of similar if blocks, let's write them in one place, make the code cleaner and support such feature for all service methods. """ @functools.wraps(func) def wrapper(instance, *args, **kwargs): args_len = len(args) if args_len > 1: message = ("%(name)s takes at most 1 positional argument " "(%(given)d given)" % {"name": func.__name__, "given": args_len}) raise TypeError(message) return func(instance, *args, **kwargs) return wrapper class ServiceMeta(type): """Alternative implementation of abstract classes for Services. Common class of specific Service should not be hardcoded for any version of API. We expect that all public methods of specific common class are overridden in all versioned implementation. """ def __new__(mcs, name, parents, dct): for field in dct: if not field.startswith("_") and callable(dct[field]): dct[field] = method_wrapper(dct[field]) return super(ServiceMeta, mcs).__new__(mcs, name, parents, dct) def __init__(cls, name, bases, namespaces): super(ServiceMeta, cls).__init__(name, bases, namespaces) bases = [c for c in cls.__bases__ if type(c) == ServiceMeta] if not bases: # nothing to check return # obtain all properties of cls, since namespace doesn't include # properties of parents not_implemented_apis = set() for name, obj in inspect.getmembers(cls): if (getattr(obj, "require_impl", False) # name in namespace means that object was introduced in cls and name not in namespaces): # it is not overridden... not_implemented_apis.add(name) if not_implemented_apis: raise exceptions.RallyException( "%s has wrong implementation. Implementation of specific " "version of API should override all required methods of " "base service class. Missed method(s): %s." % (cls.__name__, ", ".join(not_implemented_apis))) @six.add_metaclass(ServiceMeta) class Service(meta.MetaMixin): """Base help class for Cloud Services(for example OpenStack services). A simple example of implementation: .. code-block:: # Implementation of Keystone V2 service @service("keystone", service_type="identity", version="2") class KeystoneV2Service(Service): @atomic.action_timer("keystone_v2.create_tenant") def create_tenant(self, tenant_name): return self.client.tenants.create(project_name) # Implementation of Keystone V3 service @service("keystone", service_type="identity", version="3") class KeystoneV3Service(Service): @atomic.action_timer("keystone_v3.create_project") def create_project(self, project_name): return self.client.project.create(project_name) """ def __init__(self, clients, name_generator=None, atomic_inst=None): """Initialize service class :param clients: instance of rally.plugins.openstack.osclients.Clients :param name_generator: a method for generating random names. Usually it is generate_random_name method of RandomNameGeneratorMixin instance. :param atomic_inst: an object to store atomic actions. Usually, it is `_atomic_actions` property of ActionTimerMixin instance """ self._clients = clients self._name_generator = name_generator if atomic_inst is None: self._atomic_actions = atomic.ActionTimerMixin().atomic_actions() else: self._atomic_actions = atomic_inst self.version = None if self._meta_is_inited(raise_exc=False): self.version = self._meta_get("version") def generate_random_name(self): if not self._name_generator: raise exceptions.RallyException( "You cannot use `generate_random_name` method, until you " "initialize class with `name_generator` argument.") return self._name_generator() class UnifiedService(Service): """Base help class for unified layer for Cloud Services A simple example of Identity service implementation: .. code-block:: import collections Project = collections.namedtuple("Project", ["id", "name"]) # Unified entry-point for Identity OpenStack service class Identity(UnifiedService): # this method is equal in UnifiedKeystoneV2 and UnifiedKeystoneV3. # Since there is no other implementation except Keystone, there # are no needs to copy-paste it. @classmethod def _is_applicable(cls, clients): cloud_version = clients.keystone().version.split(".")[0][1:] return cloud_version == impl._meta_get("version") def create_project(self, project_name, domain_name="Default"): return self._impl.create_project(project_name, domain_name=domain_name) # Class which unifies raw keystone v2 data to common form @compat_layer(KeystoneV2Service) class UnifiedKeystoneV2(Identity): def create_project(self, project_name, domain_name="Default"): if domain_name.lower() != "default": raise NotImplementedError( "Domain functionality not implemented in Keystone v2") tenant = self._impl.create_tenant(project_name) return Project(id=tenant.id, name=tenant.name) # Class which unifies raw keystone v3 data to common form @compat_layer(KeystoneV3Service) class UnifiedKeystoneV3(Identity): def create_project(self, project_name, domain_name="Default"): project = self._impl.create_project(project_name, domain_name=domain_name) return Project(id=project.id, name=project.name) """ def __init__(self, clients, name_generator=None, atomic_inst=None): """Initialize service class :param clients: instance of rally.plugins.openstack.osclients.Clients :param name_generator: a method for generating random names. Usually it is generate_random_name method of RandomNameGeneratorMixin instance. :param atomic_inst: an object to store atomic actions. Usually, it is `_atomic_actions` property of ActionTimerMixin instance """ super(UnifiedService, self).__init__(clients, name_generator, atomic_inst) if self._meta_is_inited(raise_exc=False): # it is an instance of compatibility layer for specific Service impl_cls = self._meta_get("impl") self._impl = impl_cls(self._clients, self._name_generator, self._atomic_actions) self.version = impl_cls._meta_get("version") else: # it is a base class of service impl_cls, _all_impls = self.discover_impl() if not impl_cls: raise exceptions.RallyException( "There is no proper implementation for %s." % self.__class__.__name__) self._impl = impl_cls(self._clients, self._name_generator, self._atomic_actions) self.version = self._impl.version def discover_impl(self): """Discover implementation for service One Service can have different implementations(not only in terms of versioning, for example Network service of OpenStack has Nova-network and Neutron implementation. they are quite different). Each of such implementations can support several versions. This method is designed to choose the proper helper class based on available services in the cloud and based on expected version. Returns a tuple with implementation class as first element, a set of all implementations as a second element """ # find all classes with unified implementation impls = {cls: cls._meta_get("impl") for cls in discover.itersubclasses(self.__class__) if (cls._meta_is_inited(raise_exc=False) and cls._meta_get("impl"))} service_names = {o._meta_get("name") for o in impls.values()} enabled_services = None # let's make additional calls to cloud only when we need to make a # decision based on available services if len(service_names) > 1: enabled_services = list(self._clients.services().values()) for cls, impl in impls.items(): if (enabled_services is not None and impl._meta_get("name") not in enabled_services): continue if cls.is_applicable(self._clients): return cls, impls return None, impls @classmethod def is_applicable(cls, clients): """Check that implementation can be used in cloud.""" if cls._meta_is_inited(raise_exc=False): impl = cls._meta_get("impl", cls) client = getattr(clients, impl._meta_get("client_name")) return client.choose_version() == impl._meta_get("version") return False class _Resource(object): __slots__ = [] _id_property = None def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) def __getitem__(self, item, default=None): return getattr(self, item, default) def __repr__(self): return "<%s id=%s>" % (self.__class__.__name__, getattr(self, self._id_property, "n/a")) def __eq__(self, other): self_id = getattr(self, self._id_property) return (isinstance(other, self.__class__) and self_id == getattr(other, self._id_property)) def _as_dict(self): return dict((k, self[k]) for k in self.__slots__) def make_resource_cls(name, properties, id_property="id"): """Construct a resource class with limited number of properties. Unlike collections.namedtuple, a created class has user-friendly getitem method for obtaining properties. :param name: The name of resource (i.e image, container..) :param properties: The list of allowed properties :param id_property: The name of property which should be used as id of resource. By defaults, it is "id" field if such property presents in "properties" or first element of "properties" in other cases. """ id_property = id_property if id_property in properties else properties[0] # NOTE(andreykurilin): call a `type` method instead of returning just raw # class (create Resource class inside the method make_resource_cls and # return it) allows to setup a custom name of a new class, which will be # used in case of errors and etc return type(name.title(), (_Resource,), {"__slots__": properties, "_id_property": id_property})