Implement API extensions for the Openstack API. Based on the Openstack 1.1 API the following types of extensions are supported:

-Top level resources (extension)
-Action extensions (add an extra action to a core nova controller)
-Response extensions (inject data into response from core nova controllers)

To add an extension simply drop an extension file into the configured osapi_extensions_path (which defaults to /var/lib/nova/extensions).

See nova/tests/api/openstack/extensions/foxinsocks.py for an example Extension.
This commit is contained in:
Dan Prince 2011-03-24 22:26:47 +00:00 committed by Tarmac
commit f186c8ecc2
6 changed files with 710 additions and 2 deletions

View File

@ -74,7 +74,7 @@ use = egg:Paste#urlmap
pipeline = faultwrap auth ratelimit osapiapp10
[pipeline:openstackapi11]
pipeline = faultwrap auth ratelimit osapiapp11
pipeline = faultwrap auth ratelimit extensions osapiapp11
[filter:faultwrap]
paste.filter_factory = nova.api.openstack:FaultWrapper.factory
@ -85,6 +85,9 @@ paste.filter_factory = nova.api.openstack.auth:AuthMiddleware.factory
[filter:ratelimit]
paste.filter_factory = nova.api.openstack.limits:RateLimitingMiddleware.factory
[filter:extensions]
paste.filter_factory = nova.api.openstack.extensions:ExtensionMiddleware.factory
[app:osapiapp10]
paste.app_factory = nova.api.openstack:APIRouterV10.factory

View File

@ -71,7 +71,7 @@ class APIRouter(wsgi.Router):
"""Simple paste factory, :class:`nova.wsgi.Router` doesn't have one"""
return cls()
def __init__(self):
def __init__(self, ext_mgr=None):
self.server_members = {}
mapper = routes.Mapper()
self._setup_routes(mapper)

View File

