Add unified quotas infrastructure
This adds some infrastructure to be able to query and honor limits declared in keystone. It adds a single initial quota value for the total size of all active images for bootstrapping the tests. Checking these values is controlled by a new configuration option that globally enables and disables the checking, defaulting to False. Related to blueprint glance-unified-quotas Change-Id: I8d8f4aaed465486e80be85bc9a5d2c2be7f1ecad
This commit is contained in:
parent
fa1bc2f904
commit
06e6542f15
@ -441,14 +441,29 @@ TeraBytes respectively. Note that there should not be any space between the
|
|||||||
value and unit. Value ``0`` signifies no quota enforcement. Negative values
|
value and unit. Value ``0`` signifies no quota enforcement. Negative values
|
||||||
are invalid and result in errors.
|
are invalid and result in errors.
|
||||||
|
|
||||||
|
This has no effect if ``use_keystone_limits`` is enabled.
|
||||||
|
|
||||||
Possible values:
|
Possible values:
|
||||||
* A string that is a valid concatenation of a non-negative integer
|
* A string that is a valid concatenation of a non-negative integer
|
||||||
representing the storage value and an optional string literal
|
representing the storage value and an optional string literal
|
||||||
representing storage units as mentioned above.
|
representing storage units as mentioned above.
|
||||||
|
|
||||||
Related options:
|
Related options:
|
||||||
* None
|
* use_keystone_limits
|
||||||
|
|
||||||
|
""")),
|
||||||
|
cfg.BoolOpt('use_keystone_limits', default=False,
|
||||||
|
help=_("""
|
||||||
|
Utilize per-tenant resource limits registered in Keystone.
|
||||||
|
|
||||||
|
Enabling this feature will cause Glance to retrieve limits set in keystone
|
||||||
|
for resource consumption and enforce them against API users. Before turning
|
||||||
|
this on, the limits need to be registered in Keystone or all quotas will be
|
||||||
|
considered to be zero, and thus reject all new resource requests.
|
||||||
|
|
||||||
|
These per-tenant resource limits are independent from the static
|
||||||
|
global ones configured in this config file. If this is enabled, the
|
||||||
|
relevant static global limits will be ignored.
|
||||||
""")),
|
""")),
|
||||||
cfg.HostAddressOpt('pydev_worker_debug_host',
|
cfg.HostAddressOpt('pydev_worker_debug_host',
|
||||||
sample_default='localhost',
|
sample_default='localhost',
|
||||||
|
101
glance/quota/keystone.py
Normal file
101
glance/quota/keystone.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Copyright 2021 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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_limit import exception as ol_exc
|
||||||
|
from oslo_limit import limit
|
||||||
|
from oslo_log import log as logging
|
||||||
|
from oslo_utils import units
|
||||||
|
|
||||||
|
from glance.common import exception
|
||||||
|
from glance.db.sqlalchemy import api as db
|
||||||
|
from glance.i18n import _LE
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
CONF.import_opt('use_keystone_limits', 'glance.common.config')
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
limit.opts.register_opts(CONF)
|
||||||
|
|
||||||
|
QUOTA_IMAGE_SIZE_TOTAL = 'image_size_total'
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_some(context, project_id, quota_value_fns, deltas):
|
||||||
|
"""Helper method to enforce a set of quota values.
|
||||||
|
|
||||||
|
:param context: The RequestContext
|
||||||
|
:param project_id: The project_id of the tenant being checked
|
||||||
|
:param get_value_fns: A mapping of quota names to functions that will be
|
||||||
|
called with no arguments to return the numerical
|
||||||
|
value representing current usage.
|
||||||
|
:param deltas: A mapping of quota names to the amount of resource being
|
||||||
|
requested for each (to be added to the current usage before
|
||||||
|
determining if over-quota).
|
||||||
|
:raises: exception.LimitExceeded if the current usage is over the defined
|
||||||
|
limit.
|
||||||
|
:returns: None if the tenant is not currently over their quota.
|
||||||
|
"""
|
||||||
|
if not CONF.use_keystone_limits:
|
||||||
|
return
|
||||||
|
|
||||||
|
def callback(project_id, resource_names):
|
||||||
|
return {name: quota_value_fns[name]()
|
||||||
|
for name in resource_names}
|
||||||
|
|
||||||
|
enforcer = limit.Enforcer(callback)
|
||||||
|
try:
|
||||||
|
enforcer.enforce(project_id,
|
||||||
|
{quota_name: deltas.get(quota_name, 0)
|
||||||
|
for quota_name in quota_value_fns})
|
||||||
|
except ol_exc.ProjectOverLimit as e:
|
||||||
|
raise exception.LimitExceeded(body=str(e))
|
||||||
|
except ol_exc.SessionInitError as e:
|
||||||
|
LOG.error(_LE('Failed to initialize oslo_limit, likely due to '
|
||||||
|
'incorrect or insufficient configuration: %(err)s'),
|
||||||
|
{'err': str(e)})
|
||||||
|
# We could just raise LimitExceeded here, but a 500 is
|
||||||
|
# appropriate for incorrect server-side configuration, so we
|
||||||
|
# re-raise here after the above error message to make sure we
|
||||||
|
# are noticed.
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_one(context, project_id, quota_name, get_value_fn, delta=0):
|
||||||
|
"""Helper method to enforce a single named quota value.
|
||||||
|
|
||||||
|
:param context: The RequestContext
|
||||||
|
:param project_id: The project_id of the tenant being checked
|
||||||
|
:param quota_name: One of the quota names defined above
|
||||||
|
:param get_value_fn: A function that will be called with no arguments to
|
||||||
|
return the numerical value representing current usage.
|
||||||
|
:param delta: The amount of resource being requested (to be added to the
|
||||||
|
current usage before determining if over-quota).
|
||||||
|
:raises: exception.LimitExceeded if the current usage is over the defined
|
||||||
|
limit.
|
||||||
|
:returns: None if the tenant is not currently over their quota.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _enforce_some(context, project_id,
|
||||||
|
{quota_name: get_value_fn},
|
||||||
|
{quota_name: delta})
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_image_size_total(context, project_id, delta=0):
|
||||||
|
"""Enforce the image_size_total quota.
|
||||||
|
|
||||||
|
This enforces the total image size quota for the supplied project_id.
|
||||||
|
"""
|
||||||
|
_enforce_one(
|
||||||
|
context, project_id, QUOTA_IMAGE_SIZE_TOTAL,
|
||||||
|
lambda: db.user_get_storage_usage(context, project_id) // units.Mi,
|
||||||
|
delta=delta)
|
@ -15,10 +15,13 @@
|
|||||||
|
|
||||||
import logging as std_logging
|
import logging as std_logging
|
||||||
import os
|
import os
|
||||||
|
from unittest import mock
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import fixtures as pyfixtures
|
import fixtures as pyfixtures
|
||||||
|
from openstack.identity.v3 import endpoint
|
||||||
|
from openstack.identity.v3 import limit as klimit
|
||||||
|
from oslo_limit import limit
|
||||||
|
|
||||||
_TRUE_VALUES = ('True', 'true', '1', 'yes')
|
_TRUE_VALUES = ('True', 'true', '1', 'yes')
|
||||||
|
|
||||||
@ -136,3 +139,32 @@ class WarningsFixture(pyfixtures.Fixture):
|
|||||||
'error', message="Property '.*' has moved to '.*'")
|
'error', message="Property '.*' has moved to '.*'")
|
||||||
|
|
||||||
self.addCleanup(warnings.resetwarnings)
|
self.addCleanup(warnings.resetwarnings)
|
||||||
|
|
||||||
|
|
||||||
|
class KeystoneQuotaFixture(pyfixtures.Fixture):
|
||||||
|
def __init__(self, **defaults):
|
||||||
|
self.defaults = defaults
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(KeystoneQuotaFixture, self).setUp()
|
||||||
|
|
||||||
|
self.mock_conn = mock.MagicMock()
|
||||||
|
limit._SDK_CONNECTION = self.mock_conn
|
||||||
|
|
||||||
|
mock_gem = self.useFixture(
|
||||||
|
pyfixtures.MockPatch('oslo_limit.limit.Enforcer.'
|
||||||
|
'_get_enforcement_model')).mock
|
||||||
|
mock_gem.return_value = 'flat'
|
||||||
|
|
||||||
|
fake_endpoint = endpoint.Endpoint()
|
||||||
|
fake_endpoint.service_id = "service_id"
|
||||||
|
fake_endpoint.region_id = "region_id"
|
||||||
|
self.mock_conn.get_endpoint.return_value = fake_endpoint
|
||||||
|
|
||||||
|
def fake_limits(service_id, region_id, resource_name, project_id):
|
||||||
|
this_limit = klimit.Limit()
|
||||||
|
this_limit.resource_name = resource_name
|
||||||
|
this_limit.resource_limit = self.defaults[resource_name]
|
||||||
|
return iter([this_limit])
|
||||||
|
|
||||||
|
self.mock_conn.limits.side_effect = fake_limits
|
||||||
|
@ -13,10 +13,12 @@
|
|||||||
# 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 copy
|
import copy
|
||||||
|
import fixtures
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from oslo_limit import exception as ol_exc
|
||||||
from oslo_utils import encodeutils
|
from oslo_utils import encodeutils
|
||||||
from oslo_utils import units
|
from oslo_utils import units
|
||||||
|
|
||||||
@ -26,6 +28,8 @@ from six.moves import range
|
|||||||
from glance.common import exception
|
from glance.common import exception
|
||||||
from glance.common import store_utils
|
from glance.common import store_utils
|
||||||
import glance.quota
|
import glance.quota
|
||||||
|
from glance.quota import keystone as ks_quota
|
||||||
|
from glance.tests.unit import fixtures as glance_fixtures
|
||||||
from glance.tests.unit import utils as unit_test_utils
|
from glance.tests.unit import utils as unit_test_utils
|
||||||
from glance.tests import utils as test_utils
|
from glance.tests import utils as test_utils
|
||||||
|
|
||||||
@ -735,3 +739,136 @@ class TestImageLocationQuotas(test_utils.BaseTestCase):
|
|||||||
self.config(image_location_quota=0)
|
self.config(image_location_quota=0)
|
||||||
self.image.locations.remove(location1)
|
self.image.locations.remove(location1)
|
||||||
self.assertEqual(0, len(self.image.locations))
|
self.assertEqual(0, len(self.image.locations))
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageKeystoneQuota(test_utils.BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestImageKeystoneQuota, self).setUp()
|
||||||
|
|
||||||
|
default_limits = {
|
||||||
|
ks_quota.QUOTA_IMAGE_SIZE_TOTAL: 500,
|
||||||
|
'another_limit': 2,
|
||||||
|
}
|
||||||
|
ksqf = glance_fixtures.KeystoneQuotaFixture(**default_limits)
|
||||||
|
|
||||||
|
self.useFixture(ksqf)
|
||||||
|
|
||||||
|
self.db_api = unit_test_utils.FakeDB()
|
||||||
|
self.useFixture(fixtures.MockPatch('glance.quota.keystone.db',
|
||||||
|
self.db_api))
|
||||||
|
|
||||||
|
def _create_fake_image(self, context, size):
|
||||||
|
location_count = 2
|
||||||
|
locations = []
|
||||||
|
for i in range(location_count):
|
||||||
|
locations.append({'url': 'file:///g/there/it/is%d' % i,
|
||||||
|
'status': 'active',
|
||||||
|
'metadata': {}})
|
||||||
|
image_values = {'id': str(uuid.uuid4()), 'owner': context.owner,
|
||||||
|
'status': 'active', 'size': size * units.Mi,
|
||||||
|
'locations': locations}
|
||||||
|
self.db_api.image_create(context, image_values)
|
||||||
|
|
||||||
|
def test_enforce_overquota(self):
|
||||||
|
# Check that a single large image with multiple locations will
|
||||||
|
# trip the quota check.
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 300)
|
||||||
|
exc = self.assertRaises(exception.LimitExceeded,
|
||||||
|
ks_quota.enforce_image_size_total,
|
||||||
|
context, context.owner)
|
||||||
|
self.assertIn('image_size_total is over limit of 500', str(exc))
|
||||||
|
|
||||||
|
def test_enforce_overquota_with_delta(self):
|
||||||
|
# Check that delta is honored, if used.
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 200)
|
||||||
|
ks_quota.enforce_image_size_total(context, context.owner)
|
||||||
|
ks_quota.enforce_image_size_total(context, context.owner,
|
||||||
|
delta=50)
|
||||||
|
self.assertRaises(exception.LimitExceeded,
|
||||||
|
ks_quota.enforce_image_size_total,
|
||||||
|
context, context.owner, delta=200)
|
||||||
|
|
||||||
|
def test_enforce_overquota_disabled(self):
|
||||||
|
# Just like the overquota case above, but without being enabled,
|
||||||
|
# so no failure
|
||||||
|
self.config(use_keystone_limits=False)
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 300)
|
||||||
|
# Does not raise because keystone limits are disabled
|
||||||
|
ks_quota.enforce_image_size_total(context, context.owner)
|
||||||
|
|
||||||
|
def test_enforce_overquota_multiple(self):
|
||||||
|
# Check that multiple images with a combined amount
|
||||||
|
# (2*2*150=600) over the quota will trip the quota check.
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 150)
|
||||||
|
self._create_fake_image(context, 150)
|
||||||
|
exc = self.assertRaises(exception.LimitExceeded,
|
||||||
|
ks_quota.enforce_image_size_total,
|
||||||
|
context, context.owner)
|
||||||
|
self.assertIn('image_size_total is over limit of 500', str(exc))
|
||||||
|
|
||||||
|
def test_enforce_underquota(self):
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 100)
|
||||||
|
# We are under quota, so no exception expected
|
||||||
|
ks_quota.enforce_image_size_total(context, context.owner)
|
||||||
|
|
||||||
|
def test_enforce_underquota_with_others_over_quota(self):
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
# Put the first tenant over quota
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 300)
|
||||||
|
self._create_fake_image(context, 300)
|
||||||
|
|
||||||
|
# Create an image for another tenant that is not over quota
|
||||||
|
other_context = FakeContext()
|
||||||
|
other_context.owner = 'someone_else'
|
||||||
|
self._create_fake_image(other_context, 100)
|
||||||
|
# This tenant should pass the quota check, because it is under quota,
|
||||||
|
# even though the other is over.
|
||||||
|
ks_quota.enforce_image_size_total(other_context, other_context.owner)
|
||||||
|
|
||||||
|
def test_enforce_multiple_limits_under_quota(self):
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
# Make sure that we can call the multi-limit handler and pass when
|
||||||
|
# we are under quota.
|
||||||
|
ks_quota._enforce_some(context, context.owner,
|
||||||
|
{ks_quota.QUOTA_IMAGE_SIZE_TOTAL: lambda: 200,
|
||||||
|
'another_limit': lambda: 1},
|
||||||
|
{'another_limit': 1})
|
||||||
|
|
||||||
|
def test_enforce_multiple_limits_over_quota(self):
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
context = FakeContext()
|
||||||
|
# Make sure that even if one of a multi-limit call is over
|
||||||
|
# quota, we get the exception.
|
||||||
|
self.assertRaises(exception.LimitExceeded,
|
||||||
|
ks_quota._enforce_some,
|
||||||
|
context, context.owner,
|
||||||
|
{ks_quota.QUOTA_IMAGE_SIZE_TOTAL: lambda: 200,
|
||||||
|
'another_limit': lambda: 1},
|
||||||
|
{'another_limit': 5})
|
||||||
|
|
||||||
|
@mock.patch('oslo_limit.limit.Enforcer')
|
||||||
|
@mock.patch.object(ks_quota, 'LOG')
|
||||||
|
def test_oslo_limit_config_fail(self, mock_LOG, mock_enforcer):
|
||||||
|
self.config(use_keystone_limits=True)
|
||||||
|
mock_enforcer.return_value.enforce.side_effect = (
|
||||||
|
ol_exc.SessionInitError('test'))
|
||||||
|
context = FakeContext()
|
||||||
|
self._create_fake_image(context, 100)
|
||||||
|
self.assertRaises(ol_exc.SessionInitError,
|
||||||
|
ks_quota.enforce_image_size_total,
|
||||||
|
context, context.owner)
|
||||||
|
mock_LOG.error.assert_called_once_with(
|
||||||
|
'Failed to initialize oslo_limit, likely due to '
|
||||||
|
'incorrect or insufficient configuration: %(err)s',
|
||||||
|
{'err': "Can't initialise OpenStackSDK session: test."})
|
||||||
|
@ -38,6 +38,7 @@ six>=1.11.0 # MIT
|
|||||||
|
|
||||||
oslo.db>=5.0.0 # Apache-2.0
|
oslo.db>=5.0.0 # Apache-2.0
|
||||||
oslo.i18n>=5.0.0 # Apache-2.0
|
oslo.i18n>=5.0.0 # Apache-2.0
|
||||||
|
oslo.limit>=1.0.0 # Apache-2.0
|
||||||
oslo.log>=4.3.0 # Apache-2.0
|
oslo.log>=4.3.0 # Apache-2.0
|
||||||
oslo.messaging>=5.29.0,!=9.0.0 # Apache-2.0
|
oslo.messaging>=5.29.0,!=9.0.0 # Apache-2.0
|
||||||
oslo.middleware>=3.31.0 # Apache-2.0
|
oslo.middleware>=3.31.0 # Apache-2.0
|
||||||
|
Loading…
Reference in New Issue
Block a user