]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Pecan: fix quota management
authorSalvatore Orlando <salv.orlando@gmail.com>
Fri, 18 Sep 2015 21:10:26 +0000 (14:10 -0700)
committerSalvatore Orlando <salv.orlando@gmail.com>
Wed, 13 Jan 2016 17:10:25 +0000 (09:10 -0800)
This patch fixes quota management APIs in the Pecan framework.
To this aim:

1) an ad-hoc pair of collection/item controllers are introduced
   for the quota resource; as the new controllers have been added
   in a separate module, the neutron.pecan_wsgi.controllers.utils
   module has been added as well for helpers, routines and classes
   used by all pecan controllers;
2) the quota API extension is made pecan-aware, meaning that it
   simply returns a Pecan controller instance rather than deferring
   the task to the startup process that builds controllers using the
   home-grown WSGI framework ext manager;
3) the quota resource is now "almost" a standard neutron resource;
   unfortunately since it does not yet have its own service plugin a
   special provision is made in the attribute population hook in
   order to ensure the object is loaded for allowing correct
   policy enforcement.
4) Functional tests for the quota controller have been added.

Closes-Bug: #1505843

Change-Id: I44a1fd73f678e493d5b1163e5f183d9efdc678ac

neutron/extensions/quotasv2.py
neutron/pecan_wsgi/controllers/__init__.py
neutron/pecan_wsgi/controllers/quota.py [new file with mode: 0644]
neutron/pecan_wsgi/controllers/root.py
neutron/pecan_wsgi/controllers/utils.py [new file with mode: 0644]
neutron/pecan_wsgi/hooks/policy_enforcement.py
neutron/pecan_wsgi/startup.py
neutron/tests/functional/pecan_wsgi/test_functional.py

index 00ce987f8845ced5524f04df688e364259d7be26..fb0abb4e90d8b9bd7d187ec38e00146208097246 100644 (file)
@@ -25,11 +25,11 @@ from neutron.api.v2 import resource
 from neutron.common import constants as const
 from neutron.common import exceptions as n_exc
 from neutron import manager
+from neutron.pecan_wsgi import controllers
 from neutron import quota
 from neutron.quota import resource_registry
 from neutron import wsgi
 
-
 RESOURCE_NAME = 'quota'
 RESOURCE_COLLECTION = RESOURCE_NAME + "s"
 QUOTAS = quota.QUOTAS
@@ -145,6 +145,10 @@ class Quotasv2(extensions.ExtensionDescriptor):
             controller,
             collection_actions={'tenant': 'GET'})]
 
