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<prince.a.owusu.boateng@intel.com> Change-Id: Ief2a6a4d2d7085e2a9dcd901123bc4fe6ac7ca22 Related-bug: #1599488
This commit is contained in:
parent
d7e7dd451a
commit
a8109af65f
@ -15,12 +15,15 @@
|
|||||||
|
|
||||||
from neutron_lib.api import attributes
|
from neutron_lib.api import attributes
|
||||||
from neutron_lib import exceptions
|
from neutron_lib import exceptions
|
||||||
|
from neutron_lib.plugins import constants
|
||||||
|
from neutron_lib.plugins import directory
|
||||||
from oslo_log import log
|
from oslo_log import log
|
||||||
|
|
||||||
from neutron.common import exceptions as n_exc
|
from neutron.common import exceptions as n_exc
|
||||||
from neutron.db import api as db_api
|
from neutron.db import api as db_api
|
||||||
from neutron.db.quota import api as quota_api
|
from neutron.db.quota import api as quota_api
|
||||||
from neutron.objects import quota as quota_obj
|
from neutron.objects import quota as quota_obj
|
||||||
|
from neutron.quota import resource as res
|
||||||
|
|
||||||
LOG = log.getLogger(__name__)
|
LOG = log.getLogger(__name__)
|
||||||
|
|
||||||
@ -72,6 +75,39 @@ class DbQuotaDriver(object):
|
|||||||
|
|
||||||
return tenant_quota
|
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
|
@staticmethod
|
||||||
@db_api.retry_if_session_inactive()
|
@db_api.retry_if_session_inactive()
|
||||||
def delete_tenant_quota(context, tenant_id):
|
def delete_tenant_quota(context, tenant_id):
|
||||||
|
@ -128,6 +128,9 @@ class QuotaSetsController(wsgi.Controller):
|
|||||||
class Quotasv2(api_extensions.ExtensionDescriptor):
|
class Quotasv2(api_extensions.ExtensionDescriptor):
|
||||||
"""Quotas management support."""
|
"""Quotas management support."""
|
||||||
|
|
||||||
|
extensions.register_custom_supported_check(
|
||||||
|
RESOURCE_COLLECTION, lambda: True, plugin_agnostic=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_name(cls):
|
def get_name(cls):
|
||||||
return "Quota management support"
|
return "Quota management support"
|
||||||
|
99
neutron/extensions/quotasv2_detail.py
Normal file
99
neutron/extensions/quotasv2_detail.py
Normal file
@ -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"]
|
@ -234,27 +234,17 @@ class TrackedResource(BaseResource):
|
|||||||
# Update quota usage
|
# Update quota usage
|
||||||
return self._resync(context, tenant_id, in_use)
|
return self._resync(context, tenant_id, in_use)
|
||||||
|
|
||||||
def count(self, context, _plugin, tenant_id, resync_usage=True):
|
def count_used(self, context, tenant_id, resync_usage=True):
|
||||||
"""Return the current usage count for the resource.
|
"""Returns the current usage count for the resource.
|
||||||
|
|
||||||
This method will fetch aggregate information for resource usage
|
:param context: The request context.
|
||||||
data, unless usage data are marked as "dirty".
|
:param tenant_id: The ID of the tenant
|
||||||
In the latter case resource usage will be calculated counting
|
:param resync_usage: Default value is set to True. Syncs
|
||||||
rows for tenant_id in the resource's database model.
|
with in_use usage.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
# Load current usage data, setting a row-level lock on the DB
|
# Load current usage data, setting a row-level lock on the DB
|
||||||
usage_info = quota_api.get_quota_usage_by_resource_and_tenant(
|
usage_info = quota_api.get_quota_usage_by_resource_and_tenant(
|
||||||
context, self.name, tenant_id)
|
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
|
# If dirty or missing, calculate actual resource usage querying
|
||||||
# the database and set/create usage info data
|
# the database and set/create usage info data
|
||||||
@ -287,7 +277,26 @@ class TrackedResource(BaseResource):
|
|||||||
"Used quota:%(used)d."),
|
"Used quota:%(used)d."),
|
||||||
{'resource': self.name,
|
{'resource': self.name,
|
||||||
'used': usage_info.used})
|
'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):
|
def _except_bulk_delete(self, delete_context):
|
||||||
if delete_context.mapper.class_ == self._model_class:
|
if delete_context.mapper.class_ == self._model_class:
|
||||||
|
@ -29,6 +29,7 @@ NETWORK_API_EXTENSIONS+=",project-id"
|
|||||||
NETWORK_API_EXTENSIONS+=",provider"
|
NETWORK_API_EXTENSIONS+=",provider"
|
||||||
NETWORK_API_EXTENSIONS+=",qos"
|
NETWORK_API_EXTENSIONS+=",qos"
|
||||||
NETWORK_API_EXTENSIONS+=",quotas"
|
NETWORK_API_EXTENSIONS+=",quotas"
|
||||||
|
NETWORK_API_EXTENSIONS+=",quota_details"
|
||||||
NETWORK_API_EXTENSIONS+=",rbac-policies"
|
NETWORK_API_EXTENSIONS+=",rbac-policies"
|
||||||
NETWORK_API_EXTENSIONS+=",router"
|
NETWORK_API_EXTENSIONS+=",router"
|
||||||
NETWORK_API_EXTENSIONS+=",router_availability_zone"
|
NETWORK_API_EXTENSIONS+=",router_availability_zone"
|
||||||
|
@ -13,9 +13,11 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import six
|
||||||
from tempest.lib.common.utils import data_utils
|
from tempest.lib.common.utils import data_utils
|
||||||
from tempest.lib import decorators
|
from tempest.lib import decorators
|
||||||
from tempest.lib import exceptions as lib_exc
|
from tempest.lib import exceptions as lib_exc
|
||||||
|
from tempest import test
|
||||||
|
|
||||||
from neutron.tests.tempest.api import base
|
from neutron.tests.tempest.api import base
|
||||||
from neutron.tests.tempest import config
|
from neutron.tests.tempest import config
|
||||||
@ -58,6 +60,19 @@ class QuotasTestBase(base.BaseAdminNetworkTest):
|
|||||||
except lib_exc.NotFound:
|
except lib_exc.NotFound:
|
||||||
pass
|
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):
|
class QuotasTest(QuotasTestBase):
|
||||||
"""Test the Neutron API of Quotas.
|
"""Test the Neutron API of Quotas.
|
||||||
@ -67,6 +82,7 @@ class QuotasTest(QuotasTestBase):
|
|||||||
|
|
||||||
list quotas for tenants who have non-default quota values
|
list quotas for tenants who have non-default quota values
|
||||||
show quotas for a specified tenant
|
show quotas for a specified tenant
|
||||||
|
show detail quotas for a specified tenant
|
||||||
update quotas for a specified tenant
|
update quotas for a specified tenant
|
||||||
reset quotas to default values 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()
|
non_default_quotas = self.admin_client.list_quotas()
|
||||||
for q in non_default_quotas['quotas']:
|
for q in non_default_quotas['quotas']:
|
||||||
self.assertNotEqual(tenant_id, q['tenant_id'])
|
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])
|
||||||
|
@ -217,11 +217,12 @@ class BaseNetworkTest(test.BaseTestCase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Wrapper utility that returns a test network."""
|
||||||
network_name = network_name or data_utils.rand_name('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']
|
network = body['network']
|
||||||
cls.networks.append(network)
|
cls.networks.append(network)
|
||||||
return network
|
return network
|
||||||
|
@ -124,7 +124,13 @@ class NetworkClientJSON(service_client.RestClient):
|
|||||||
# list of field's name. An example:
|
# list of field's name. An example:
|
||||||
# {'fields': ['id', 'name']}
|
# {'fields': ['id', 'name']}
|
||||||
plural = self.pluralize(resource_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:
|
if fields:
|
||||||
uri += '?' + urlparse.urlencode(fields, doseq=1)
|
uri += '?' + urlparse.urlencode(fields, doseq=1)
|
||||||
resp, body = self.get(uri)
|
resp, body = self.get(uri)
|
||||||
|
@ -51,7 +51,8 @@ EXTDIR = os.path.join(base.ROOTDIR, 'unit/extensions')
|
|||||||
_uuid = uuidutils.generate_uuid
|
_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
|
path = '/%s' % resource
|
||||||
|
|
||||||
if id is not None:
|
if id is not None:
|
||||||
@ -63,6 +64,9 @@ def _get_path(resource, id=None, action=None, fmt=None):
|
|||||||
if fmt is not None:
|
if fmt is not None:
|
||||||
path = path + '.%s' % fmt
|
path = path + '.%s' % fmt
|
||||||
|
|
||||||
|
if endpoint is not None:
|
||||||
|
path = path + '/%s' % endpoint
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,14 +18,26 @@ from neutron_lib import exceptions as lib_exc
|
|||||||
|
|
||||||
from neutron.common import exceptions
|
from neutron.common import exceptions
|
||||||
from neutron.db import db_base_plugin_v2 as base_plugin
|
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.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 import base
|
||||||
|
from neutron.tests.unit import quota as test_quota
|
||||||
from neutron.tests.unit import testlib_api
|
from neutron.tests.unit import testlib_api
|
||||||
|
|
||||||
|
|
||||||
DB_PLUGIN_KLASS = 'neutron.db.db_base_plugin_v2.NeutronDbPluginV2'
|
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):
|
class FakePlugin(base_plugin.NeutronDbPluginV2, driver.DbQuotaDriver):
|
||||||
"""A fake plugin class containing all DB methods."""
|
"""A fake plugin class containing all DB methods."""
|
||||||
|
|
||||||
@ -46,6 +58,28 @@ class TestResource(object):
|
|||||||
return self.fake_count
|
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'
|
PROJECT = 'prj_test'
|
||||||
RESOURCE = 'res_test'
|
RESOURCE = 'res_test'
|
||||||
ALT_RESOURCE = 'res_test_meh'
|
ALT_RESOURCE = 'res_test_meh'
|
||||||
@ -227,3 +261,48 @@ class TestDbQuotaDriver(testlib_api.SqlTestCase,
|
|||||||
resources,
|
resources,
|
||||||
deltas,
|
deltas,
|
||||||
self.plugin)
|
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'])
|
||||||
|
153
neutron/tests/unit/extensions/test_quotasv2_detail.py
Normal file
153
neutron/tests/unit/extensions/test_quotasv2_detail.py
Normal file
@ -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)
|
@ -129,6 +129,25 @@ class TestTrackedResource(testlib_api.SqlTestCase):
|
|||||||
# count() always resyncs with the db
|
# count() always resyncs with the db
|
||||||
self.assertEqual(2, res.count(self.context, None, self.tenant_id))
|
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):
|
def _test_count(self):
|
||||||
res = self._create_resource()
|
res = self._create_resource()
|
||||||
quota_api.set_quota_usage(
|
quota_api.set_quota_usage(
|
||||||
@ -148,6 +167,18 @@ class TestTrackedResource(testlib_api.SqlTestCase):
|
|||||||
None,
|
None,
|
||||||
self.tenant_id))
|
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):
|
def test_count_with_dirty_true_resync(self):
|
||||||
res = self._test_count()
|
res = self._test_count()
|
||||||
# Expect correct count to be returned, which also implies
|
# Expect correct count to be returned, which also implies
|
||||||
@ -157,6 +188,14 @@ class TestTrackedResource(testlib_api.SqlTestCase):
|
|||||||
self.tenant_id,
|
self.tenant_id,
|
||||||
resync_usage=True))
|
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):
|
def test_count_with_dirty_true_resync_calls_set_quota_usage(self):
|
||||||
res = self._test_count()
|
res = self._test_count()
|
||||||
set_quota_usage = 'neutron.db.quota.api.set_quota_usage'
|
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(
|
mock_set_quota_usage.assert_called_once_with(
|
||||||
self.context, self.resource, self.tenant_id, in_use=2)
|
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):
|
def test_count_with_dirty_true_no_usage_info(self):
|
||||||
res = self._create_resource()
|
res = self._create_resource()
|
||||||
self._add_data()
|
self._add_data()
|
||||||
@ -176,6 +227,13 @@ class TestTrackedResource(testlib_api.SqlTestCase):
|
|||||||
# count to be returned
|
# count to be returned
|
||||||
self.assertEqual(2, res.count(self.context, None, self.tenant_id))
|
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):
|
def test_count_with_dirty_true_no_usage_info_calls_set_quota_usage(self):
|
||||||
res = self._create_resource()
|
res = self._create_resource()
|
||||||
self._add_data()
|
self._add_data()
|
||||||
@ -188,6 +246,19 @@ class TestTrackedResource(testlib_api.SqlTestCase):
|
|||||||
mock_set_quota_usage.assert_called_once_with(
|
mock_set_quota_usage.assert_called_once_with(
|
||||||
self.context, self.resource, self.tenant_id, in_use=2)
|
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):
|
def test_add_delete_data_triggers_event(self):
|
||||||
res = self._create_resource()
|
res = self._create_resource()
|
||||||
other_res = self._create_other_resource()
|
other_res = self._create_other_resource()
|
||||||
|
@ -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``.
|
Loading…
x
Reference in New Issue
Block a user