@ -0,0 +1,369 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 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 imp
import os
import sys
import routes
import webob.dec
import webob.exc
from nova import flags
from nova import log as logging
from nova import wsgi
from nova.api.openstack import faults
LOG = logging.getLogger('extensions')
FLAGS = flags.FLAGS
class ActionExtensionController(wsgi.Controller):
def __init__(self, application):
self.application = application
self.action_handlers = {}
def add_action(self, action_name, handler):
self.action_handlers[action_name] = handler
def action(self, req, id):
input_dict = self._deserialize(req.body, req.get_content_type())
for action_name, handler in self.action_handlers.iteritems():
if action_name in input_dict:
return handler(input_dict, req, id)
# no action handler found (bump to downstream application)
res = self.application
return res
class ResponseExtensionController(wsgi.Controller):
def __init__(self, application):
self.application = application
self.handlers = []
def add_handler(self, handler):
self.handlers.append(handler)
def process(self, req, *args, **kwargs):
res = req.get_response(self.application)
content_type = req.best_match_content_type()
# currently response handlers are un-ordered
for handler in self.handlers:
res = handler(res)
try:
body = res.body
headers = res.headers
except AttributeError:
body = self._serialize(res, content_type)
headers = {"Content-Type": content_type}
res = webob.Response()
res.body = body
res.headers = headers
return res
class ExtensionController(wsgi.Controller):
def __init__(self, extension_manager):
self.extension_manager = extension_manager
def _translate(self, ext):
ext_data = {}
ext_data['name'] = ext.get_name()
ext_data['alias'] = ext.get_alias()
ext_data['description'] = ext.get_description()
ext_data['namespace'] = ext.get_namespace()
ext_data['updated'] = ext.get_updated()
ext_data['links'] = [] # TODO: implement extension links
return ext_data
def index(self, req):
extensions = []
for alias, ext in self.extension_manager.extensions.iteritems():
extensions.append(self._translate(ext))
return dict(extensions=extensions)
def show(self, req, id):
# NOTE: the extensions alias is used as the 'id' for show
ext = self.extension_manager.extensions[id]
return self._translate(ext)
def delete(self, req, id):
raise faults.Fault(exc.HTTPNotFound())
def create(self, req):
raise faults.Fault(exc.HTTPNotFound())
def delete(self, req, id):
raise faults.Fault(exc.HTTPNotFound())
class ExtensionMiddleware(wsgi.Middleware):
"""
Extensions middleware that intercepts configured routes for extensions.
"""
@classmethod
def factory(cls, global_config, **local_config):
""" paste factory """
def _factory(app):
return cls(app, **local_config)
return _factory
def _action_ext_controllers(self, application, ext_mgr, mapper):
"""
Return a dict of ActionExtensionController objects by collection
"""
action_controllers = {}
for action in ext_mgr.get_actions():
if not action.collection in action_controllers.keys():
controller = ActionExtensionController(application)
mapper.connect("/%s/:(id)/action.:(format)" %
action.collection,
action='action',
controller=controller,
conditions=dict(method=['POST']))
mapper.connect("/%s/:(id)/action" % action.collection,
action='action',
controller=controller,
conditions=dict(method=['POST']))
action_controllers[action.collection] = controller
return action_controllers
def _response_ext_controllers(self, application, ext_mgr, mapper):
"""
Return a dict of ResponseExtensionController objects by collection
"""
response_ext_controllers = {}
for resp_ext in ext_mgr.get_response_extensions():
if not resp_ext.key in response_ext_controllers.keys():
controller = ResponseExtensionController(application)
mapper.connect(resp_ext.url_route + '.:(format)',
action='process',
controller=controller,
conditions=resp_ext.conditions)
mapper.connect(resp_ext.url_route,
action='process',
controller=controller,
conditions=resp_ext.conditions)
response_ext_controllers[resp_ext.key] = controller
return response_ext_controllers
def __init__(self, application, ext_mgr=None):
if ext_mgr is None:
ext_mgr = ExtensionManager(FLAGS.osapi_extensions_path)
self.ext_mgr = ext_mgr
mapper = routes.Mapper()
# extended resources
for resource in ext_mgr.get_resources():
LOG.debug(_('Extended resource: %s'),
resource.collection)
mapper.resource(resource.collection, resource.collection,
controller=resource.controller,
collection=resource.collection_actions,
member=resource.member_actions,
parent_resource=resource.parent)
# extended actions
action_controllers = self._action_ext_controllers(application, ext_mgr,
mapper)
for action in ext_mgr.get_actions():
LOG.debug(_('Extended action: %s'), action.action_name)
controller = action_controllers[action.collection]
controller.add_action(action.action_name, action.handler)
# extended responses
resp_controllers = self._response_ext_controllers(application, ext_mgr,
mapper)
for response_ext in ext_mgr.get_response_extensions():
LOG.debug(_('Extended response: %s'), response_ext.key)
controller = resp_controllers[response_ext.key]
controller.add_handler(response_ext.handler)
self._router = routes.middleware.RoutesMiddleware(self._dispatch,
mapper)
super(ExtensionMiddleware, self).__init__(application)
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
"""
Route the incoming request with router.
"""
req.environ['extended.app'] = self.application
return self._router
@staticmethod
@webob.dec.wsgify(RequestClass=wsgi.Request)
def _dispatch(req):
"""
Returns the routed WSGI app's response or defers to the extended
application.
"""
match = req.environ['wsgiorg.routing_args'][1]
if not match:
return req.environ['extended.app']
app = match['controller']
return app
class ExtensionManager(object):
"""
Load extensions from the configured extension path.
See nova/tests/api/openstack/extensions/foxinsocks.py for an example
extension implementation.
"""
def __init__(self, path):
LOG.audit(_('Initializing extension manager.'))
self.path = path
self.extensions = {}
self._load_extensions()
def get_resources(self):
"""
returns a list of ResourceExtension objects
"""
resources = []
resources.append(ResourceExtension('extensions',
ExtensionController(self)))
for alias, ext in self.extensions.iteritems():
try:
resources.extend(ext.get_resources())
except AttributeError:
# NOTE: Extension aren't required to have resource extensions
pass
return resources
def get_actions(self):
"""
returns a list of ActionExtension objects
"""
actions = []
for alias, ext in self.extensions.iteritems():
try:
actions.extend(ext.get_actions())
except AttributeError:
# NOTE: Extension aren't required to have action extensions
pass
return actions
def get_response_extensions(self):
"""
returns a list of ResponseExtension objects
"""
response_exts = []
for alias, ext in self.extensions.iteritems():
try:
response_exts.extend(ext.get_response_extensions())
except AttributeError:
# NOTE: Extension aren't required to have response extensions
pass
return response_exts
def _check_extension(self, extension):
"""
Checks for required methods in extension objects.
"""
try:
LOG.debug(_('Ext name: %s'), extension.get_name())
LOG.debug(_('Ext alias: %s'), extension.get_alias())
LOG.debug(_('Ext description: %s'), extension.get_description())
LOG.debug(_('Ext namespace: %s'), extension.get_namespace())
LOG.debug(_('Ext updated: %s'), extension.get_updated())
except AttributeError as ex:
LOG.exception(_("Exception loading extension: %s"), unicode(ex))
def _load_extensions(self):
"""
Load extensions from the configured path. The extension name is
constructed from the module_name. If your extension module was named
widgets.py the extension class within that module should be
'Widgets'.
See nova/tests/api/openstack/extensions/foxinsocks.py for an example
extension implementation.
"""
if not os.path.exists(self.path):
return
for f in os.listdir(self.path):
LOG.audit(_('Loading extension file: %s'), f)
mod_name, file_ext = os.path.splitext(os.path.split(f)[-1])
ext_path = os.path.join(self.path, f)
if file_ext.lower() == '.py':
mod = imp.load_source(mod_name, ext_path)
ext_name = mod_name[0].upper() + mod_name[1:]
try:
new_ext = getattr(mod, ext_name)()
self._check_extension(new_ext)
self.extensions[new_ext.get_alias()] = new_ext
except AttributeError as ex:
LOG.exception(_("Exception loading extension: %s"),
unicode(ex))
class ResponseExtension(object):
"""
ResponseExtension objects can be used to add data to responses from
core nova OpenStack API controllers.
"""
def __init__(self, method, url_route, handler):
self.url_route = url_route
self.handler = handler
self.conditions = dict(method=[method])
self.key = "%s-%s" % (method, url_route)
class ActionExtension(object):
"""
ActionExtension objects can be used to add custom actions to core nova
nova OpenStack API controllers.
"""
def __init__(self, collection, action_name, handler):
self.collection = collection
self.action_name = action_name
self.handler = handler
class ResourceExtension(object):
"""
ResourceExtension objects can be used to add top level resources
to the OpenStack API in nova.
"""
def __init__(self, collection, controller, parent=None,
collection_actions={}, member_actions={}):
self.collection = collection
self.controller = controller
self.parent = parent
self.collection_actions = collection_actions
self.member_actions = member_actions

