The change allows loading several service plugins along with core plugin.

The following functionality changes were made:
1. Multiple plugins are loaded one per type
2. QuantumManager now holds dictionary {plugin_type: plugin_instance}
   Core plugin is stored there as well
3. Extensions are checked against all loaded plugins
4. Service plugins are specified by service_plugins option in quantum.conf file
5. Provide basic interface for service plugins
6. Introduce dummy service plugin as example and PoC
7. Service plugin's REST calls get corresponding plugin's common prefix
8. Add UTs for new extension framework functionality and for QuantumManager

Implements: blueprint quantum-service-framework
Change-Id: I1d00d6f848937410bccd91c852ff0871a86d7bb8
This commit is contained in:
Eugene Nikanorov 2012-11-08 18:44:02 +04:00
parent a06fd815bf
commit 925b4185f5
13 changed files with 419 additions and 37 deletions

View File

@ -40,6 +40,9 @@ bind_port = 9696
# Quantum plugin provider module
core_plugin = quantum.plugins.sample.SamplePlugin.FakePlugin
# Advanced service modules
# service_plugins =
# Paste configuration file
api_paste_config = api-paste.ini

View File

@ -41,6 +41,8 @@ core_opts = [
cfg.StrOpt('auth_strategy', default='keystone'),
cfg.StrOpt('core_plugin',
default='quantum.plugins.sample.SamplePlugin.FakePlugin'),
cfg.ListOpt('service_plugins',
default=[]),
cfg.StrOpt('base_mac', default="fa:16:3e:00:00:00"),
cfg.IntOpt('mac_generation_retries', default=16),
cfg.BoolOpt('allow_bulk', default=True),

View File

@ -267,26 +267,30 @@ class ExtensionMiddleware(wsgi.Middleware):
# extended resources
for resource in self.ext_mgr.get_resources():
path_prefix = resource.path_prefix
if resource.parent:
path_prefix = (resource.path_prefix +
"/%s/{%s_id}" %
(resource.parent["collection_name"],
resource.parent["member_name"]))
LOG.debug(_('Extended resource: %s'),
resource.collection)
for action, method in resource.collection_actions.iteritems():
path_prefix = ""
parent = resource.parent
conditions = dict(method=[method])
path = "/%s/%s" % (resource.collection, action)
if parent:
path_prefix = "/%s/{%s_id}" % (parent["collection_name"],
parent["member_name"])
with mapper.submapper(controller=resource.controller,
action=action,
path_prefix=path_prefix,
conditions=conditions) as submap:
submap.connect(path)
submap.connect("%s.:(format)" % path)
mapper.resource(resource.collection, resource.collection,
controller=resource.controller,
member=resource.member_actions,
parent_resource=resource.parent)
parent_resource=resource.parent,
path_prefix=path_prefix)
# extended actions
action_controllers = self._action_ext_controllers(application,
@ -534,44 +538,44 @@ class PluginAwareExtensionManager(ExtensionManager):
_instance = None
def __init__(self, path, plugin):
self.plugin = plugin
def __init__(self, path, plugins):
self.plugins = plugins
super(PluginAwareExtensionManager, self).__init__(path)
def _check_extension(self, extension):
"""Checks if plugin supports extension and implements the
"""Checks if any of plugins supports extension and implements the
extension contract."""
extension_is_valid = super(PluginAwareExtensionManager,
self)._check_extension(extension)
return (extension_is_valid and
self._plugin_supports(extension) and
self._plugin_implements_interface(extension))
self._plugins_support(extension) and
self._plugins_implement_interface(extension))
def _plugin_supports(self, extension):
def _plugins_support(self, extension):
alias = extension.get_alias()
supports_extension = (hasattr(self.plugin,
"supported_extension_aliases") and
alias in self.plugin.supported_extension_aliases)
supports_extension = any((hasattr(plugin,
"supported_extension_aliases") and
alias in plugin.supported_extension_aliases)
for plugin in self.plugins.values())
plugin_provider = cfg.CONF.core_plugin
if not supports_extension and plugin_provider in ENABLED_EXTS:
supports_extension = (alias in
ENABLED_EXTS[plugin_provider]['ext_alias'])
if not supports_extension:
LOG.warn("extension %s not supported by plugin %s",
alias, self.plugin)
LOG.warn(_("extension %s not supported by any of loaded plugins" %
alias))
return supports_extension
def _plugin_implements_interface(self, extension):
def _plugins_implement_interface(self, extension):
if(not hasattr(extension, "get_plugin_interface") or
extension.get_plugin_interface() is None):
return True
plugin_has_interface = isinstance(self.plugin,
extension.get_plugin_interface())
if not plugin_has_interface:
LOG.warn("plugin %s does not implement extension's"
"plugin interface %s" % (self.plugin,
extension.get_alias()))
return plugin_has_interface
for plugin in self.plugins.values():
if isinstance(plugin, extension.get_plugin_interface()):
return True
LOG.warn(_("Loaded plugins do not implement extension %s interface"
% extension.get_alias()))
return False
@classmethod
def get_instance(cls):
@ -582,7 +586,7 @@ class PluginAwareExtensionManager(ExtensionManager):
LOG.debug('loading model %s', model)
model_class = importutils.import_class(model)
cls._instance = cls(get_extensions_path(),
QuantumManager.get_plugin())
QuantumManager.get_service_plugins())
return cls._instance
@ -612,13 +616,14 @@ class ActionExtension(object):
class ResourceExtension(object):
"""Add top level resources to the OpenStack API in Quantum."""
def __init__(self, collection, controller, parent=None,
def __init__(self, collection, controller, parent=None, path_prefix="",
collection_actions={}, member_actions={}):
self.collection = collection
self.controller = controller
self.parent = parent
self.collection_actions = collection_actions
self.member_actions = member_actions
self.path_prefix = path_prefix
# Returns the extention paths from a config entry and the __path__

View File

@ -27,6 +27,7 @@ from quantum.common.exceptions import ClassNotFound
from quantum.openstack.common import cfg
from quantum.openstack.common import importutils
from quantum.openstack.common import log as logging
from quantum.plugins.common import constants
LOG = logging.getLogger(__name__)
@ -58,8 +59,52 @@ class QuantumManager(object):
"Example: pip install quantum-sample-plugin")
self.plugin = plugin_klass()
# core plugin as a part of plugin collection simplifies
# checking extensions
# TODO (enikanorov): make core plugin the same as
# the rest of service plugins
self.service_plugins = {constants.CORE: self.plugin}
self._load_service_plugins()
def _load_service_plugins(self):
plugin_providers = cfg.CONF.service_plugins
LOG.debug(_("Loading service plugins: %s" % plugin_providers))
for provider in plugin_providers:
if provider == '':
continue
try:
LOG.info(_("Loading Plugin: %s" % provider))
plugin_class = importutils.import_class(provider)
except ClassNotFound:
LOG.exception(_("Error loading plugin"))
raise Exception(_("Plugin not found."))
plugin_inst = plugin_class()
# only one implementation of svc_type allowed
# specifying more than one plugin
# for the same type is a fatal exception
if plugin_inst.get_plugin_type() in self.service_plugins:
raise Exception(_("Multiple plugins for service "
"%s were configured" %
plugin_inst.get_plugin_type()))
self.service_plugins[plugin_inst.get_plugin_type()] = plugin_inst
LOG.debug(_("Successfully loaded %(type)s plugin. "
"Description: %(desc)s"),
{"type": plugin_inst.get_plugin_type(),
"desc": plugin_inst.get_plugin_description()})
@classmethod
def get_plugin(cls):
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance.plugin
return cls._instance
@classmethod
def get_plugin(cls):
return cls.get_instance().plugin
@classmethod
def get_service_plugins(cls):
return cls.get_instance().service_plugins

View File

@ -0,0 +1,16 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.

View File

@ -0,0 +1,16 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.

View File

@ -0,0 +1,26 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.
# service type constants:
CORE = "CORE"
DUMMY = "DUMMY"
COMMON_PREFIXES = {
CORE: "",
DUMMY: "/dummy_svc",
}

View File

@ -0,0 +1,16 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.

View File

@ -0,0 +1,16 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.

View File

@ -0,0 +1,32 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.
from quantum.plugins.common import constants
from quantum.plugins.services.service_base import ServicePluginBase
class QuantumDummyPlugin(ServicePluginBase):
supported_extension_aliases = []
def __init__(self):
pass
def get_plugin_type(self):
return constants.DUMMY
def get_plugin_description(self):
return "Quantum Dummy Plugin"

View File

@ -0,0 +1,35 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack 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.
import abc
class ServicePluginBase(object):
""" defines base interface for any Advanced Service plugin """
__metaclass__ = abc.ABCMeta
supported_extension_aliases = []
@abc.abstractmethod
def get_plugin_type(self):
""" returns one of predefine service types. see
quantum/plugins/common/constants.py """
pass
@abc.abstractmethod
def get_plugin_description(self):
""" returns string description of the plugin """
pass

View File

@ -33,6 +33,7 @@ from quantum.extensions.extensions import (
PluginAwareExtensionManager,
)
from quantum.openstack.common import jsonutils
from quantum.plugins.common import constants
from quantum.tests.unit import BaseTest
from quantum.tests.unit.extension_stubs import (
ExtensionExpectingPluginInterface,
@ -43,6 +44,7 @@ from quantum.tests.unit.extension_stubs import (
import quantum.tests.unit.extensions
from quantum import wsgi
LOG = logging.getLogger('quantum.tests.test_extensions')
ROOTDIR = os.path.dirname(os.path.dirname(__file__))
@ -93,6 +95,22 @@ class ResourceExtensionTest(unittest.TestCase):
def custom_collection_action(self, request, **kwargs):
return {'collection': 'value'}
class DummySvcPlugin(wsgi.Controller):
def get_plugin_type(self):
return constants.DUMMY
def index(self, request, **kwargs):
return "resource index"
def custom_member_action(self, request, **kwargs):
return {'member_action': 'value'}
def collection_action(self, request, **kwargs):
return {'collection': 'value'}
def show(self, request, id):
return {'data': {'id': id}}
def test_exceptions_notimplemented(self):
controller = self.ResourceExtensionController()
member = {'notimplemented_function': "GET"}
@ -122,6 +140,20 @@ class ResourceExtensionTest(unittest.TestCase):
show_response = test_app.get("/tweedles/25266")
self.assertEqual({'data': {'id': "25266"}}, show_response.json)
def test_resource_gets_prefix_of_plugin(self):
class DummySvcPlugin(wsgi.Controller):
def index(self, request):
return ""
def get_plugin_type(self):
return constants.DUMMY
res_ext = extensions.ResourceExtension(
'tweedles', DummySvcPlugin(), path_prefix="/dummy_svc")
test_app = _setup_extensions_test_app(SimpleExtensionManager(res_ext))
index_response = test_app.get("/dummy_svc/tweedles")
self.assertEqual(200, index_response.status_int)
def test_resource_extension_with_custom_member_action(self):
controller = self.ResourceExtensionController()
member = {'custom_member_action': "GET"}
@ -134,6 +166,53 @@ class ResourceExtensionTest(unittest.TestCase):
self.assertEqual(jsonutils.loads(response.body)['member_action'],
"value")
def test_resource_ext_with_custom_member_action_gets_plugin_prefix(self):
controller = self.DummySvcPlugin()
member = {'custom_member_action': "GET"}
collections = {'collection_action': "GET"}
res_ext = extensions.ResourceExtension('tweedles', controller,
path_prefix="/dummy_svc",
member_actions=member,
collection_actions=collections)
test_app = _setup_extensions_test_app(SimpleExtensionManager(res_ext))
response = test_app.get("/dummy_svc/tweedles/1/custom_member_action")
self.assertEqual(200, response.status_int)
self.assertEqual(jsonutils.loads(response.body)['member_action'],
"value")
response = test_app.get("/dummy_svc/tweedles/collection_action")
self.assertEqual(200, response.status_int)
self.assertEqual(jsonutils.loads(response.body)['collection'],
"value")
def test_plugin_prefix_with_parent_resource(self):
controller = self.DummySvcPlugin()
parent = dict(member_name="tenant",
collection_name="tenants")
member = {'custom_member_action': "GET"}
collections = {'collection_action': "GET"}
res_ext = extensions.ResourceExtension('tweedles', controller, parent,
path_prefix="/dummy_svc",
member_actions=member,
collection_actions=collections)
test_app = _setup_extensions_test_app(SimpleExtensionManager(res_ext))
index_response = test_app.get("/dummy_svc/tenants/1/tweedles")
self.assertEqual(200, index_response.status_int)
response = test_app.get("/dummy_svc/tenants/1/"
"tweedles/1/custom_member_action")
self.assertEqual(200, response.status_int)
self.assertEqual(jsonutils.loads(response.body)['member_action'],
"value")
response = test_app.get("/dummy_svc/tenants/2/"
"tweedles/collection_action")
self.assertEqual(200, response.status_int)
self.assertEqual(jsonutils.loads(response.body)['collection'],
"value")
def test_resource_extension_for_get_custom_collection_action(self):
controller = self.ResourceExtensionController()
collections = {'custom_collection_action': "GET"}
@ -143,6 +222,7 @@ class ResourceExtensionTest(unittest.TestCase):
response = test_app.get("/tweedles/custom_collection_action")
self.assertEqual(200, response.status_int)
LOG.debug(jsonutils.loads(response.body))
self.assertEqual(jsonutils.loads(response.body)['collection'], "value")
def test_resource_extension_for_put_custom_collection_action(self):
@ -354,7 +434,8 @@ class PluginAwareExtensionManagerTest(unittest.TestCase):
def test_unsupported_extensions_are_not_loaded(self):
stub_plugin = StubPlugin(supported_extensions=["e1", "e3"])
ext_mgr = PluginAwareExtensionManager('', stub_plugin)
ext_mgr = PluginAwareExtensionManager('',
{constants.CORE: stub_plugin})
ext_mgr.add_extension(StubExtension("e1"))
ext_mgr.add_extension(StubExtension("e2"))
@ -372,21 +453,24 @@ class PluginAwareExtensionManagerTest(unittest.TestCase):
"""
pass
ext_mgr = PluginAwareExtensionManager('', ExtensionUnawarePlugin())
ext_mgr = PluginAwareExtensionManager('',
{constants.CORE:
ExtensionUnawarePlugin()})
ext_mgr.add_extension(StubExtension("e1"))
self.assertFalse("e1" in ext_mgr.extensions)
def test_extensions_not_loaded_for_plugin_without_expected_interface(self):
class PluginWithoutExpectedInterface(object):
class PluginWithoutExpectedIface(object):
"""
Plugin does not implement get_foo method as expected by extension
"""
supported_extension_aliases = ["supported_extension"]
ext_mgr = PluginAwareExtensionManager('',
PluginWithoutExpectedInterface())
{constants.CORE:
PluginWithoutExpectedIface()})
ext_mgr.add_extension(
ExtensionExpectingPluginInterface("supported_extension"))
@ -403,7 +487,8 @@ class PluginAwareExtensionManagerTest(unittest.TestCase):
def get_foo(self, bar=None):
pass
ext_mgr = PluginAwareExtensionManager('',
PluginWithExpectedInterface())
{constants.CORE:
PluginWithExpectedInterface()})
ext_mgr.add_extension(
ExtensionExpectingPluginInterface("supported_extension"))
@ -417,7 +502,8 @@ class PluginAwareExtensionManagerTest(unittest.TestCase):
"""
pass
stub_plugin = StubPlugin(supported_extensions=["e1"])
ext_mgr = PluginAwareExtensionManager('', stub_plugin)
ext_mgr = PluginAwareExtensionManager('', {constants.CORE:
stub_plugin})
ext_mgr.add_extension(ExtensionForQuamtumPluginInterface("e1"))
self.assertTrue("e1" in ext_mgr.extensions)
@ -432,11 +518,24 @@ class PluginAwareExtensionManagerTest(unittest.TestCase):
return None
stub_plugin = StubPlugin(supported_extensions=["e1"])
ext_mgr = PluginAwareExtensionManager('', stub_plugin)
ext_mgr = PluginAwareExtensionManager('', {constants.CORE:
stub_plugin})
ext_mgr.add_extension(ExtensionWithNoNeedForPluginInterface("e1"))
self.assertTrue("e1" in ext_mgr.extensions)
def test_extension_loaded_for_non_core_plugin(self):
class NonCorePluginExtenstion(StubExtension):
def get_plugin_interface(self):
return None
stub_plugin = StubPlugin(supported_extensions=["e1"])
ext_mgr = PluginAwareExtensionManager('', {constants.DUMMY:
stub_plugin})
ext_mgr.add_extension(NonCorePluginExtenstion("e1"))
self.assertTrue("e1" in ext_mgr.extensions)
class ExtensionControllerTest(unittest.TestCase):
@ -483,7 +582,7 @@ def setup_extensions_middleware(extension_manager=None):
extension_manager = (extension_manager or
PluginAwareExtensionManager(
extensions_path,
FakePluginWithExtension()))
{constants.CORE: FakePluginWithExtension()}))
config_file = 'quantum.conf.test'
args = ['--config-file', etcdir(config_file)]
config.parse(args=args)

View File

@ -0,0 +1,71 @@
# Copyright (c) 2012 OpenStack, LLC.
#
# 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 logging
import types
import unittest2
from quantum.common import config
from quantum.common.test_lib import test_config
from quantum.manager import QuantumManager
from quantum.openstack.common import cfg
from quantum.plugins.common import constants
from quantum.plugins.services.dummy.dummy_plugin import QuantumDummyPlugin
LOG = logging.getLogger(__name__)
DB_PLUGIN_KLASS = 'quantum.db.db_base_plugin_v2.QuantumDbPluginV2'
class QuantumManagerTestCase(unittest2.TestCase):
def setUp(self):
super(QuantumManagerTestCase, self).setUp()
def tearDown(self):
unittest2.TestCase.tearDown(self)
cfg.CONF.reset()
QuantumManager._instance = None
def test_service_plugin_is_loaded(self):
cfg.CONF.set_override("core_plugin",
test_config.get('plugin_name_v2',
DB_PLUGIN_KLASS))
cfg.CONF.set_override("service_plugins",
["quantum.plugins.services."
"dummy.dummy_plugin.QuantumDummyPlugin"])
QuantumManager._instance = None
mgr = QuantumManager.get_instance()
plugin = mgr.get_service_plugins()[constants.DUMMY]
self.assertTrue(
isinstance(plugin,
(QuantumDummyPlugin, types.ClassType)),
"loaded plugin should be of type QuantumDummyPlugin")
def test_multiple_plugins_specified_for_service_type(self):
cfg.CONF.set_override("service_plugins",
["quantum.plugins.services."
"dummy.dummy_plugin.QuantumDummyPlugin",
"quantum.plugins.services."
"dummy.dummy_plugin.QuantumDummyPlugin"])
QuantumManager._instance = None
try:
QuantumManager.get_instance().get_service_plugins()
self.assertTrue(False,
"Shouldn't load multiple plugins "
"for the same type")
except Exception as e:
LOG.debug(str(e))