From cbba65ac913cef3ef7daf711d8217a445c8662f4 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Thu, 13 Oct 2022 22:36:11 -0700 Subject: quotas: Add account-level per-policy quotas Reseller admins can set new headers on accounts like X-Account-Quota-Bytes-Policy-: This may be done to limit consumption of a faster, all-flash policy, for example. This is independent of the existing X-Account-Meta-Quota-Bytes header, which continues to limit the total storage for an account across all policies. Change-Id: Ib25c2f667e5b81301f8c67375644981a13487cfe --- doc/source/api/container_quotas.rst | 2 + etc/proxy-server.conf-sample | 4 +- swift/common/middleware/account_quotas.py | 138 +++++++++++++++------ test/unit/common/middleware/test_account_quotas.py | 90 ++++++++++++++ 4 files changed, 192 insertions(+), 42 deletions(-) diff --git a/doc/source/api/container_quotas.rst b/doc/source/api/container_quotas.rst index a41561274..9c58eef27 100644 --- a/doc/source/api/container_quotas.rst +++ b/doc/source/api/container_quotas.rst @@ -1,3 +1,5 @@ +.. _container_quotas: + ================ Container quotas ================ diff --git a/etc/proxy-server.conf-sample b/etc/proxy-server.conf-sample index d2db8d752..d893ff8d7 100644 --- a/etc/proxy-server.conf-sample +++ b/etc/proxy-server.conf-sample @@ -1110,11 +1110,11 @@ use = egg:swift#dlo # Time limit on GET requests (seconds) # 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] 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] use = egg:swift#account_quotas diff --git a/swift/common/middleware/account_quotas.py b/swift/common/middleware/account_quotas.py index cc2792fd4..0baf00635 100644 --- a/swift/common/middleware/account_quotas.py +++ b/swift/common/middleware/account_quotas.py @@ -19,9 +19,19 @@ given account quota (in bytes) is exceeded while DELETE requests are still allowed. ``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 -resellers. There is no quota limit if ``x-account-meta-quota-bytes`` is not -set. +store the overall account quota. Write requests to this metadata entry are +only permitted for resellers. There is no overall account quota limit if +``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-``. 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 ``/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, \ HTTPRequestEntityTooLarge, wsgify 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): @@ -68,29 +79,49 @@ class AccountQuotaMiddleware(object): self.app = app def handle_account(self, request): - # account request, so we pay attention to the quotas - new_quota = request.headers.get( - 'X-Account-Meta-Quota-Bytes') - if request.headers.get( - 'X-Remove-Account-Meta-Quota-Bytes'): - new_quota = 0 # X-Remove dominates if both are present - - if request.environ.get('reseller_request') is True: - if new_quota and not new_quota.isdigit(): - return HTTPBadRequest() - return self.app - - # deny quota set for non-reseller - if new_quota is not None: - return HTTPForbidden() - return self.app + if request.method in ("POST", "PUT"): + # account request, so we pay attention to the quotas + new_quotas = {} + new_quotas[None] = request.headers.get( + 'X-Account-Meta-Quota-Bytes') + if request.headers.get( + 'X-Remove-Account-Meta-Quota-Bytes'): + new_quotas[None] = 0 # X-Remove dominates if both are present + + for policy in POLICIES: + tail = 'Account-Quota-Bytes-Policy-%s' % policy.name + if request.headers.get('X-Remove-' + tail): + new_quotas[policy.idx] = 0 + else: + quota = request.headers.pop('X-' + tail, None) + new_quotas[policy.idx] = quota + + if request.environ.get('reseller_request') is True: + if any(quota and not quota.isdigit() + for quota in new_quotas.values()): + 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 def __call__(self, request): - if request.method not in ("POST", "PUT"): - return self.app - try: ver, account, container, obj = request.split_path( 2, 4, rest_with_last=True) @@ -102,7 +133,7 @@ class AccountQuotaMiddleware(object): # container or object request; even if the quota headers are set # in the request, they're meaningless - if request.method == "POST" or not obj: + if not (request.method == "PUT" and obj): return self.app # OK, object PUT @@ -110,6 +141,7 @@ class AccountQuotaMiddleware(object): # but resellers aren't constrained by quotas :-) return self.app + # Object PUT request content_length = (request.content_length or 0) account_info = get_account_info(request.environ, self.app, @@ -119,24 +151,50 @@ class AccountQuotaMiddleware(object): try: quota = int(account_info['meta'].get('quota-bytes', -1)) except ValueError: - return self.app - 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'] + quota = -1 + if quota >= 0: + 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'] + + 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 - def reject_authorize(*args, **kwargs): - aresp = orig_authorize(*args, **kwargs) - 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 - request.environ['swift.authorize'] = reject_authorize - else: - return resp return self.app diff --git a/test/unit/common/middleware/test_account_quotas.py b/test/unit/common/middleware/test_account_quotas.py index 9ff0ff277..feb637c31 100644 --- a/test/unit/common/middleware/test_account_quotas.py +++ b/test/unit/common/middleware/test_account_quotas.py @@ -18,6 +18,7 @@ from swift.common.swob import Request, wsgify, HTTPForbidden, HTTPOk, \ from swift.common.middleware import account_quotas, copy +from test.unit import patch_policies from test.unit.common.middleware.helpers import FakeSwift @@ -53,6 +54,8 @@ class TestAccountQuota(unittest.TestCase): self.app = FakeSwift() self.app.register('HEAD', '/v1/a', HTTPOk, { '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('PUT', '/v1/a/c/o', HTTPOk, {}) @@ -128,6 +131,48 @@ class TestAccountQuota(unittest.TestCase): self.assertEqual(res.status_int, 413) 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): self.app.register('HEAD', '/v1/a', HTTPOk, { 'x-account-bytes-used': '1000', @@ -335,6 +380,19 @@ class TestAccountQuota(unittest.TestCase): 'reseller_request': True}) res = req.get_response(app) 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): app = account_quotas.AccountQuotaMiddleware(self.app) @@ -345,6 +403,18 @@ class TestAccountQuota(unittest.TestCase): 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '100'}) res = req.get_response(app) 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): app = account_quotas.AccountQuotaMiddleware(self.app) @@ -356,6 +426,24 @@ class TestAccountQuota(unittest.TestCase): '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-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): app = account_quotas.AccountQuotaMiddleware(self.app) @@ -414,6 +502,8 @@ class AccountQuotaCopyingTestCases(unittest.TestCase): self.headers = [] self.app = FakeSwift() 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, { 'content-length': '1000'}) self.aq_filter = account_quotas.filter_factory({})(self.app) -- cgit v1.2.1