+    @classmethod
+    def get_pecan_controllers(cls):
+        return ((RESOURCE_COLLECTION, controllers.QuotasController()), )
+
     def get_extended_resources(self, version):
         if version == "2.0":
             return EXTENDED_ATTRIBUTES_2_0
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a25c1ad67c6b1e7b4f155d7317062b55fad08fc5 100644 (file)
@@ -0,0 +1,16 @@
+#    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 neutron.pecan_wsgi.controllers import quota
+
+
+QuotasController = quota.QuotasController
diff --git a/neutron/pecan_wsgi/controllers/quota.py b/neutron/pecan_wsgi/controllers/quota.py
new file mode 100644 (file)
index 0000000..d773102
--- /dev/null
@@ -0,0 +1,128 @@
+# Copyright (c) 2015 Taturiello Consulting, Meh.
+# 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 oslo_config import cfg
+from oslo_log import log
+from oslo_utils import importutils
+from pecan import request
+from pecan import response
+
+from neutron.api.v2 import attributes
+from neutron.common import constants
+from neutron.common import exceptions as n_exc
+from neutron.pecan_wsgi.controllers import utils
+from neutron.quota import resource_registry
+
+LOG = log.getLogger(__name__)
+RESOURCE_NAME = "quota"
+
+
+class QuotasController(utils.NeutronPecanController):
+
+    def __init__(self):
+        self._driver = importutils.import_class(
+            cfg.CONF.QUOTAS.quota_driver
+        )
+        super(QuotasController, self).__init__(
+            "%ss" % RESOURCE_NAME, RESOURCE_NAME)
+
+    def _check_admin(self, context,
+                     reason=_("Only admin can view or configure quota")):
+        if not context.is_admin:
+            raise n_exc.AdminRequired(reason=reason)
+
+    @utils.expose()
+    def _lookup(self, tenant_id, *remainder):
+        return QuotaController(self._driver, tenant_id), remainder
+
+    @utils.expose()
+    def index(self):
+        neutron_context = request.context.get('neutron_context')
+        # FIXME(salv-orlando): There shouldn't be any need to to this eplicit
+        # check. However some behaviours from the "old" extension have
+        # been temporarily carried over here
+        self._check_admin(neutron_context)
+        # TODO(salv-orlando): proper plurals management
+        return {self.collection:
+                self._driver.get_all_quotas(
+                    neutron_context,
+                    resource_registry.get_all_resources())}
+
+
+class QuotaController(utils.NeutronPecanController):
+
+    def __init__(self, _driver, tenant_id):
+        self._driver = _driver
+        self._tenant_id = tenant_id
+
+        super(QuotaController, self).__init__(
+            "%ss" % RESOURCE_NAME, RESOURCE_NAME)
+
+        # Ensure limits for all registered resources are returned
+        attr_dict = attributes.RESOURCE_ATTRIBUTE_MAP[self.collection]
+        for quota_resource in resource_registry.get_all_resources().keys():
+            attr_dict[quota_resource] = {
+                'allow_post': False,
+                'allow_put': True,
+                'convert_to': attributes.convert_to_int,
+                'validate': {
+                    'type:range': [-1, constants.DB_INTEGER_MAX_VALUE]},
+                'is_visible': True}
+
+    @utils.expose(generic=True)
+    def index(self):
+        return get_tenant_quotas(self._tenant_id, self._driver)
+
+    @utils.when(index, method='PUT')
+    def put(self, *args, **kwargs):
+        neutron_context = request.context.get('neutron_context')
+        # For put requests there's always going to be a single element
+        quota_data = request.context['resources'][0]
+        for key, value in quota_data.items():
+            self._driver.update_quota_limit(
+                neutron_context, self._tenant_id, key, value)
+        return get_tenant_quotas(self._tenant_id, self._driver)
+
+    @utils.when(index, method='DELETE')
+    def delete(self):
+        neutron_context = request.context.get('neutron_context')
+        self._driver.delete_tenant_quota(neutron_context,
+                                         self._tenant_id)
+        response.status = 204
+
+
+def get_tenant_quotas(tenant_id, driver=None):
+    if not driver:
+        driver = importutils.import_class(cfg.CONF.QUOTAS.quota_driver)
+
+    neutron_context = request.context.get('neutron_context')
+    if tenant_id == 'tenant':
+        # NOTE(salv-orlando): Read the following before the code in order
+        # to avoid puking.
+        # There is a weird undocumented behaviour of the Neutron quota API
+        # as 'tenant' is used as an API action to return the identifier
+        # of the tenant in the request context. This is used exclusively
+        # for interaction with python-neutronclient and is a possibly
+        # unnecessary 'whoami' API endpoint. Pending resolution of this
+        # API issue, this controller will just treat the magic string
+        # 'tenant' (and only that string) and return the response expected
+        # by python-neutronclient
+        return {'tenant': {'tenant_id': neutron_context.tenant_id}}
+    tenant_quotas = driver.get_tenant_quotas(
+        neutron_context,
+        resource_registry.get_all_resources(),
+        tenant_id)
+    tenant_quotas['tenant_id'] = tenant_id
+    return {RESOURCE_NAME: tenant_quotas}
index 977a7e58a7a213e9ad8457ae4d7b3a9f9f269e26..36cc6dc5adbfd6f3c4faf6c36d21fa2dac62a065 100644 (file)
@@ -22,6 +22,7 @@ from neutron._i18n import _, _LW
 from neutron.api import extensions
 from neutron.api.views import versions as versions_view
 from neutron import manager
