387 lines
15 KiB
Python
387 lines
15 KiB
Python
#
|
|
# 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) and
|
|
# name in namespace means that object was introduced in cls
|
|
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})
|