From a8109af65f275ec1b2e725695bf3bb9976f22ae3 Mon Sep 17 00:00:00 2001 From: Sergey Belous Date: Fri, 7 Oct 2016 14:29:07 +0300 Subject: [PATCH] Extend Quota API to report usage statistics Extend existing quota api to report a quota set. The quota set will contain a set of resources and its corresponding reservation, limits and in_use count for each tenant. DocImpact:Documentation describing the new API as well as the new information that it exposes. APIImpact Co-Authored-By: Prince Boateng Change-Id: Ief2a6a4d2d7085e2a9dcd901123bc4fe6ac7ca22 Related-bug: #1599488 --- neutron/db/quota/driver.py | 36 +++++ neutron/extensions/quotasv2.py | 3 + neutron/extensions/quotasv2_detail.py | 99 ++++++++++++ neutron/quota/resource.py | 43 +++-- .../tests/contrib/hooks/api_all_extensions | 1 + .../tests/tempest/api/admin/test_quotas.py | 52 ++++++ neutron/tests/tempest/api/base.py | 5 +- .../services/network/json/network_client.py | 8 +- neutron/tests/unit/api/v2/test_base.py | 6 +- neutron/tests/unit/db/quota/test_driver.py | 81 +++++++++- .../unit/extensions/test_quotasv2_detail.py | 153 ++++++++++++++++++ neutron/tests/unit/quota/test_resource.py | 71 ++++++++ .../extend-quota-api-2df3b84309664234.yaml | 6 + 13 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 neutron/extensions/quotasv2_detail.py create mode 100644 neutron/tests/unit/extensions/test_quotasv2_detail.py create mode 100644 releasenotes/notes/extend-quota-api-2df3b84309664234.yaml diff --git a/neutron/db/quota/driver.py b/neutron/db/quota/driver.py index 3ec4c9210a5..d5e962b99af 100644 --- a/neutron/db/quota/driver.py +++ b/neutron/db/quota/driver.py @@ -15,12 +15,15 @@ from neutron_lib.api import attributes from neutron_lib import exceptions +from neutron_lib.plugins import constants +from neutron_lib.plugins import directory from oslo_log import log from neutron.common import exceptions as n_exc from neutron.db import api as db_api from neutron.db.quota import api as quota_api from neutron.objects import quota as quota_obj +from neutron.quota import resource as res LOG = log.getLogger(__name__) @@ -72,6 +75,39 @@ class DbQuotaDriver(object): return tenant_quota + @staticmethod + @db_api.retry_if_session_inactive() + def get_detailed_tenant_quotas(context, resources, tenant_id): + """Given a list of resources and a sepecific tenant, retrieve + the detailed quotas (limit, used, reserved). + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resource keys. + :return dict: mapping resource name in dict to its correponding limit + used and reserved. Reserved currently returns default value of 0 + """ + res_reserve_info = quota_api.get_reservations_for_resources( + context, tenant_id, resources.keys()) + tenant_quota_ext = {} + for key, resource in resources.items(): + if isinstance(resource, res.TrackedResource): + used = resource.count_used(context, tenant_id, + resync_usage=False) + else: + plugins = directory.get_plugins() + plugin = plugins.get(key, plugins[constants.CORE]) + used = resource.count(context, plugin, tenant_id) + + tenant_quota_ext[key] = { + 'limit': resource.default, + 'used': used, + 'reserved': res_reserve_info.get(key, 0), + } + #update with specific tenant limits + quota_objs = quota_obj.Quota.get_objects(context, project_id=tenant_id) + for item in quota_objs: + tenant_quota_ext[item['resource']]['limit'] = item['limit'] + return tenant_quota_ext + @staticmethod @db_api.retry_if_session_inactive() def delete_tenant_quota(context, tenant_id): diff --git a/neutron/extensions/quotasv2.py b/neutron/extensions/quotasv2.py index 517169ac1d0..64e118a03eb 100644 --- a/neutron/extensions/quotasv2.py +++ b/neutron/extensions/quotasv2.py @@ -128,6 +128,9 @@ class QuotaSetsController(wsgi.Controller): class Quotasv2(api_extensions.ExtensionDescriptor): """Quotas management support.""" + extensions.register_custom_supported_check( + RESOURCE_COLLECTION, lambda: True, plugin_agnostic=True) + @classmethod def get_name(cls): return "Quota management support" diff --git a/neutron/extensions/quotasv2_detail.py b/neutron/extensions/quotasv2_detail.py new file mode 100644 index 00000000000..9a673182c33 --- /dev/null +++ b/neutron/extensions/quotasv2_detail.py @@ -0,0 +1,99 @@ +# Copyright 2017 Intel Corporation. +# 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 neutron_lib.api import extensions as api_extensions +from neutron_lib import exceptions as n_exc +from neutron_lib.plugins import directory +from oslo_config import cfg + +from neutron._i18n import _ +from neutron.api import extensions +from neutron.api.v2 import base +from neutron.api.v2 import resource +from neutron.extensions import quotasv2 +from neutron.quota import resource_registry + + +DETAIL_QUOTAS_ACTION = 'details' +RESOURCE_NAME = 'quota' +ALIAS = RESOURCE_NAME + '_' + DETAIL_QUOTAS_ACTION +QUOTA_DRIVER = cfg.CONF.QUOTAS.quota_driver +RESOURCE_COLLECTION = RESOURCE_NAME + "s" +DB_QUOTA_DRIVER = 'neutron.db.quota.driver.DbQuotaDriver' +EXTENDED_ATTRIBUTES_2_0 = { + RESOURCE_COLLECTION: {} +} + + +class DetailQuotaSetsController(quotasv2.QuotaSetsController): + + def _get_detailed_quotas(self, request, tenant_id): + return self._driver.get_detailed_tenant_quotas( + request.context, + resource_registry.get_all_resources(), tenant_id) + + def details(self, request, id): + if id != request.context.project_id: + # Check if admin + if not request.context.is_admin: + reason = _("Only admin is authorized to access quotas for" + " another tenant") + raise n_exc.AdminRequired(reason=reason) + return {self._resource_name: + self._get_detailed_quotas(request, id)} + + +class Quotasv2_detail(api_extensions.ExtensionDescriptor): + """Quota details management support.""" + + # Ensure new extension is not loaded with old conf driver. + extensions.register_custom_supported_check( + ALIAS, lambda: True if QUOTA_DRIVER == DB_QUOTA_DRIVER else False, + plugin_agnostic=True) + + @classmethod + def get_name(cls): + return "Quota details management support" + + @classmethod + def get_alias(cls): + return ALIAS + + @classmethod + def get_description(cls): + return 'Expose functions for quotas usage statistics per project' + + @classmethod + def get_updated(cls): + return "2017-02-10T10:00:00-00:00" + + @classmethod + def get_resources(cls): + """Returns Extension Resources.""" + controller = resource.Resource( + DetailQuotaSetsController(directory.get_plugin()), + faults=base.FAULT_MAP) + return [extensions.ResourceExtension( + RESOURCE_COLLECTION, + controller, + member_actions={'details': 'GET'}, + collection_actions={'tenant': 'GET'})] + + def get_extended_resources(self, version): + return EXTENDED_ATTRIBUTES_2_0 if version == "2.0" else {} + + def get_required_extensions(self): + return ["quotas"] diff --git a/neutron/quota/resource.py b/neutron/quota/resource.py index b57bee538bd..d80a052fb49 100644 --- a/neutron/quota/resource.py +++ b/neutron/quota/resource.py @@ -234,27 +234,17 @@ class TrackedResource(BaseResource): # Update quota usage return self._resync(context, tenant_id, in_use) - def count(self, context, _plugin, tenant_id, resync_usage=True): - """Return the current usage count for the resource. + def count_used(self, context, tenant_id, resync_usage=True): + """Returns the current usage count for the resource. - This method will fetch aggregate information for resource usage - data, unless usage data are marked as "dirty". - In the latter case resource usage will be calculated counting - rows for tenant_id in the resource's database model. - Active reserved amount are instead always calculated by summing - amounts for matching records in the 'reservations' database model. - - The _plugin and _resource parameters are unused but kept for - compatibility with the signature of the count method for - CountableResource instances. + :param context: The request context. + :param tenant_id: The ID of the tenant + :param resync_usage: Default value is set to True. Syncs + with in_use usage. """ # Load current usage data, setting a row-level lock on the DB usage_info = quota_api.get_quota_usage_by_resource_and_tenant( context, self.name, tenant_id) - # Always fetch reservations, as they are not tracked by usage counters - reservations = quota_api.get_reservations_for_resources( - context, tenant_id, [self.name]) - reserved = reservations.get(self.name, 0) # If dirty or missing, calculate actual resource usage querying # the database and set/create usage info data @@ -287,7 +277,26 @@ class TrackedResource(BaseResource): "Used quota:%(used)d."), {'resource': self.name, 'used': usage_info.used}) - return usage_info.used + reserved + return usage_info.used + + def count_reserved(self, context, tenant_id): + """Return the current reservation count for the resource.""" + # NOTE(princenana) Current implementation of reservations + # is ephemeral and returns the default value + reservations = quota_api.get_reservations_for_resources( + context, tenant_id, [self.name]) + reserved = reservations.get(self.name, 0) + return reserved + + def count(self, context, _plugin, tenant_id, resync_usage=True): + """Return the count of the resource. + + The _plugin parameter is unused but kept for + compatibility with the signature of the count method for + CountableResource instances. + """ + return (self.count_used(context, tenant_id, resync_usage) + + self.count_reserved(context, tenant_id)) def _except_bulk_delete(self, delete_context): if delete_context.mapper.class_ == self._model_class: diff --git a/neutron/tests/contrib/hooks/api_all_extensions b/neutron/tests/contrib/hooks/api_all_extensions index da8dbfd2c0f..ab5dfc15fec 100644 --- a/neutron/tests/contrib/hooks/api_all_extensions +++ b/neutron/tests/contrib/hooks/api_all_extensions @@ -29,6 +29,7 @@ NETWORK_API_EXTENSIONS+=",project-id" NETWORK_API_EXTENSIONS+=",provider" NETWORK_API_EXTENSIONS+=",qos" NETWORK_API_EXTENSIONS+=",quotas" +NETWORK_API_EXTENSIONS+=",quota_details" NETWORK_API_EXTENSIONS+=",rbac-policies" NETWORK_API_EXTENSIONS+=",router" NETWORK_API_EXTENSIONS+=",router_availability_zone" diff --git a/neutron/tests/tempest/api/admin/test_quotas.py b/neutron/tests/tempest/api/admin/test_quotas.py index 85aecb9daa4..fe8f5117ab1 100644 --- a/neutron/tests/tempest/api/admin/test_quotas.py +++ b/neutron/tests/tempest/api/admin/test_quotas.py @@ -13,9 +13,11 @@ # License for the specific language governing permissions and limitations # under the License. +import six from tempest.lib.common.utils import data_utils from tempest.lib import decorators from tempest.lib import exceptions as lib_exc +from tempest import test from neutron.tests.tempest.api import base from neutron.tests.tempest import config @@ -58,6 +60,19 @@ class QuotasTestBase(base.BaseAdminNetworkTest): except lib_exc.NotFound: pass + def _create_network(self, project_id): + network = self.create_network(client=self.admin_client, + tenant_id=project_id) + self.addCleanup(self.admin_client.delete_network, + network['id']) + return network + + def _create_port(self, **kwargs): + port = self.admin_client.create_port(**kwargs)['port'] + self.addCleanup(self.admin_client.delete_port, + port['id']) + return port + class QuotasTest(QuotasTestBase): """Test the Neutron API of Quotas. @@ -67,6 +82,7 @@ class QuotasTest(QuotasTestBase): list quotas for tenants who have non-default quota values show quotas for a specified tenant + show detail quotas for a specified tenant update quotas for a specified tenant reset quotas to default values for a specified tenant @@ -108,3 +124,39 @@ class QuotasTest(QuotasTestBase): non_default_quotas = self.admin_client.list_quotas() for q in non_default_quotas['quotas']: self.assertNotEqual(tenant_id, q['tenant_id']) + + @decorators.idempotent_id('e974b5ba-090a-452c-a578-f9710151d9fc') + @decorators.attr(type='gate') + @test.requires_ext(extension="quota_details", service="network") + def test_detail_quotas(self): + tenant_id = self._create_tenant()['id'] + new_quotas = {'network': {'used': 1, 'limit': 2, 'reserved': 0}, + 'port': {'used': 1, 'limit': 2, 'reserved': 0}} + + # update quota limit for tenant + new_quota = {'network': new_quotas['network']['limit'], 'port': + new_quotas['port']['limit']} + quota_set = self._setup_quotas(tenant_id, **new_quota) + + # create test resources + network = self._create_network(tenant_id) + post_body = {"network_id": network['id'], + "tenant_id": tenant_id} + self._create_port(**post_body) + + # confirm from extended API quotas were changed + # as requested for tenant + quota_set = self.admin_client.show_details_quota(tenant_id) + quota_set = quota_set['quota'] + for key, value in six.iteritems(new_quotas): + self.assertEqual(new_quotas[key]['limit'], + quota_set[key]['limit']) + self.assertEqual(new_quotas[key]['reserved'], + quota_set[key]['reserved']) + self.assertEqual(new_quotas[key]['used'], + quota_set[key]['used']) + + # validate 'default' action for old extension + quota_limit = self.admin_client.show_quotas(tenant_id)['quota'] + for key, value in six.iteritems(new_quotas): + self.assertEqual(new_quotas[key]['limit'], quota_limit[key]) diff --git a/neutron/tests/tempest/api/base.py b/neutron/tests/tempest/api/base.py index 84746fbbadd..0ec71c3b2c2 100644 --- a/neutron/tests/tempest/api/base.py +++ b/neutron/tests/tempest/api/base.py @@ -217,11 +217,12 @@ class BaseNetworkTest(test.BaseTestCase): pass @classmethod - def create_network(cls, network_name=None, **kwargs): + def create_network(cls, network_name=None, client=None, **kwargs): """Wrapper utility that returns a test network.""" network_name = network_name or data_utils.rand_name('test-network-') - body = cls.client.create_network(name=network_name, **kwargs) + client = client or cls.client + body = client.create_network(name=network_name, **kwargs) network = body['network'] cls.networks.append(network) return network diff --git a/neutron/tests/tempest/services/network/json/network_client.py b/neutron/tests/tempest/services/network/json/network_client.py index 39f2c38284b..12cdf971bb9 100644 --- a/neutron/tests/tempest/services/network/json/network_client.py +++ b/neutron/tests/tempest/services/network/json/network_client.py @@ -124,7 +124,13 @@ class NetworkClientJSON(service_client.RestClient): # list of field's name. An example: # {'fields': ['id', 'name']} plural = self.pluralize(resource_name) - uri = '%s/%s' % (self.get_uri(plural), resource_id) + if 'details_quotas' in plural: + details, plural = plural.split('_') + uri = '%s/%s/%s' % (self.get_uri(plural), + resource_id, details) + else: + uri = '%s/%s' % (self.get_uri(plural), resource_id) + if fields: uri += '?' + urlparse.urlencode(fields, doseq=1) resp, body = self.get(uri) diff --git a/neutron/tests/unit/api/v2/test_base.py b/neutron/tests/unit/api/v2/test_base.py index ce1f8910e23..d1d01cc2569 100644 --- a/neutron/tests/unit/api/v2/test_base.py +++ b/neutron/tests/unit/api/v2/test_base.py @@ -51,7 +51,8 @@ EXTDIR = os.path.join(base.ROOTDIR, 'unit/extensions') _uuid = uuidutils.generate_uuid -def _get_path(resource, id=None, action=None, fmt=None): +def _get_path(resource, id=None, action=None, + fmt=None, endpoint=None): path = '/%s' % resource if id is not None: @@ -63,6 +64,9 @@ def _get_path(resource, id=None, action=None, fmt=None): if fmt is not None: path = path + '.%s' % fmt + if endpoint is not None: + path = path + '/%s' % endpoint + return path diff --git a/neutron/tests/unit/db/quota/test_driver.py b/neutron/tests/unit/db/quota/test_driver.py index 635fe70caac..8a4e9fb1571 100644 --- a/neutron/tests/unit/db/quota/test_driver.py +++ b/neutron/tests/unit/db/quota/test_driver.py @@ -18,14 +18,26 @@ from neutron_lib import exceptions as lib_exc from neutron.common import exceptions from neutron.db import db_base_plugin_v2 as base_plugin +from neutron.db.quota import api as quota_api from neutron.db.quota import driver +from neutron.objects import quota as quota_obj +from neutron.quota import resource from neutron.tests import base +from neutron.tests.unit import quota as test_quota from neutron.tests.unit import testlib_api - DB_PLUGIN_KLASS = 'neutron.db.db_base_plugin_v2.NeutronDbPluginV2' +def _count_resource(context, plugin, resource, tenant_id): + """A fake counting function to determine current used counts""" + if resource[-1] == 's': + resource = resource[:-1] + result = quota_obj.QuotaUsage.get_object_dirty_protected( + context, resource=resource) + return 0 if not result else result.in_use + + class FakePlugin(base_plugin.NeutronDbPluginV2, driver.DbQuotaDriver): """A fake plugin class containing all DB methods.""" @@ -46,6 +58,28 @@ class TestResource(object): return self.fake_count +class TestTrackedResource(resource.TrackedResource): + """Describes a test tracked resource for detailed quota checking""" + def __init__(self, name, model_class, flag=None, + plural_name=None): + super(TestTrackedResource, self).__init__( + name, model_class, flag=flag, plural_name=None) + + @property + def default(self): + return self.flag + + +class TestCountableResource(resource.CountableResource): + """Describes a test countable resource for detailed quota checking""" + def __init__(self, name, count, flag=-1, plural_name=None): + super(TestCountableResource, self).__init__( + name, count, flag=flag, plural_name=None) + + @property + def default(self): + return self.flag + PROJECT = 'prj_test' RESOURCE = 'res_test' ALT_RESOURCE = 'res_test_meh' @@ -227,3 +261,48 @@ class TestDbQuotaDriver(testlib_api.SqlTestCase, resources, deltas, self.plugin) + + def test_get_detailed_tenant_quotas_resource(self): + res = {RESOURCE: TestTrackedResource(RESOURCE, test_quota.MehModel)} + + self.plugin.update_quota_limit(self.context, PROJECT, RESOURCE, 6) + quota_driver = driver.DbQuotaDriver() + quota_driver.make_reservation(self.context, PROJECT, res, + {RESOURCE: 1}, self.plugin) + quota_api.set_quota_usage(self.context, RESOURCE, PROJECT, 2) + detailed_quota = self.plugin.get_detailed_tenant_quotas(self.context, + res, PROJECT) + self.assertEqual(6, detailed_quota[RESOURCE]['limit']) + self.assertEqual(2, detailed_quota[RESOURCE]['used']) + self.assertEqual(1, detailed_quota[RESOURCE]['reserved']) + + def test_get_detailed_tenant_quotas_multiple_resource(self): + project_1 = 'prj_test_1' + resource_1 = 'res_test_1' + resource_2 = 'res_test_2' + resources = {resource_1: + TestTrackedResource(resource_1, test_quota.MehModel), + resource_2: + TestCountableResource(resource_2, _count_resource)} + + self.plugin.update_quota_limit(self.context, project_1, resource_1, 6) + self.plugin.update_quota_limit(self.context, project_1, resource_2, 9) + quota_driver = driver.DbQuotaDriver() + quota_driver.make_reservation(self.context, project_1, + resources, + {resource_1: 1, resource_2: 7}, + self.plugin) + + quota_api.set_quota_usage(self.context, resource_1, project_1, 2) + quota_api.set_quota_usage(self.context, resource_2, project_1, 3) + detailed_quota = self.plugin.get_detailed_tenant_quotas(self.context, + resources, + project_1) + + self.assertEqual(6, detailed_quota[resource_1]['limit']) + self.assertEqual(1, detailed_quota[resource_1]['reserved']) + self.assertEqual(2, detailed_quota[resource_1]['used']) + + self.assertEqual(9, detailed_quota[resource_2]['limit']) + self.assertEqual(7, detailed_quota[resource_2]['reserved']) + self.assertEqual(3, detailed_quota[resource_2]['used']) diff --git a/neutron/tests/unit/extensions/test_quotasv2_detail.py b/neutron/tests/unit/extensions/test_quotasv2_detail.py new file mode 100644 index 00000000000..f24265c0b1f --- /dev/null +++ b/neutron/tests/unit/extensions/test_quotasv2_detail.py @@ -0,0 +1,153 @@ +# Copyright 2017 Intel Corporation. +# 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 mock +from oslo_config import cfg +import webtest + +from neutron_lib import context + +from neutron.api import extensions +from neutron.api.v2 import router +from neutron.common import config +from neutron.conf import quota as qconf +from neutron import quota +from neutron.tests import tools +from neutron.tests.unit.api.v2 import test_base +from neutron.tests.unit import testlib_api + +DEFAULT_QUOTAS_ACTION = 'details' +TARGET_PLUGIN = 'neutron.plugins.ml2.plugin.Ml2Plugin' + +_get_path = test_base._get_path + + +class DetailQuotaExtensionTestCase(testlib_api.WebTestCase): + + def setUp(self): + super(DetailQuotaExtensionTestCase, self).setUp() + # Ensure existing ExtensionManager is not used + extensions.PluginAwareExtensionManager._instance = None + + self.useFixture(tools.AttributeMapMemento()) + + # Create the default configurations + self.config_parse() + + # Update the plugin and extensions path + self.setup_coreplugin('ml2') + quota.QUOTAS = quota.QuotaEngine() + self._plugin_patcher = mock.patch(TARGET_PLUGIN, autospec=True) + self.plugin = self._plugin_patcher.start() + self.plugin.return_value.supported_extension_aliases = \ + ['quotas', 'quota_details'] + # QUOTAS will register the items in conf when starting + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + app = config.load_paste_app('extensions_test_app') + ext_middleware = extensions.ExtensionMiddleware(app, ext_mgr=ext_mgr) + self.api = webtest.TestApp(ext_middleware) + # Initialize the router for the core API in order to ensure core quota + # resources are registered + router.APIRouter() + + +class DetailQuotaExtensionDbTestCase(DetailQuotaExtensionTestCase): + fmt = 'json' + + def test_show_detail_quotas(self): + tenant_id = 'tenant_id1' + env = {'neutron.context': context.Context('', tenant_id, + is_admin=True)} + res = self.api.get(_get_path('quotas', id=tenant_id, + fmt=self.fmt, + endpoint=DEFAULT_QUOTAS_ACTION), + extra_environ=env) + self.assertEqual(200, res.status_int) + quota = self.deserialize(res) + self.assertEqual(0, quota['quota']['network']['reserved']) + self.assertEqual(0, quota['quota']['subnet']['reserved']) + self.assertEqual(0, quota['quota']['port']['reserved']) + self.assertEqual(0, quota['quota']['network']['used']) + self.assertEqual(0, quota['quota']['subnet']['used']) + self.assertEqual(0, quota['quota']['port']['used']) + self.assertEqual(qconf.DEFAULT_QUOTA_NETWORK, + quota['quota']['network']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA_SUBNET, + quota['quota']['subnet']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA_PORT, + quota['quota']['port']['limit']) + + def test_detail_quotas_negative_limit_value(self): + cfg.CONF.set_override( + 'quota_port', -666, group='QUOTAS') + cfg.CONF.set_override( + 'quota_network', -10, group='QUOTAS') + cfg.CONF.set_override( + 'quota_subnet', -50, group='QUOTAS') + tenant_id = 'tenant_id1' + env = {'neutron.context': context.Context('', tenant_id, + is_admin=True)} + res = self.api.get(_get_path('quotas', id=tenant_id, + fmt=self.fmt, + endpoint=DEFAULT_QUOTAS_ACTION), + extra_environ=env) + self.assertEqual(200, res.status_int) + quota = self.deserialize(res) + self.assertEqual(0, quota['quota']['network']['reserved']) + self.assertEqual(0, quota['quota']['subnet']['reserved']) + self.assertEqual(0, quota['quota']['port']['reserved']) + self.assertEqual(0, quota['quota']['network']['used']) + self.assertEqual(0, quota['quota']['subnet']['used']) + self.assertEqual(0, quota['quota']['port']['used']) + self.assertEqual(qconf.DEFAULT_QUOTA, + quota['quota']['network']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA, + quota['quota']['subnet']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA, + quota['quota']['port']['limit']) + + def test_show_detail_quotas_with_admin(self): + tenant_id = 'tenant_id1' + env = {'neutron.context': context.Context('', tenant_id + '2', + is_admin=True)} + res = self.api.get(_get_path('quotas', id=tenant_id, + fmt=self.fmt, + endpoint=DEFAULT_QUOTAS_ACTION), + extra_environ=env) + self.assertEqual(200, res.status_int) + quota = self.deserialize(res) + self.assertEqual(0, quota['quota']['network']['reserved']) + self.assertEqual(0, quota['quota']['subnet']['reserved']) + self.assertEqual(0, quota['quota']['port']['reserved']) + self.assertEqual(0, quota['quota']['network']['used']) + self.assertEqual(0, quota['quota']['subnet']['used']) + self.assertEqual(0, quota['quota']['port']['used']) + self.assertEqual(qconf.DEFAULT_QUOTA_NETWORK, + quota['quota']['network']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA_SUBNET, + quota['quota']['subnet']['limit']) + self.assertEqual(qconf.DEFAULT_QUOTA_PORT, + quota['quota']['port']['limit']) + + def test_detail_quotas_without_admin_forbidden_returns_403(self): + tenant_id = 'tenant_id1' + env = {'neutron.context': context.Context('', tenant_id, + is_admin=False)} + res = self.api.get(_get_path('quotas', id=tenant_id, + fmt=self.fmt, + endpoint=DEFAULT_QUOTAS_ACTION), + extra_environ=env, expect_errors=True) + self.assertEqual(403, res.status_int) diff --git a/neutron/tests/unit/quota/test_resource.py b/neutron/tests/unit/quota/test_resource.py index 5c7d27bbed6..b808e10aa2f 100644 --- a/neutron/tests/unit/quota/test_resource.py +++ b/neutron/tests/unit/quota/test_resource.py @@ -129,6 +129,25 @@ class TestTrackedResource(testlib_api.SqlTestCase): # count() always resyncs with the db self.assertEqual(2, res.count(self.context, None, self.tenant_id)) + def test_count_reserved(self): + res = self._create_resource() + quota_api.create_reservation(self.context, self.tenant_id, + {res.name: 1}) + self.assertEqual(1, res.count_reserved(self.context, self.tenant_id)) + + def test_count_used_first_call_with_dirty_false(self): + quota_api.set_quota_usage( + self.context, self.resource, self.tenant_id, in_use=1) + res = self._create_resource() + self._add_data() + # explicitly set dirty flag to False + quota_api.set_all_quota_usage_dirty( + self.context, self.resource, dirty=False) + # Expect correct count_used to be returned + # anyway since the first call to + # count_used() always resyncs with the db + self.assertEqual(2, res.count_used(self.context, self.tenant_id)) + def _test_count(self): res = self._create_resource() quota_api.set_quota_usage( @@ -148,6 +167,18 @@ class TestTrackedResource(testlib_api.SqlTestCase): None, self.tenant_id)) + def test_count_used_with_dirty_false(self): + res = self._test_count() + res.count_used(self.context, self.tenant_id) + # At this stage count_used has been invoked, + # and the dirty flag should be false. Another invocation + # of count_used should not query the model class + set_quota = 'neutron.db.quota.api.set_quota_usage' + with mock.patch(set_quota) as mock_set_quota: + self.assertEqual(0, mock_set_quota.call_count) + self.assertEqual(2, res.count_used(self.context, + self.tenant_id)) + def test_count_with_dirty_true_resync(self): res = self._test_count() # Expect correct count to be returned, which also implies @@ -157,6 +188,14 @@ class TestTrackedResource(testlib_api.SqlTestCase): self.tenant_id, resync_usage=True)) + def test_count_used_with_dirty_true_resync(self): + res = self._test_count() + # Expect correct count_used to be returned, which also implies + # set_quota_usage has been invoked with the correct parameters + self.assertEqual(2, res.count_used(self.context, + self.tenant_id, + resync_usage=True)) + def test_count_with_dirty_true_resync_calls_set_quota_usage(self): res = self._test_count() set_quota_usage = 'neutron.db.quota.api.set_quota_usage' @@ -169,6 +208,18 @@ class TestTrackedResource(testlib_api.SqlTestCase): mock_set_quota_usage.assert_called_once_with( self.context, self.resource, self.tenant_id, in_use=2) + def test_count_used_with_dirty_true_resync_calls_set_quota_usage(self): + res = self._test_count() + set_quota_usage = 'neutron.db.quota.api.set_quota_usage' + with mock.patch(set_quota_usage) as mock_set_quota_usage: + quota_api.set_quota_usage_dirty(self.context, + self.resource, + self.tenant_id) + res.count_used(self.context, self.tenant_id, + resync_usage=True) + mock_set_quota_usage.assert_called_once_with( + self.context, self.resource, self.tenant_id, in_use=2) + def test_count_with_dirty_true_no_usage_info(self): res = self._create_resource() self._add_data() @@ -176,6 +227,13 @@ class TestTrackedResource(testlib_api.SqlTestCase): # count to be returned self.assertEqual(2, res.count(self.context, None, self.tenant_id)) + def test_count_used_with_dirty_true_no_usage_info(self): + res = self._create_resource() + self._add_data() + # Invoke count_used without having usage info in DB - Expect correct + # count_used to be returned + self.assertEqual(2, res.count_used(self.context, self.tenant_id)) + def test_count_with_dirty_true_no_usage_info_calls_set_quota_usage(self): res = self._create_resource() self._add_data() @@ -188,6 +246,19 @@ class TestTrackedResource(testlib_api.SqlTestCase): mock_set_quota_usage.assert_called_once_with( self.context, self.resource, self.tenant_id, in_use=2) + def test_count_used_with_dirty_true_no_usage_info_calls_set_quota_usage( + self): + res = self._create_resource() + self._add_data() + set_quota_usage = 'neutron.db.quota.api.set_quota_usage' + with mock.patch(set_quota_usage) as mock_set_quota_usage: + quota_api.set_quota_usage_dirty(self.context, + self.resource, + self.tenant_id) + res.count_used(self.context, self.tenant_id, resync_usage=True) + mock_set_quota_usage.assert_called_once_with( + self.context, self.resource, self.tenant_id, in_use=2) + def test_add_delete_data_triggers_event(self): res = self._create_resource() other_res = self._create_other_resource() diff --git a/releasenotes/notes/extend-quota-api-2df3b84309664234.yaml b/releasenotes/notes/extend-quota-api-2df3b84309664234.yaml new file mode 100644 index 00000000000..d173f62d2db --- /dev/null +++ b/releasenotes/notes/extend-quota-api-2df3b84309664234.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Implements a new extension, ``quota_details`` which extends existing quota API + to show detailed information for a specified tenant. The new API shows + details such as ``limits``, ``used``, ``reserved``.