+from neutron.pecan_wsgi.controllers import utils
 
 LOG = log.getLogger(__name__)
 _VERSION_INFO = {}
@@ -36,44 +37,30 @@ def _get_version_info():
     return _VERSION_INFO.values()
 
 
-def expose(*args, **kwargs):
-    """Helper function so we don't have to specify json for everything."""
-    kwargs.setdefault('content_type', 'application/json')
-    kwargs.setdefault('template', 'json')
-    return pecan.expose(*args, **kwargs)
-
-
-def when(index, *args, **kwargs):
-    """Helper function so we don't have to specify json for everything."""
-    kwargs.setdefault('content_type', 'application/json')
-    kwargs.setdefault('template', 'json')
-    return index.when(*args, **kwargs)
-
-
 class RootController(object):
 
-    @expose(generic=True)
+    @utils.expose(generic=True)
     def index(self):
         builder = versions_view.get_view_builder(pecan.request)
         versions = [builder.build(version) for version in _get_version_info()]
         return dict(versions=versions)
 
-    @when(index, method='HEAD')
-    @when(index, method='POST')
-    @when(index, method='PATCH')
-    @when(index, method='PUT')
-    @when(index, method='DELETE')
+    @utils.when(index, method='HEAD')
+    @utils.when(index, method='POST')
+    @utils.when(index, method='PATCH')
+    @utils.when(index, method='PUT')
+    @utils.when(index, method='DELETE')
     def not_supported(self):
         pecan.abort(405)
 
 
 class ExtensionsController(object):
 
-    @expose()
+    @utils.expose()
     def _lookup(self, alias, *remainder):
         return ExtensionController(alias), remainder
 