View File

@ -298,6 +298,8 @@ DEFINE_string('ec2_dmz_host', '$my_ip', 'internal ip of api server')
DEFINE_integer('ec2_port', 8773, 'cloud controller port')
DEFINE_string('ec2_scheme', 'http', 'prefix for ec2')
DEFINE_string('ec2_path', '/services/Cloud', 'suffix for ec2')
DEFINE_string('osapi_extensions_path', '/var/lib/nova/extensions',
'default directory for nova extensions')
DEFINE_string('osapi_host', '$my_ip', 'ip of api server')
DEFINE_string('osapi_scheme', 'http', 'prefix for openstack')
DEFINE_integer('osapi_port', 8774, 'OpenStack API port')

View File

@ -0,0 +1,98 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 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 json
from nova import wsgi
from nova.api.openstack import extensions
class FoxInSocksController(wsgi.Controller):
def index(self, req):
return "Try to say this Mr. Knox, sir..."
class Foxinsocks(object):
def __init__(self):
pass
def get_name(self):
return "Fox In Socks"
def get_alias(self):
return "FOXNSOX"
def get_description(self):
return "The Fox In Socks Extension"
def get_namespace(self):
return "http://www.fox.in.socks/api/ext/pie/v1.0"
def get_updated(self):
return "2011-01-22T13:25:27-06:00"
def get_resources(self):
resources = []
resource = extensions.ResourceExtension('foxnsocks',
FoxInSocksController())
resources.append(resource)
return resources
def get_actions(self):
actions = []
actions.append(extensions.ActionExtension('servers', 'add_tweedle',
self._add_tweedle))
actions.append(extensions.ActionExtension('servers', 'delete_tweedle',
self._delete_tweedle))
return actions
def get_response_extensions(self):
response_exts = []
def _goose_handler(res):
#NOTE: This only handles JSON responses.
# You can use content type header to test for XML.
data = json.loads(res.body)
data['flavor']['googoose'] = "Gooey goo for chewy chewing!"
return data
resp_ext = extensions.ResponseExtension('GET', '/v1.1/flavors/:(id)',
_goose_handler)
response_exts.append(resp_ext)
def _bands_handler(res):
#NOTE: This only handles JSON responses.
# You can use content type header to test for XML.
data = json.loads(res.body)
data['big_bands'] = 'Pig Bands!'
return data
resp_ext2 = extensions.ResponseExtension('GET', '/v1.1/flavors/:(id)',
_bands_handler)
response_exts.append(resp_ext2)
return response_exts
def _add_tweedle(self, input_dict, req, id):
return "Tweedle Beetle Added."
def _delete_tweedle(self, input_dict, req, id):
return "Tweedle Beetle Deleted."

