From 0c7237efd90e0c1cf44141750e36549559e494c0 Mon Sep 17 00:00:00 2001 From: Chris Yeoh Date: Tue, 23 Apr 2013 02:15:27 +0930 Subject: API extensions framework for v3 API This is the initial patch for the new extension framework to be used by the Nova v3 API. It will only be used by v3 API extensions and not v2 API extensions. v3 API extensions will only use this plugin framework and will not be compatible with the old one. - Sets up a /v3 url prefix - Looks in an entry point namespace of nova.api.extensions - The fixed_ips extensions is ported to /v3 as an example of a resource extensions. Required changes are very minor. - All extensions must derive from the V3APIExtensionBase class - Drops updated field from extensions, replaced with version field - Ports tests for fixed_ips extension - None of the core has been ported in this patch, though the example extension works without it. The intent is to port the core code over as plugins as well. There will still be a conceptual core however I don't think we need a separate directory for core. This is the first of a series of patches to add support for the new extension framework. Future direction including support for controller extensions, removal of extension code in core code etc can be seen here: https://github.com/cyeoh/nova/tree/v3_api_extension_framework Partially implements blueprint v3-api-extension-framework Change-Id: I88aa6353ad1d74cac51abbb6aac7274b1567485a --- etc/nova/api-paste.ini | 10 ++ nova/api/openstack/__init__.py | 110 ++++++++++++ nova/api/openstack/compute/__init__.py | 12 ++ nova/api/openstack/compute/plugins/__init__.py | 0 nova/api/openstack/compute/plugins/v3/__init__.py | 0 nova/api/openstack/compute/plugins/v3/fixed_ips.py | 98 +++++++++++ nova/api/openstack/extensions.py | 50 ++++++ .../api/openstack/compute/plugins/__init__.py | 0 .../api/openstack/compute/plugins/v3/__init__.py | 0 .../openstack/compute/plugins/v3/test_fixed_ips.py | 188 +++++++++++++++++++++ setup.cfg | 3 + 11 files changed, 471 insertions(+) create mode 100644 nova/api/openstack/compute/plugins/__init__.py create mode 100644 nova/api/openstack/compute/plugins/v3/__init__.py create mode 100644 nova/api/openstack/compute/plugins/v3/fixed_ips.py create mode 100644 nova/tests/api/openstack/compute/plugins/__init__.py create mode 100644 nova/tests/api/openstack/compute/plugins/v3/__init__.py create mode 100644 nova/tests/api/openstack/compute/plugins/v3/test_fixed_ips.py diff --git a/etc/nova/api-paste.ini b/etc/nova/api-paste.ini index 34c87b92d..1bd26143f 100644 --- a/etc/nova/api-paste.ini +++ b/etc/nova/api-paste.ini @@ -61,6 +61,7 @@ use = call:nova.api.openstack.urlmap:urlmap_factory /: oscomputeversions /v1.1: openstack_compute_api_v2 /v2: openstack_compute_api_v2 +/v3: openstack_compute_api_v3 [composite:openstack_compute_api_v2] use = call:nova.api.auth:pipeline_factory @@ -68,6 +69,12 @@ noauth = faultwrap sizelimit noauth ratelimit osapi_compute_app_v2 keystone = faultwrap sizelimit authtoken keystonecontext ratelimit osapi_compute_app_v2 keystone_nolimit = faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v2 +[composite:openstack_compute_api_v3] +use = call:nova.api.auth:pipeline_factory +noauth = faultwrap sizelimit noauth ratelimit osapi_compute_app_v3 +keystone = faultwrap sizelimit authtoken keystonecontext ratelimit osapi_compute_app_v3 +keystone_nolimit = faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v3 + [filter:faultwrap] paste.filter_factory = nova.api.openstack:FaultWrapper.factory @@ -83,6 +90,9 @@ paste.filter_factory = nova.api.sizelimit:RequestBodySizeLimiter.factory [app:osapi_compute_app_v2] paste.app_factory = nova.api.openstack.compute:APIRouter.factory +[app:osapi_compute_app_v3] +paste.app_factory = nova.api.openstack.compute:APIRouterV3.factory + [pipeline:oscomputeversions] pipeline = faultwrap oscomputeversionapp diff --git a/nova/api/openstack/__init__.py b/nova/api/openstack/__init__.py index a76b74324..cc276234b 100644 --- a/nova/api/openstack/__init__.py +++ b/nova/api/openstack/__init__.py @@ -21,9 +21,11 @@ WSGI middleware for OpenStack API controllers. """ import routes +import stevedore import webob.dec import webob.exc +from nova.api.openstack import extensions from nova.api.openstack import wsgi from nova import notifications from nova.openstack.common import log as logging @@ -191,3 +193,111 @@ class APIRouter(base_wsgi.Router): def _setup_routes(self, mapper, ext_mgr, init_only): raise NotImplementedError() + + +class APIRouterV3(base_wsgi.Router): + """ + Routes requests on the OpenStack v3 API to the appropriate controller + and method. + """ + + API_EXTENSION_NAMESPACE = 'nova.api.v3.extensions' + + @classmethod + def factory(cls, global_config, **local_config): + """Simple paste factory, :class:`nova.wsgi.Router` doesn't have one.""" + return cls() + + def __init__(self): + # TODO(cyeoh): bp v3-api-extension-framework. Currently load + # all extensions but eventually should be able to exclude + # based on a config file + def _check_load_extension(ext): + return isinstance(ext.obj, extensions.V3APIExtensionBase) + + self.api_extension_manager = stevedore.enabled.EnabledExtensionManager( + namespace=self.API_EXTENSION_NAMESPACE, + check_func=_check_load_extension, + invoke_on_load=True) + + mapper = ProjectMapper() + self.resources = {} + + # NOTE(cyeoh) Core API support is rewritten as extensions + # but conceptually still have core + if list(self.api_extension_manager): + # NOTE(cyeoh): Stevedore raises an exception if there are + # no plugins detected. I wonder if this is a bug. + self.api_extension_manager.map(self._register_extensions) + self.api_extension_manager.map(self._register_resources, + mapper=mapper) + self.api_extension_manager.map(self._register_controllers) + + super(APIRouterV3, self).__init__(mapper) + + def _register_extensions(self, ext): + raise NotImplementedError() + + def _register_resources(self, ext, mapper): + """Register resources defined by the extensions + + Extensions define what resources they want to add through a + get_resources function + """ + + handler = ext.obj + LOG.debug("Running _register_resources on %s", ext.obj) + + for resource in handler.get_resources(): + LOG.debug(_('Extended resource: %s'), resource.collection) + + inherits = None + if resource.inherits: + inherits = self.resources.get(resource.inherits) + if not resource.controller: + resource.controller = inherits.controller + wsgi_resource = wsgi.Resource(resource.controller, + inherits=inherits) + self.resources[resource.collection] = wsgi_resource + kargs = dict( + controller=wsgi_resource, + collection=resource.collection_actions, + member=resource.member_actions) + + if resource.parent: + kargs['parent_resource'] = resource.parent + + mapper.resource(resource.collection, resource.collection, + **kargs) + + if resource.custom_routes_fn: + resource.custom_routes_fn(mapper, wsgi_resource) + + def _register_controllers(self, ext): + """Register controllers defined by the extensions + + Extensions define what resources they want to add through + a get_controller_extensions function + """ + + handler = ext.obj + LOG.debug("Running _register_controllers on %s", ext.obj) + + for extension in handler.get_controller_extensions(): + ext_name = extension.extension.name + collection = extension.collection + controller = extension.controller + + if collection not in self.resources: + LOG.warning(_('Extension %(ext_name)s: Cannot extend ' + 'resource %(collection)s: No such resource'), + {'ext_name': ext_name, 'collection': collection}) + continue + + LOG.debug(_('Extension %(ext_name)s extending resource: ' + '%(collection)s'), + {'ext_name': ext_name, 'collection': collection}) + + resource = self.resources[collection] + resource.register_actions(controller) + resource.register_extensions(controller) diff --git a/nova/api/openstack/compute/__init__.py b/nova/api/openstack/compute/__init__.py index a46d51eaf..80247705f 100644 --- a/nova/api/openstack/compute/__init__.py +++ b/nova/api/openstack/compute/__init__.py @@ -128,3 +128,15 @@ class APIRouter(nova.api.openstack.APIRouter): controller=server_metadata_controller, action='update_all', conditions={"method": ['PUT']}) + + +class APIRouterV3(nova.api.openstack.APIRouterV3): + """ + Routes requests on the OpenStack API to the appropriate controller + and method. + """ + + def _register_extensions(self, ext): + pass + # TODO(cyeoh): bp v3-api-extension-framework - Register extension + # information diff --git a/nova/api/openstack/compute/plugins/__init__.py b/nova/api/openstack/compute/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nova/api/openstack/compute/plugins/v3/__init__.py b/nova/api/openstack/compute/plugins/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nova/api/openstack/compute/plugins/v3/fixed_ips.py b/nova/api/openstack/compute/plugins/v3/fixed_ips.py new file mode 100644 index 000000000..dc22a83ea --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/fixed_ips.py @@ -0,0 +1,98 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012, 2013 IBM Corp. +# +# 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 webob.exc + +from nova.api.openstack import extensions +from nova import db +from nova import exception +from nova.openstack.common import log as logging + +LOG = logging.getLogger(__name__) +authorize = extensions.extension_authorizer('compute', 'fixed_ips') + + +class FixedIPController(object): + def show(self, req, id): + """Return data about the given fixed ip.""" + context = req.environ['nova.context'] + authorize(context) + + try: + fixed_ip = db.fixed_ip_get_by_address_detailed(context, id) + except exception.FixedIpNotFoundForAddress as ex: + raise webob.exc.HTTPNotFound(explanation=ex.format_message()) + + fixed_ip_info = {"fixed_ip": {}} + if not fixed_ip[1]: + msg = _("Fixed IP %s has been deleted") % id + raise webob.exc.HTTPNotFound(explanation=msg) + + fixed_ip_info['fixed_ip']['cidr'] = fixed_ip[1]['cidr'] + fixed_ip_info['fixed_ip']['address'] = fixed_ip[0]['address'] + + if fixed_ip[2]: + fixed_ip_info['fixed_ip']['hostname'] = fixed_ip[2]['hostname'] + fixed_ip_info['fixed_ip']['host'] = fixed_ip[2]['host'] + else: + fixed_ip_info['fixed_ip']['hostname'] = None + fixed_ip_info['fixed_ip']['host'] = None + + return fixed_ip_info + + def action(self, req, id, body): + context = req.environ['nova.context'] + authorize(context) + if 'reserve' in body: + LOG.debug(_("Reserving IP address %s") % id) + return self._set_reserved(context, id, True) + elif 'unreserve' in body: + LOG.debug(_("Unreserving IP address %s") % id) + return self._set_reserved(context, id, False) + else: + raise webob.exc.HTTPBadRequest( + explanation="No valid action specified") + + def _set_reserved(self, context, address, reserved): + try: + fixed_ip = db.fixed_ip_get_by_address(context, address) + db.fixed_ip_update(context, fixed_ip['address'], + {'reserved': reserved}) + except exception.FixedIpNotFoundForAddress: + msg = _("Fixed IP %s not found") % address + raise webob.exc.HTTPNotFound(explanation=msg) + + return webob.exc.HTTPAccepted() + + +class FixedIPs(extensions.V3APIExtensionBase): + """Fixed IPs support.""" + + name = "FixedIPs" + alias = "os-fixed-ips" + namespace = "http://docs.openstack.org/compute/ext/fixed_ips/api/v3" + version = 1 + + def get_resources(self): + member_actions = {'action': 'POST'} + resources = [ + extensions.ResourceExtension('os-fixed-ips', + FixedIPController(), + member_actions=member_actions)] + return resources + + def get_controller_extensions(self): + return [] diff --git a/nova/api/openstack/extensions.py b/nova/api/openstack/extensions.py index e7c806388..dcf6149e5 100644 --- a/nova/api/openstack/extensions.py +++ b/nova/api/openstack/extensions.py @@ -16,6 +16,7 @@ # License for the specific language governing permissions and limitations # under the License. +import abc import os import webob.dec @@ -396,3 +397,52 @@ def soft_extension_authorizer(api_name, extension_name): except exception.NotAuthorized: return False return authorize + + +class V3APIExtensionBase(object): + """Abstract base class for all V3 API extensions. + + All V3 API extensions must derive from this class and implement + the abstract methods get_resources and get_controller_extensions + even if they just return an empty list. The extensions must also + define the abstract properties. + """ + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def get_resources(self): + """Return a list of resources extensions. + + The extensions should return a list of ResourceExtension + objects. This list may be empty. + """ + pass + + @abc.abstractmethod + def get_controller_extensions(self): + """Return a list of controller extensions. + + The extensions should return a list of ControllerExtension + objects. This list may be empty. + """ + pass + + @abc.abstractproperty + def name(self): + """Name of the extension.""" + pass + + @abc.abstractproperty + def alias(self): + """Alias for the extension.""" + pass + + @abc.abstractproperty + def namespace(self): + """Namespace for the extension.""" + pass + + @abc.abstractproperty + def version(self): + """Version of the extension.""" + pass diff --git a/nova/tests/api/openstack/compute/plugins/__init__.py b/nova/tests/api/openstack/compute/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nova/tests/api/openstack/compute/plugins/v3/__init__.py b/nova/tests/api/openstack/compute/plugins/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nova/tests/api/openstack/compute/plugins/v3/test_fixed_ips.py b/nova/tests/api/openstack/compute/plugins/v3/test_fixed_ips.py new file mode 100644 index 000000000..2a1387d15 --- /dev/null +++ b/nova/tests/api/openstack/compute/plugins/v3/test_fixed_ips.py @@ -0,0 +1,188 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 IBM Corp. +# +# 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 webob + +from nova.api.openstack.compute.plugins.v3 import fixed_ips +from nova import context +from nova import db +from nova import exception +from nova import test +from nova.tests.api.openstack import fakes + + +fake_fixed_ips = [{'id': 1, + 'address': '192.168.1.1', + 'network_id': 1, + 'virtual_interface_id': 1, + 'instance_uuid': '1', + 'allocated': False, + 'leased': False, + 'reserved': False, + 'host': None, + 'deleted': False}, + {'id': 2, + 'address': '192.168.1.2', + 'network_id': 1, + 'virtual_interface_id': 2, + 'instance_uuid': '2', + 'allocated': False, + 'leased': False, + 'reserved': False, + 'host': None, + 'deleted': False}, + {'id': 3, + 'address': '10.0.0.2', + 'network_id': 1, + 'virtual_interface_id': 3, + 'instance_uuid': '3', + 'allocated': False, + 'leased': False, + 'reserved': False, + 'host': None, + 'deleted': True}, + ] + + +def fake_fixed_ip_get_by_address(context, address): + for fixed_ip in fake_fixed_ips: + if fixed_ip['address'] == address and not fixed_ip['deleted']: + return fixed_ip + raise exception.FixedIpNotFoundForAddress(address=address) + + +def fake_fixed_ip_get_by_address_detailed(context, address): + network = {'id': 1, + 'cidr': "192.168.1.0/24"} + for fixed_ip in fake_fixed_ips: + if fixed_ip['address'] == address and not fixed_ip['deleted']: + return (fixed_ip, FakeModel(network), None) + raise exception.FixedIpNotFoundForAddress(address=address) + + +def fake_fixed_ip_update(context, address, values): + fixed_ip = fake_fixed_ip_get_by_address(context, address) + if fixed_ip is None: + raise exception.FixedIpNotFoundForAddress(address=address) + else: + for key in values: + fixed_ip[key] = values[key] + + +class FakeModel(object): + """Stubs out for model.""" + def __init__(self, values): + self.values = values + + def __getattr__(self, name): + return self.values[name] + + def __getitem__(self, key): + if key in self.values: + return self.values[key] + else: + raise NotImplementedError() + + def __repr__(self): + return '' % self.values + + +def fake_network_get_all(context): + network = {'id': 1, + 'cidr': "192.168.1.0/24"} + return [FakeModel(network)] + + +class FixedIpTest(test.TestCase): + + def setUp(self): + super(FixedIpTest, self).setUp() + + self.stubs.Set(db, "fixed_ip_get_by_address", + fake_fixed_ip_get_by_address) + self.stubs.Set(db, "fixed_ip_get_by_address_detailed", + fake_fixed_ip_get_by_address_detailed) + self.stubs.Set(db, "fixed_ip_update", fake_fixed_ip_update) + + self.context = context.get_admin_context() + self.controller = fixed_ips.FixedIPController() + + def test_fixed_ips_get(self): + req = fakes.HTTPRequest.blank('/v3/fake/os-fixed-ips/192.168.1.1') + res_dict = self.controller.show(req, '192.168.1.1') + response = {'fixed_ip': {'cidr': '192.168.1.0/24', + 'hostname': None, + 'host': None, + 'address': '192.168.1.1'}} + self.assertEqual(response, res_dict) + + def test_fixed_ips_get_bad_ip_fail(self): + req = fakes.HTTPRequest.blank('/v3/fake/os-fixed-ips/10.0.0.1') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.show, req, + '10.0.0.1') + + def test_fixed_ips_get_deleted_ip_fail(self): + req = fakes.HTTPRequest.blank('/v3/fake/os-fixed-ips/10.0.0.2') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.show, req, + '10.0.0.2') + + def test_fixed_ip_reserve(self): + fake_fixed_ips[0]['reserved'] = False + body = {'reserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/192.168.1.1/action') + result = self.controller.action(req, "192.168.1.1", body) + + self.assertEqual('202 Accepted', result.status) + self.assertEqual(fake_fixed_ips[0]['reserved'], True) + + def test_fixed_ip_reserve_bad_ip(self): + body = {'reserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/10.0.0.1/action') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.action, req, + '10.0.0.1', body) + + def test_fixed_ip_reserve_deleted_ip(self): + body = {'reserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/10.0.0.2/action') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.action, req, + '10.0.0.2', body) + + def test_fixed_ip_unreserve(self): + fake_fixed_ips[0]['reserved'] = True + body = {'unreserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/192.168.1.1/action') + result = self.controller.action(req, "192.168.1.1", body) + + self.assertEqual('202 Accepted', result.status) + self.assertEqual(fake_fixed_ips[0]['reserved'], False) + + def test_fixed_ip_unreserve_bad_ip(self): + body = {'unreserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/10.0.0.1/action') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.action, req, + '10.0.0.1', body) + + def test_fixed_ip_unreserve_deleted_ip(self): + body = {'unreserve': None} + req = fakes.HTTPRequest.blank( + '/v3/fake/os-fixed-ips/10.0.0.2/action') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.action, req, + '10.0.0.2', body) diff --git a/setup.cfg b/setup.cfg index aa9371ecb..54127dcfb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,9 @@ console_scripts = nova-spicehtml5proxy = nova.cmd.spicehtml5proxy:main nova-xvpvncproxy = nova.cmd.xvpvncproxy:main +nova.api.v3.extensions = + fixed_ips = nova.api.openstack.compute.plugins.v3.fixed_ips:FixedIPs + [build_sphinx] all_files = 1 build-dir = doc/build -- cgit