Add Enforcer.calculate_usage()

In multiple situations, it is necessary to be able to probe the limits
set for a project without actually enforcing. Examples:

1. Exposing a usage API where we want to not only report the current
   usage, but the limit as well. Otherwise clients have to do their
   own calls to keystone and correlation to get a single integer
   limit value, which we should be able to expose for them.
2. When checking quota as part of a long-running process of consuming
   an unbounded data stream, we need to be able to determine how much
   quota remains so that we can stop the transfer if we exceed the
   limit. Without this, we have to periodically call to keystone
   during the transfer, which is expensive and could fail.

This patch adds a calculate_usage() method to the Enforcer which
calculates the usage using the enforcement model and returns a
mapping of resource names to namedtuples that contain limit and usage
information.

Change-Id: Ic0632cc5ec52aefb85a04f879651963bfa54dcbe
This commit is contained in:
Dan Smith 2021-06-04 08:00:41 -07:00
parent efc26ae724
commit 1175b0f7c1
3 changed files with 138 additions and 3 deletions

View File

@ -138,3 +138,37 @@ Here is a simple usage of limit enforcement
# What to do in case of limit exception, e contain a list of # What to do in case of limit exception, e contain a list of
# resource over quota # resource over quota
logging.error(e) logging.error(e)
Check a limit
-------------
Another usage pattern is to check a limit and usage for a given
project, outside the scope of enforcement. This may be useful in a
reporting API to be able to expose to a user the limit and usage
information that the enforcer would use to judge a resource
consumption event.
.. note::
This should ideally not be used to provide your own enforcement of
limits, but rather for reporting or planning purposes.
Here is a simple usage of limit reporting
.. code-block:: python
import logging
from oslo_limit import limit
# Callback function who need to return resource usage for each
# resource asked in resources_names, for a given project_id
def callback(project_id, resource_names):
return {x: get_resource_usage_by_project(x, project_id) for x in resource_names}
enforcer = limit.Enforcer(callback)
usage = enforcer.calculate_usage('project_uuid', ['my_resource'])
logging.info('%s using %i out of %i allowed %s resource' % (
'project_uuid',
usage['my_resource'].usage,
usage['my_resource'].limit,
'my_resource'))

View File