View File

@ -0,0 +1,236 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 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 json
import stubout
import unittest
import webob
import os.path
from nova import context
from nova import flags
from nova.api import openstack
from nova.api.openstack import extensions
from nova.api.openstack import flavors
from nova.tests.api.openstack import fakes
import nova.wsgi
FLAGS = flags.FLAGS
response_body = "Try to say this Mr. Knox, sir..."
class StubController(nova.wsgi.Controller):
def __init__(self, body):
self.body = body
def index(self, req):
return self.body
class StubExtensionManager(object):
def __init__(self, resource_ext=None, action_ext=None, response_ext=None):
self.resource_ext = resource_ext
self.action_ext = action_ext
self.response_ext = response_ext
def get_name(self):
return "Tweedle Beetle Extension"
def get_alias(self):
return "TWDLBETL"
def get_description(self):
return "Provides access to Tweedle Beetles"
def get_resources(self):
resource_exts = []
if self.resource_ext:
resource_exts.append(self.resource_ext)
return resource_exts
def get_actions(self):
action_exts = []
if self.action_ext:
action_exts.append(self.action_ext)
return action_exts
def get_response_extensions(self):
response_exts = []
if self.response_ext:
response_exts.append(self.response_ext)
return response_exts
class ExtensionControllerTest(unittest.TestCase):
def test_index(self):
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app)
request = webob.Request.blank("/extensions")
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
def test_get_by_alias(self):
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app)
request = webob.Request.blank("/extensions/FOXNSOX")
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
class ResourceExtensionTest(unittest.TestCase):
def test_no_extension_present(self):
manager = StubExtensionManager(None)
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app, manager)
request = webob.Request.blank("/blah")
response = request.get_response(ext_midware)
self.assertEqual(404, response.status_int)
def test_get_resources(self):
res_ext = extensions.ResourceExtension('tweedles',
StubController(response_body))
manager = StubExtensionManager(res_ext)
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app, manager)
request = webob.Request.blank("/tweedles")
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
self.assertEqual(response_body, response.body)
def test_get_resources_with_controller(self):
res_ext = extensions.ResourceExtension('tweedles',
StubController(response_body))
manager = StubExtensionManager(res_ext)
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app, manager)
request = webob.Request.blank("/tweedles")
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
self.assertEqual(response_body, response.body)
class ExtensionManagerTest(unittest.TestCase):
response_body = "Try to say this Mr. Knox, sir..."
def setUp(self):
FLAGS.osapi_extensions_path = os.path.join(os.path.dirname(__file__),
"extensions")
def test_get_resources(self):
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app)
request = webob.Request.blank("/foxnsocks")
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
self.assertEqual(response_body, response.body)
class ActionExtensionTest(unittest.TestCase):
def setUp(self):
FLAGS.osapi_extensions_path = os.path.join(os.path.dirname(__file__),
"extensions")
def _send_server_action_request(self, url, body):
app = openstack.APIRouterV11()
ext_midware = extensions.ExtensionMiddleware(app)
request = webob.Request.blank(url)
request.method = 'POST'
request.content_type = 'application/json'
request.body = json.dumps(body)
response = request.get_response(ext_midware)
return response
def test_extended_action(self):
body = dict(add_tweedle=dict(name="test"))
response = self._send_server_action_request("/servers/1/action", body)
self.assertEqual(200, response.status_int)
self.assertEqual("Tweedle Beetle Added.", response.body)
body = dict(delete_tweedle=dict(name="test"))
response = self._send_server_action_request("/servers/1/action", body)
self.assertEqual(200, response.status_int)
self.assertEqual("Tweedle Beetle Deleted.", response.body)
def test_invalid_action_body(self):
body = dict(blah=dict(name="test")) # Doesn't exist
response = self._send_server_action_request("/servers/1/action", body)
self.assertEqual(501, response.status_int)
def test_invalid_action(self):
body = dict(blah=dict(name="test"))
response = self._send_server_action_request("/asdf/1/action", body)
self.assertEqual(404, response.status_int)
class ResponseExtensionTest(unittest.TestCase):
def setUp(self):
super(ResponseExtensionTest, self).setUp()
self.stubs = stubout.StubOutForTesting()
fakes.FakeAuthManager.reset_fake_data()
fakes.FakeAuthDatabase.data = {}
fakes.stub_out_auth(self.stubs)
self.context = context.get_admin_context()
def tearDown(self):
self.stubs.UnsetAll()
super(ResponseExtensionTest, self).tearDown()
def test_get_resources_with_stub_mgr(self):
test_resp = "Gooey goo for chewy chewing!"
def _resp_handler(res):
# only handle JSON responses
data = json.loads(res.body)
data['flavor']['googoose'] = test_resp
return data
resp_ext = extensions.ResponseExtension('GET',
'/v1.1/flavors/:(id)',
_resp_handler)
manager = StubExtensionManager(None, None, resp_ext)
app = fakes.wsgi_app()
ext_midware = extensions.ExtensionMiddleware(app, manager)
request = webob.Request.blank("/v1.1/flavors/1")
request.environ['api.version'] = '1.1'
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
response_data = json.loads(response.body)
self.assertEqual(test_resp, response_data['flavor']['googoose'])
def test_get_resources_with_mgr(self):
test_resp = "Gooey goo for chewy chewing!"
app = fakes.wsgi_app()
ext_midware = extensions.ExtensionMiddleware(app)
request = webob.Request.blank("/v1.1/flavors/1")
request.environ['api.version'] = '1.1'
response = request.get_response(ext_midware)
self.assertEqual(200, response.status_int)
response_data = json.loads(response.body)
self.assertEqual(test_resp, response_data['flavor']['googoose'])
self.assertEqual("Pig Bands!", response_data['big_bands'])