-    @expose()
+    @utils.expose()
     def index(self):
         ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
         exts = [extensions.ExtensionController._translate(ext)
@@ -93,20 +80,20 @@ class V2Controller(object):
 
     extensions = ExtensionsController()
 
-    @expose(generic=True)
+    @utils.expose(generic=True)
     def index(self):
         builder = versions_view.get_view_builder(pecan.request)
         return dict(version=builder.build(self.version_info))
 
-    @when(index, method='HEAD')
-    @when(index, method='POST')
-    @when(index, method='PATCH')
-    @when(index, method='PUT')
-    @when(index, method='DELETE')
+    @utils.when(index, method='HEAD')
+    @utils.when(index, method='POST')
+    @utils.when(index, method='PATCH')
+    @utils.when(index, method='PUT')
+    @utils.when(index, method='DELETE')
     def not_supported(self):
         pecan.abort(405)
 
-    @expose()
+    @utils.expose()
     def _lookup(self, collection, *remainder):
         controller = manager.NeutronManager.get_controller_for_resource(
             collection)
@@ -131,7 +118,7 @@ class ExtensionController(object):
     def __init__(self, alias):
         self.alias = alias
 
-    @expose()
+    @utils.expose()
     def index(self):
         ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
         ext = ext_mgr.extensions.get(self.alias, None)
@@ -142,24 +129,15 @@ class ExtensionController(object):
         return {'extension': extensions.ExtensionController._translate(ext)}
 
 
-class NeutronPecanController(object):
-
-    def __init__(self, collection, resource):
-        self.collection = collection
-        self.resource = resource
-        self.plugin = manager.NeutronManager.get_plugin_for_resource(
-            self.resource)
-
-
-class CollectionsController(NeutronPecanController):
+class CollectionsController(utils.NeutronPecanController):
 
-    @expose()
+    @utils.expose()
     def _lookup(self, item, *remainder):
         # Store resource identifier in request context
         request.context['resource_id'] = item
         return ItemController(self.resource, item), remainder
 
-    @expose(generic=True)
+    @utils.expose(generic=True)
     def index(self, *args, **kwargs):
         return self.get(*args, **kwargs)
 
@@ -175,14 +153,14 @@ class CollectionsController(NeutronPecanController):
         neutron_context = request.context['neutron_context']
         return {self.collection: lister(neutron_context, filters=filters)}
 
-    @when(index, method='HEAD')
-    @when(index, method='PATCH')
-    @when(index, method='PUT')
-    @when(index, method='DELETE')
+    @utils.when(index, method='HEAD')
+    @utils.when(index, method='PATCH')
+    @utils.when(index, method='PUT')
+    @utils.when(index, method='DELETE')
     def not_supported(self):
         pecan.abort(405)
 
-    @when(index, method='POST')
+    @utils.when(index, method='POST')
     def post(self, *args, **kwargs):
         # TODO(kevinbenton): emulated bulk!
         resources = request.context['resources']
@@ -204,13 +182,13 @@ class CollectionsController(NeutronPecanController):
         return {key: creator(neutron_context, data)}
 
 
-class ItemController(NeutronPecanController):
+class ItemController(utils.NeutronPecanController):
 
     def __init__(self, resource, item):
         super(ItemController, self).__init__(None, resource)
         self.item = item
 
-    @expose(generic=True)
+    @utils.expose(generic=True)
     def index(self, *args, **kwargs):
         return self.get()
 
@@ -219,13 +197,13 @@ class ItemController(NeutronPecanController):
         neutron_context = request.context['neutron_context']
         return {self.resource: getter(neutron_context, self.item)}
 
-    @when(index, method='HEAD')
-    @when(index, method='POST')
-    @when(index, method='PATCH')
+    @utils.when(index, method='HEAD')
+    @utils.when(index, method='POST')
+    @utils.when(index, method='PATCH')
     def not_supported(self):
         pecan.abort(405)
 
-    @when(index, method='PUT')
+    @utils.when(index, method='PUT')
     def put(self, *args, **kwargs):
         neutron_context = request.context['neutron_context']
         resources = request.context['resources']
@@ -241,7 +219,7 @@ class ItemController(NeutronPecanController):
         data = {self.resource: resources[0]}
         return updater(neutron_context, self.item, data)
 
-    @when(index, method='DELETE')
+    @utils.when(index, method='DELETE')
     def delete(self):
         # TODO(kevinbenton): setting code could be in a decorator
         pecan.response.status = 204
diff --git a/neutron/pecan_wsgi/controllers/utils.py b/neutron/pecan_wsgi/controllers/utils.py
new file mode 100644 (file)
index 0000000..49bd97e
--- /dev/null
@@ -0,0 +1,43 @@
+# Copyright (c) 2015 Taturiello Consulting, Meh.
+# 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 pecan
+
+from neutron import manager
+
+# Utility functions for Pecan controllers.
+
+
+def expose(*args, **kwargs):
+    """Helper function so we don't have to specify json for everything."""
+    kwargs.setdefault('content_type', 'application/json')
+    kwargs.setdefault('template', 'json')
+    return pecan.expose(*args, **kwargs)
+
+
+def when(index, *args, **kwargs):
+    """Helper function so we don't have to specify json for everything."""
+    kwargs.setdefault('content_type', 'application/json')
+    kwargs.setdefault('template', 'json')
+    return index.when(*args, **kwargs)
+
+
+class NeutronPecanController(object):
+
+    def __init__(self, collection, resource):
+        self.collection = collection
+        self.resource = resource
+        self.plugin = manager.NeutronManager.get_plugin_for_resource(
+            self.resource)
index e505eea482d8ccff8fb1dcb7ce8e8952d06c62f7..dcd1800ab56d9898d1f58f9afcb1b3da20237c9f 100644 (file)
@@ -24,10 +24,18 @@ import webob
 from neutron._i18n import _
 from neutron.api.v2 import attributes as v2_attributes
 from neutron.common import constants as const
+from neutron.extensions import quotasv2
 from neutron import manager
+from neutron.pecan_wsgi.controllers import quota
 from neutron import policy
 
 
+def _custom_getter(resource, resource_id):
+    """Helper function to retrieve resources not served by any plugin."""
+    if resource == quotasv2.RESOURCE_NAME:
+        return quota.get_tenant_quotas(resource_id)[quotasv2.RESOURCE_NAME]
+
+
 class PolicyHook(hooks.PecanHook):
     priority = 135
     ACTION_MAP = {'POST': 'create', 'PUT': 'update', 'GET': 'get',
@@ -39,9 +47,15 @@ class PolicyHook(hooks.PecanHook):
                       if (value.get('required_by_policy') or
                           value.get('primary_key') or 'default' not in value)]
         plugin = manager.NeutronManager.get_plugin_for_resource(resource)
-        getter = getattr(plugin, 'get_%s' % resource)
-        # TODO(kevinbenton): the parent_id logic currently in base.py
-        return getter(neutron_context, resource_id, fields=field_list)
+        if plugin:
+            getter = getattr(plugin, 'get_%s' % resource)
+            # TODO(kevinbenton): the parent_id logic currently in base.py
+            return getter(neutron_context, resource_id, fields=field_list)
+        else:
+            # Some legit resources, like quota, do not have a plugin yet.
+            # Retrieving the original object is nevertheless important
+            # for policy checks.
+            return _custom_getter(resource, resource_id)
 
     def before(self, state):
         # This hook should be run only for PUT,POST and DELETE methods and for
index 4b6dab2f7ad6f57531400b235d7b30aa77cae27e..e4ea30ed84f0ce6a43a9e6830a30057ea2ff745b 100644 (file)
@@ -74,8 +74,8 @@ def initialize_all():
                       hasattr(ext, 'get_pecan_controllers')]
     pecan_controllers = {}
     for ext in pecanized_exts:
-        LOG.debug("Extension %s is pecan-enabled. Fetching resources "
-                  "and controllers", ext.get_name())
+        LOG.info(_LI("Extension %s is pecan-aware. Fetching resources "
+                     "and controllers"), ext.get_name())
         controllers = ext.get_pecan_controllers()
         # controllers is actually a list of pairs where the first element is
         # the collection name and the second the actual controller
@@ -83,24 +83,28 @@ def initialize_all():
             pecan_controllers[collection] = coll_controller
 
     for collection in attributes.RESOURCE_ATTRIBUTE_MAP:
-        if collection not in pecan_controllers:
-            resource = _handle_plurals(collection)
+        resource = _handle_plurals(collection)
+        controller = pecan_controllers.get(collection)
+        if not controller:
             LOG.debug("Building controller for resource:%s", resource)
             plugin = _plugin_for_resource(collection)
             if plugin:
                 manager.NeutronManager.set_plugin_for_resource(
                     resource, plugin)
+            else:
+                LOG.warn(_LW("No plugin found for resource:%s. API calls "
+                             "may not be correctly dispatched"), resource)
             controller = root.CollectionsController(collection, resource)
-            manager.NeutronManager.set_controller_for_resource(
-                collection, controller)
-            LOG.info(_LI("Added controller for resource %(resource)s "
-                         "via URI path segment:%(collection)s"),
-                     {'resource': resource,
-                      'collection': collection})
         else:
             LOG.debug("There are already controllers for resource:%s",
                       resource)
 
+        manager.NeutronManager.set_controller_for_resource(
+            collection, controller)
+        LOG.info(_LI("Added controller for resource %(resource)s "
+                     "via URI path segment:%(collection)s"),
+                 {'resource': resource,
+                  'collection': collection})
     # NOTE(salv-orlando): If you are care about code quality, please read below
     # Hackiness is strong with the piece of code below. It is used for
     # populating resource plurals and registering resources with the quota