@ -12,6 +12,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from collections import namedtuple
from keystoneauth1 import exceptions as ksa_exceptions from keystoneauth1 import exceptions as ksa_exceptions
from keystoneauth1 import loading from keystoneauth1 import loading
from openstack import connection from openstack import connection
@ -27,6 +29,9 @@ _SDK_CONNECTION = None
opts.register_opts(CONF) opts.register_opts(CONF)
ProjectUsage = namedtuple('ProjectUsage', ['limit', 'usage'])
def _get_keystone_connection(): def _get_keystone_connection():
global _SDK_CONNECTION global _SDK_CONNECTION
if not _SDK_CONNECTION: if not _SDK_CONNECTION:
@ -124,6 +129,46 @@ class Enforcer(object):
self.model.enforce(project_id, deltas) self.model.enforce(project_id, deltas)
def calculate_usage(self, project_id, resources_to_check):
"""Calculate resource usage and limits for resources_to_check.
From the list of resources_to_check, we collect the project's
limit and current usage for each, exactly like we would for
enforce(). This is useful for reporting current project usage
and limits in a situation where enforcement is not desired.
This should *not* be used to conduct custom enforcement, but
rather only for reporting.
:param project_id: The project for which to check usage and limits.
:type project_id: string
:param resources_to_check: A list of resource names to query.
:type resources_to_check: list
:returns: A dictionary of name:limit.ProjectUsage for the
requested names against the provided project.
"""
if not project_id or not isinstance(project_id, str):
msg = 'project_id must be a non-empty string.'
raise ValueError(msg)
msg = ('resources_to_check must be non-empty sequence of '
'resource name strings')
try:
if len(resources_to_check) == 0:
raise ValueError(msg)
except TypeError:
raise ValueError(msg)
for resource_name in resources_to_check:
if not isinstance(resource_name, str):
raise ValueError(msg)
limits = self.model.get_project_limits(project_id, resources_to_check)
usage = self.model.get_project_usage(project_id, resources_to_check)
return {resource: ProjectUsage(limit, usage[resource])
for resource, limit in dict(limits).items()}
class _FlatEnforcer(object): class _FlatEnforcer(object):
@ -133,14 +178,21 @@ class _FlatEnforcer(object):
self._usage_callback = usage_callback self._usage_callback = usage_callback
self._utils = _EnforcerUtils() self._utils = _EnforcerUtils()
def get_project_limits(self, project_id, resources_to_check):
return self._utils.get_project_limits(project_id, resources_to_check)
def get_project_usage(self, project_id, resources_to_check):
return self._usage_callback(project_id, resources_to_check)
def enforce(self, project_id, deltas): def enforce(self, project_id, deltas):
resources_to_check = list(deltas.keys()) resources_to_check = list(deltas.keys())
# Always check the limits in the same order, for predictable errors # Always check the limits in the same order, for predictable errors
resources_to_check.sort() resources_to_check.sort()
project_limits = self._utils.get_project_limits(project_id, project_limits = self.get_project_limits(project_id,
resources_to_check)
current_usage = self.get_project_usage(project_id,
resources_to_check) resources_to_check)
current_usage = self._usage_callback(project_id, resources_to_check)
self._utils.enforce_limits(project_id, project_limits, self._utils.enforce_limits(project_id, project_limits,
current_usage, deltas) current_usage, deltas)
@ -153,6 +205,12 @@ class _StrictTwoLevelEnforcer(object):
def __init__(self, usage_callback): def __init__(self, usage_callback):
self._usage_callback = usage_callback self._usage_callback = usage_callback
def get_project_limits(self, project_id, resources_to_check):
raise NotImplementedError()
def get_project_usage(self, project_id, resources_to_check):
raise NotImplementedError()
def enforce(self, project_id, deltas): def enforce(self, project_id, deltas):
raise NotImplementedError() raise NotImplementedError()

View File

@ -125,6 +125,49 @@ class TestEnforcer(base.BaseTestCase):
mock_enforce.assert_called_once_with(project_id, deltas) mock_enforce.assert_called_once_with(project_id, deltas)
@mock.patch.object(limit._EnforcerUtils, "get_project_limits")
def test_calculate_usage(self, mock_get_limits):
mock_usage = mock.MagicMock()
mock_usage.return_value = {'a': 1, 'b': 2}
project_id = uuid.uuid4().hex
mock_get_limits.return_value = [('a', 10), ('b', 5)]
expected = {
'a': limit.ProjectUsage(10, 1),
'b': limit.ProjectUsage(5, 2),
}
enforcer = limit.Enforcer(mock_usage)
self.assertEqual(expected, enforcer.calculate_usage(project_id,
['a', 'b']))
def test_calculate_usage_bad_params(self):
enforcer = limit.Enforcer(mock.MagicMock())
# Non-string project_id
self.assertRaises(ValueError,
enforcer.calculate_usage,
None, ['foo'])
self.assertRaises(ValueError,
enforcer.calculate_usage,
123, ['foo'])
# Zero-length resources_to_check
self.assertRaises(ValueError,
enforcer.calculate_usage,
'project', [])
# Non-sequence resources_to_check
self.assertRaises(ValueError,
enforcer.calculate_usage,
'project', 123)
# Invalid non-string value in resources_to_check
self.assertRaises(ValueError,
enforcer.calculate_usage,
'project', ['a', 123, 'b'])
class TestFlatEnforcer(base.BaseTestCase): class TestFlatEnforcer(base.BaseTestCase):
def setUp(self): def setUp(self):