Merge "quotas: Add account-level per-policy quotas"

This commit is contained in:
Zuul 2023-03-24 03:49:04 +00:00 committed by Gerrit Code Review
commit 6d3d419715
4 changed files with 190 additions and 40 deletions

View File

@ -1,3 +1,5 @@
.. _container_quotas:
================ ================
Container quotas Container quotas
================ ================

View File

@ -1110,11 +1110,11 @@ use = egg:swift#dlo
# Time limit on GET requests (seconds) # Time limit on GET requests (seconds)
# max_get_time = 86400 # max_get_time = 86400
# Note: Put after auth in the pipeline. # Note: Put after auth and server-side copy in the pipeline.
[filter:container-quotas] [filter:container-quotas]
use = egg:swift#container_quotas use = egg:swift#container_quotas
# Note: Put after auth in the pipeline. # Note: Put after auth and server-side copy in the pipeline.
[filter:account-quotas] [filter:account-quotas]
use = egg:swift#account_quotas use = egg:swift#account_quotas

View File

@ -19,9 +19,19 @@ given account quota (in bytes) is exceeded while DELETE requests are still
allowed. allowed.
``account_quotas`` uses the ``x-account-meta-quota-bytes`` metadata entry to ``account_quotas`` uses the ``x-account-meta-quota-bytes`` metadata entry to
store the quota. Write requests to this metadata entry are only permitted for store the overall account quota. Write requests to this metadata entry are
resellers. There is no quota limit if ``x-account-meta-quota-bytes`` is not only permitted for resellers. There is no overall account quota limit if
set. ``x-account-meta-quota-bytes`` is not set.
Additionally, account quotas may be set for each storage policy, using metadata
of the form ``x-account-quota-bytes-policy-<policy name>``. Again, only
resellers may update these metadata, and there will be no limit for a
particular policy if the corresponding metadata is not set.
.. note::
Per-policy quotas need not sum to the overall account quota, and the sum of
all :ref:`container_quotas` for a given policy need not sum to the account's
policy quota.
The ``account_quotas`` middleware should be added to the pipeline in your The ``account_quotas`` middleware should be added to the pipeline in your
``/etc/swift/proxy-server.conf`` file just after any auth middleware. ``/etc/swift/proxy-server.conf`` file just after any auth middleware.
@ -55,7 +65,8 @@ account size has been updated.
from swift.common.swob import HTTPForbidden, HTTPBadRequest, \ from swift.common.swob import HTTPForbidden, HTTPBadRequest, \
HTTPRequestEntityTooLarge, wsgify HTTPRequestEntityTooLarge, wsgify
from swift.common.registry import register_swift_info from swift.common.registry import register_swift_info
from swift.proxy.controllers.base import get_account_info from swift.common.storage_policy import POLICIES
from swift.proxy.controllers.base import get_account_info, get_container_info
class AccountQuotaMiddleware(object): class AccountQuotaMiddleware(object):
@ -68,29 +79,49 @@ class AccountQuotaMiddleware(object):
self.app = app self.app = app
def handle_account(self, request): def handle_account(self, request):
# account request, so we pay attention to the quotas if request.method in ("POST", "PUT"):
new_quota = request.headers.get( # account request, so we pay attention to the quotas
'X-Account-Meta-Quota-Bytes') new_quotas = {}
if request.headers.get( new_quotas[None] = request.headers.get(
'X-Remove-Account-Meta-Quota-Bytes'): 'X-Account-Meta-Quota-Bytes')
new_quota = 0 # X-Remove dominates if both are present if request.headers.get(
'X-Remove-Account-Meta-Quota-Bytes'):
new_quotas[None] = 0 # X-Remove dominates if both are present
if request.environ.get('reseller_request') is True: for policy in POLICIES:
if new_quota and not new_quota.isdigit(): tail = 'Account-Quota-Bytes-Policy-%s' % policy.name
return HTTPBadRequest() if request.headers.get('X-Remove-' + tail):
return self.app new_quotas[policy.idx] = 0
else:
quota = request.headers.pop('X-' + tail, None)
new_quotas[policy.idx] = quota
# deny quota set for non-reseller if request.environ.get('reseller_request') is True:
if new_quota is not None: if any(quota and not quota.isdigit()
return HTTPForbidden() for quota in new_quotas.values()):
return self.app return HTTPBadRequest()
for idx, quota in new_quotas.items():
if idx is None:
continue # For legacy reasons, it's in user meta
hdr = 'X-Account-Sysmeta-Quota-Bytes-Policy-%d' % idx
request.headers[hdr] = quota
elif any(quota is not None for quota in new_quotas.values()):
# deny quota set for non-reseller
return HTTPForbidden()
resp = request.get_response(self.app)
# Non-resellers can't update quotas, but they *can* see them
for policy in POLICIES:
infix = 'Quota-Bytes-Policy'
value = resp.headers.get('X-Account-Sysmeta-%s-%d' % (
infix, policy.idx))
if value:
resp.headers['X-Account-%s-%s' % (infix, policy.name)] = value
return resp
@wsgify @wsgify
def __call__(self, request): def __call__(self, request):
if request.method not in ("POST", "PUT"):
return self.app
try: try:
ver, account, container, obj = request.split_path( ver, account, container, obj = request.split_path(
2, 4, rest_with_last=True) 2, 4, rest_with_last=True)
@ -102,7 +133,7 @@ class AccountQuotaMiddleware(object):
# container or object request; even if the quota headers are set # container or object request; even if the quota headers are set
# in the request, they're meaningless # in the request, they're meaningless
if request.method == "POST" or not obj: if not (request.method == "PUT" and obj):
return self.app return self.app
# OK, object PUT # OK, object PUT
@ -110,6 +141,7 @@ class AccountQuotaMiddleware(object):
# but resellers aren't constrained by quotas :-) # but resellers aren't constrained by quotas :-)
return self.app return self.app
# Object PUT request
content_length = (request.content_length or 0) content_length = (request.content_length or 0)
account_info = get_account_info(request.environ, self.app, account_info = get_account_info(request.environ, self.app,
@ -119,24 +151,50 @@ class AccountQuotaMiddleware(object):
try: try:
quota = int(account_info['meta'].get('quota-bytes', -1)) quota = int(account_info['meta'].get('quota-bytes', -1))
except ValueError: except ValueError:
return self.app quota = -1
if quota < 0: if quota >= 0:
return self.app new_size = int(account_info['bytes']) + content_length
if quota < new_size:
resp = HTTPRequestEntityTooLarge(body='Upload exceeds quota.')
if 'swift.authorize' in request.environ:
orig_authorize = request.environ['swift.authorize']
new_size = int(account_info['bytes']) + content_length def reject_authorize(*args, **kwargs):
if quota < new_size: aresp = orig_authorize(*args, **kwargs)
resp = HTTPRequestEntityTooLarge(body='Upload exceeds quota.') if aresp:
if 'swift.authorize' in request.environ: return aresp
orig_authorize = request.environ['swift.authorize'] return resp
request.environ['swift.authorize'] = reject_authorize
def reject_authorize(*args, **kwargs): else:
aresp = orig_authorize(*args, **kwargs) return resp
if aresp:
return aresp container_info = get_container_info(request.environ, self.app,
swift_source='AQ')
if not container_info:
return self.app
policy_idx = container_info['storage_policy']
sysmeta_key = 'quota-bytes-policy-%s' % policy_idx
try:
policy_quota = int(account_info['sysmeta'].get(sysmeta_key, -1))
except ValueError:
policy_quota = -1
if policy_quota >= 0:
policy_stats = account_info['storage_policies'].get(policy_idx, {})
new_size = int(policy_stats.get('bytes', 0)) + content_length
if policy_quota < new_size:
resp = HTTPRequestEntityTooLarge(
body='Upload exceeds policy quota.')
if 'swift.authorize' in request.environ:
orig_authorize = request.environ['swift.authorize']
def reject_authorize(*args, **kwargs):
aresp = orig_authorize(*args, **kwargs)
if aresp:
return aresp
return resp
request.environ['swift.authorize'] = reject_authorize
else:
return resp return resp
request.environ['swift.authorize'] = reject_authorize
else:
return resp
return self.app return self.app

View File

@ -18,6 +18,7 @@ from swift.common.swob import Request, wsgify, HTTPForbidden, HTTPOk, \
from swift.common.middleware import account_quotas, copy from swift.common.middleware import account_quotas, copy
from test.unit import patch_policies
from test.unit.common.middleware.helpers import FakeSwift from test.unit.common.middleware.helpers import FakeSwift
@ -53,6 +54,8 @@ class TestAccountQuota(unittest.TestCase):
self.app = FakeSwift() self.app = FakeSwift()
self.app.register('HEAD', '/v1/a', HTTPOk, { self.app.register('HEAD', '/v1/a', HTTPOk, {
'x-account-bytes-used': '1000'}) 'x-account-bytes-used': '1000'})
self.app.register('HEAD', '/v1/a/c', HTTPOk, {
'x-backend-storage-policy-index': '1'})
self.app.register('POST', '/v1/a', HTTPOk, {}) self.app.register('POST', '/v1/a', HTTPOk, {})
self.app.register('PUT', '/v1/a/c/o', HTTPOk, {}) self.app.register('PUT', '/v1/a/c/o', HTTPOk, {})
@ -128,6 +131,48 @@ class TestAccountQuota(unittest.TestCase):
self.assertEqual(res.status_int, 413) self.assertEqual(res.status_int, 413)
self.assertEqual(res.body, b'Upload exceeds quota.') self.assertEqual(res.body, b'Upload exceeds quota.')
@patch_policies
def test_exceed_per_policy_quota(self):
self.app.register('HEAD', '/v1/a', HTTPOk, {
'x-account-bytes-used': '100',
'x-account-storage-policy-unu-bytes-used': '100',
'x-account-sysmeta-quota-bytes-policy-1': '10',
'x-account-meta-quota-bytes': '1000'})
app = account_quotas.AccountQuotaMiddleware(self.app)
cache = FakeCache(None)
req = Request.blank('/v1/a/c/o',
environ={'REQUEST_METHOD': 'PUT',
'swift.cache': cache})
res = req.get_response(app)
self.assertEqual(res.status_int, 413)
self.assertEqual(res.body, b'Upload exceeds policy quota.')
@patch_policies
def test_policy_quota_translation(self):
def do_test(method):
self.app.register(method, '/v1/a', HTTPOk, {
'x-account-bytes-used': '100',
'x-account-storage-policy-unu-bytes-used': '100',
'x-account-sysmeta-quota-bytes-policy-1': '10',
'x-account-meta-quota-bytes': '1000'})
app = account_quotas.AccountQuotaMiddleware(self.app)
cache = FakeCache(None)
req = Request.blank('/v1/a', method=method, environ={
'swift.cache': cache})
res = req.get_response(app)
self.assertEqual(res.status_int, 200)
self.assertEqual(res.headers.get(
'X-Account-Meta-Quota-Bytes'), '1000')
self.assertEqual(res.headers.get(
'X-Account-Sysmeta-Quota-Bytes-Policy-1'), '10')
self.assertEqual(res.headers.get(
'X-Account-Quota-Bytes-Policy-Unu'), '10')
self.assertEqual(res.headers.get(
'X-Account-Storage-Policy-Unu-Bytes-Used'), '100')
do_test('GET')
do_test('HEAD')
def test_exceed_quota_not_authorized(self): def test_exceed_quota_not_authorized(self):
self.app.register('HEAD', '/v1/a', HTTPOk, { self.app.register('HEAD', '/v1/a', HTTPOk, {
'x-account-bytes-used': '1000', 'x-account-bytes-used': '1000',
@ -335,6 +380,19 @@ class TestAccountQuota(unittest.TestCase):
'reseller_request': True}) 'reseller_request': True})
res = req.get_response(app) res = req.get_response(app)
self.assertEqual(res.status_int, 400) self.assertEqual(res.status_int, 400)
self.assertEqual(self.app.calls, [])
def test_invalid_policy_quota(self):
app = account_quotas.AccountQuotaMiddleware(self.app)
cache = FakeCache(None)
req = Request.blank('/v1/a', environ={
'REQUEST_METHOD': 'POST',
'swift.cache': cache,
'HTTP_X_ACCOUNT_QUOTA_BYTES_POLICY_POLICY_0': 'abc',
'reseller_request': True})
res = req.get_response(app)
self.assertEqual(res.status_int, 400)
self.assertEqual(self.app.calls, [])
def test_valid_quotas_admin(self): def test_valid_quotas_admin(self):
app = account_quotas.AccountQuotaMiddleware(self.app) app = account_quotas.AccountQuotaMiddleware(self.app)
@ -345,6 +403,18 @@ class TestAccountQuota(unittest.TestCase):
'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '100'}) 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '100'})
res = req.get_response(app) res = req.get_response(app)
self.assertEqual(res.status_int, 403) self.assertEqual(res.status_int, 403)
self.assertEqual(self.app.calls, [])
def test_valid_policy_quota_admin(self):
app = account_quotas.AccountQuotaMiddleware(self.app)
cache = FakeCache(None)
req = Request.blank('/v1/a', environ={
'REQUEST_METHOD': 'POST',
'swift.cache': cache,
'HTTP_X_ACCOUNT_QUOTA_BYTES_POLICY_POLICY_0': '100'})
res = req.get_response(app)
self.assertEqual(res.status_int, 403)
self.assertEqual(self.app.calls, [])
def test_valid_quotas_reseller(self): def test_valid_quotas_reseller(self):
app = account_quotas.AccountQuotaMiddleware(self.app) app = account_quotas.AccountQuotaMiddleware(self.app)
@ -356,6 +426,24 @@ class TestAccountQuota(unittest.TestCase):
'reseller_request': True}) 'reseller_request': True})
res = req.get_response(app) res = req.get_response(app)
self.assertEqual(res.status_int, 200) self.assertEqual(res.status_int, 200)
self.assertEqual(self.app.calls_with_headers, [
('POST', '/v1/a', {'Host': 'localhost:80',
'X-Account-Meta-Quota-Bytes': '100'})])
def test_valid_policy_quota_reseller(self):
app = account_quotas.AccountQuotaMiddleware(self.app)
cache = FakeCache(None)
req = Request.blank('/v1/a', environ={
'REQUEST_METHOD': 'POST',
'swift.cache': cache,
'HTTP_X_ACCOUNT_QUOTA_BYTES_POLICY_POLICY_0': '100',
'reseller_request': True})
res = req.get_response(app)
self.assertEqual(res.status_int, 200)
self.assertEqual(self.app.calls_with_headers, [
('POST', '/v1/a', {
'Host': 'localhost:80',
'X-Account-Sysmeta-Quota-Bytes-Policy-0': '100'})])
def test_delete_quotas(self): def test_delete_quotas(self):
app = account_quotas.AccountQuotaMiddleware(self.app) app = account_quotas.AccountQuotaMiddleware(self.app)
@ -414,6 +502,8 @@ class AccountQuotaCopyingTestCases(unittest.TestCase):
self.headers = [] self.headers = []
self.app = FakeSwift() self.app = FakeSwift()
self.app.register('HEAD', '/v1/a', HTTPOk, self.headers) self.app.register('HEAD', '/v1/a', HTTPOk, self.headers)
self.app.register('HEAD', '/v1/a/c', HTTPOk, {
'x-backend-storage-policy-index': '1'})
self.app.register('GET', '/v1/a/c2/o2', HTTPOk, { self.app.register('GET', '/v1/a/c2/o2', HTTPOk, {
'content-length': '1000'}) 'content-length': '1000'})
self.aq_filter = account_quotas.filter_factory({})(self.app) self.aq_filter = account_quotas.filter_factory({})(self.app)