index 2c05b20fb0b8fe3ef8b3a2411bede171b9e281dd..ee20ec34473a0bff1cb1adcd37dde371a7aac7da 100644 (file)
@@ -462,3 +462,89 @@ class TestRootController(PecanFunctionalTest):
 
     def test_head(self):
         self._test_method_returns_405('head')
+
+
+class TestQuotasController(TestRootController):
+    """Test quota management API controller."""
+
+    base_url = '/v2.0/quotas'
+    default_expected_limits = {
+        'network': 10,
+        'port': 50,
+        'subnet': 10}
+
+    def _verify_limits(self, response, limits):
+        for resource, limit in limits.items():
+            self.assertEqual(limit, response['quota'][resource])
+
+    def _verify_default_limits(self, response):
+        self._verify_limits(response, self.default_expected_limits)
+
+    def _verify_after_update(self, response, updated_limits):
+        expected_limits = self.default_expected_limits.copy()
+        expected_limits.update(updated_limits)
+        self._verify_limits(response, expected_limits)
+
+    def test_index_admin(self):
+        # NOTE(salv-orlando): The quota controller has an hardcoded check for
+        # admin-ness for this operation, which is supposed to return quotas for
+        # all tenants. Such check is "vestigial" from the home-grown WSGI and
+        # shall be removed
+        response = self.app.get('%s.json' % self.base_url,
+                                headers={'X-Project-Id': 'admin',
+                                         'X-Roles': 'admin'})
+        self.assertEqual(200, response.status_int)
+
+    def test_index(self):
+        response = self.app.get('%s.json' % self.base_url, expect_errors=True)
+        self.assertEqual(403, response.status_int)
+
+    def test_get_admin(self):
+        response = self.app.get('%s/foo.json' % self.base_url,
+                                headers={'X-Project-Id': 'admin',
+                                         'X-Roles': 'admin'})
+        self.assertEqual(200, response.status_int)
+        # As quota limits have not been updated, expect default values
+        json_body = jsonutils.loads(response.body)
+        self._verify_default_limits(json_body)
+
+    def test_get(self):
+        # It is not ok to access another tenant's limits
+        url = '%s/foo.json' % self.base_url
+        response = self.app.get(url, expect_errors=True)
+        self.assertEqual(403, response.status_int)
+        # It is however ok to retrieve your own limits
+        response = self.app.get(url, headers={'X-Project-Id': 'foo'})
+        self.assertEqual(200, response.status_int)
+        json_body = jsonutils.loads(response.body)
+        self._verify_default_limits(json_body)
+
+    def test_put_get_delete(self):
+        # PUT and DELETE actions are in the same test as a meaningful DELETE
+        # test would require a put anyway
+        url = '%s/foo.json' % self.base_url
+        response = self.app.put_json(url,
+                                     params={'quota': {'network': 99}},
+                                     headers={'X-Project-Id': 'admin',
+                                              'X-Roles': 'admin'})
+        self.assertEqual(200, response.status_int)
+        json_body = jsonutils.loads(response.body)
+        self._verify_after_update(json_body, {'network': 99})
+
+        response = self.app.get(url, headers={'X-Project-Id': 'foo'})
+        self.assertEqual(200, response.status_int)
+        json_body = jsonutils.loads(response.body)
+        self._verify_after_update(json_body, {'network': 99})
+
+        response = self.app.delete(url, headers={'X-Project-Id': 'admin',
+                                                 'X-Roles': 'admin'})
+        self.assertEqual(204, response.status_int)
+        # As DELETE does not return a body we need another GET
+        response = self.app.get(url, headers={'X-Project-Id': 'foo'})
+        self.assertEqual(200, response.status_int)
+        json_body = jsonutils.loads(response.body)
+        self._verify_default_limits(json_body)
+
+    def test_delete(self):
+        # TODO(salv-orlando)
+        pass