diff --git a/api-ref/source/v2/discovery.inc b/api-ref/source/v2/discovery.inc index dff7a7f404..1344280249 100644 --- a/api-ref/source/v2/discovery.inc +++ b/api-ref/source/v2/discovery.inc @@ -103,3 +103,26 @@ Response Example .. literalinclude:: samples/stores-list-response.json :language: json + +Quota usage +~~~~~~~~~~~ + +.. rest_method:: GET /v2/info/usage + +The user's quota and current usage are displayed, if enabled by +server-side configuration. + +Normal response codes: 200 + +Request +------- + +There are no request parameters. + +This call does not allow a request body. + +Response Example +---------------- + +.. literalinclude:: samples/usage-response.json + :language: json diff --git a/api-ref/source/v2/samples/usage-response.json b/api-ref/source/v2/samples/usage-response.json new file mode 100644 index 0000000000..961d8153c8 --- /dev/null +++ b/api-ref/source/v2/samples/usage-response.json @@ -0,0 +1,20 @@ +{ + "usage": { + "image_size_total": { + "limit": 1024, + "usage": 256 + }, + "image_count_total": { + "limit": 10, + "usage": 2 + }, + "image_stage_total": { + "limit": 512, + "usage": 0 + }, + "image_count_uploading": { + "limit": 2, + "usage": 0 + } + } +} diff --git a/glance/api/middleware/version_negotiation.py b/glance/api/middleware/version_negotiation.py index d645b0ca32..ffa30347c7 100644 --- a/glance/api/middleware/version_negotiation.py +++ b/glance/api/middleware/version_negotiation.py @@ -82,6 +82,7 @@ class VersionNegotiationFilter(wsgi.Middleware): allowed_versions['v2.6'] = 2 allowed_versions['v2.7'] = 2 allowed_versions['v2.9'] = 2 + allowed_versions['v2.13'] = 2 if CONF.enabled_backends: allowed_versions['v2.8'] = 2 allowed_versions['v2.10'] = 2 diff --git a/glance/api/v2/discovery.py b/glance/api/v2/discovery.py index 1002c8a5db..e8c2caf751 100644 --- a/glance/api/v2/discovery.py +++ b/glance/api/v2/discovery.py @@ -13,12 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy + from oslo_config import cfg +import oslo_serialization.jsonutils as json import webob.exc from glance.common import wsgi +import glance.db from glance.i18n import _ - +from glance.quota import keystone as ks_quota CONF = cfg.CONF @@ -63,6 +67,49 @@ class InfoController(object): return {'stores': backends} + def get_usage(self, req): + project_usage = ks_quota.get_usage(req.context) + return {'usage': + {name: {'usage': usage.usage, + 'limit': usage.limit} + for name, usage in project_usage.items()}} + + +class ResponseSerializer(wsgi.JSONResponseSerializer): + def __init__(self, usage_schema=None): + super(ResponseSerializer, self).__init__() + self.schema = usage_schema or get_usage_schema() + + def get_usage(self, response, usage): + body = json.dumps(self.schema.filter(usage), ensure_ascii=False) + response.unicode_body = str(body) + response.content_type = 'application/json' + + +_USAGE_SCHEMA = { + 'usage': { + 'type': 'array', + 'items': { + 'type': 'object', + 'additionalProperties': True, + 'validation_data': { + 'type': 'object', + 'additonalProperties': False, + 'properties': { + 'usage': {'type': 'integer'}, + 'limit': {'type': 'integer'}, + }, + }, + }, + }, +} + + +def get_usage_schema(): + return glance.schema.Schema('usage', copy.deepcopy(_USAGE_SCHEMA)) + def create_resource(): - return wsgi.Resource(InfoController()) + usage_schema = get_usage_schema() + serializer = ResponseSerializer(usage_schema) + return wsgi.Resource(InfoController(), None, serializer) diff --git a/glance/api/v2/router.py b/glance/api/v2/router.py index 2055d5cbdc..d3f115466f 100644 --- a/glance/api/v2/router.py +++ b/glance/api/v2/router.py @@ -588,5 +588,9 @@ class API(wsgi.Router): controller=reject_method_resource, action='reject', allowed_methods='GET') + mapper.connect('/info/usage', + controller=info_resource, + action='get_usage', + conditions={'method': ['GET']}) super(API, self).__init__(mapper) diff --git a/glance/api/versions.py b/glance/api/versions.py index f9278a2a43..f548e8a45f 100644 --- a/glance/api/versions.py +++ b/glance/api/versions.py @@ -79,7 +79,7 @@ class Controller(object): version_objs = [] if CONF.enabled_backends: version_objs.extend([ - build_version_object(2.12, 'v2', 'CURRENT'), + build_version_object(2.12, 'v2', 'SUPPORTED'), build_version_object(2.11, 'v2', 'SUPPORTED'), build_version_object('2.10', 'v2', 'SUPPORTED'), build_version_object(2.9, 'v2', 'SUPPORTED'), @@ -87,9 +87,10 @@ class Controller(object): ]) else: version_objs.extend([ - build_version_object(2.9, 'v2', 'CURRENT'), + build_version_object(2.9, 'v2', 'SUPPORTED'), ]) version_objs.extend([ + build_version_object(2.13, 'v2', 'CURRENT'), build_version_object(2.7, 'v2', 'SUPPORTED'), build_version_object(2.6, 'v2', 'SUPPORTED'), build_version_object(2.5, 'v2', 'SUPPORTED'), diff --git a/glance/quota/keystone.py b/glance/quota/keystone.py index fa8e8c3e14..67b48d3289 100644 --- a/glance/quota/keystone.py +++ b/glance/quota/keystone.py @@ -142,3 +142,29 @@ def enforce_image_count_uploading(context, project_id): context, project_id, QUOTA_IMAGE_COUNT_UPLOADING, lambda: db.user_get_uploading_count(context, project_id), delta=0) + + +def get_usage(context, project_id=None): + if not CONF.use_keystone_limits: + return {} + + if not project_id: + project_id = context.project_id + + usages = { + QUOTA_IMAGE_SIZE_TOTAL: lambda: db.user_get_storage_usage( + context, project_id) // units.Mi, + QUOTA_IMAGE_STAGING_TOTAL: lambda: db.user_get_staging_usage( + context, project_id) // units.Mi, + QUOTA_IMAGE_COUNT_TOTAL: lambda: db.user_get_image_count( + context, project_id), + QUOTA_IMAGE_COUNT_UPLOADING: lambda: db.user_get_uploading_count( + context, project_id), + } + + def callback(project_id, resource_names): + return {name: usages[name]() + for name in resource_names} + + enforcer = limit.Enforcer(callback) + return enforcer.calculate_usage(project_id, list(usages.keys())) diff --git a/glance/tests/functional/v2/test_discovery.py b/glance/tests/functional/v2/test_discovery.py new file mode 100644 index 0000000000..537f19558e --- /dev/null +++ b/glance/tests/functional/v2/test_discovery.py @@ -0,0 +1,98 @@ +# Copyright 2021 Red Hat, Inc. +# 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 fixtures + +from oslo_utils import units + +from glance.quota import keystone as ks_quota +from glance.tests import functional +from glance.tests.functional.v2.test_images import get_enforcer_class +from glance.tests import utils as test_utils + + +class TestDiscovery(functional.SynchronousAPIBase): + def setUp(self): + super(TestDiscovery, self).setUp() + self.config(use_keystone_limits=True) + + self.enforcer_mock = self.useFixture( + fixtures.MockPatchObject(ks_quota, 'limit')).mock + + def set_limit(self, limits): + self.enforcer_mock.Enforcer = get_enforcer_class(limits) + + def _assert_usage(self, expected): + usage = self.api_get('/v2/info/usage') + usage = usage.json['usage'] + for item in ('count', 'size', 'stage'): + key = 'image_%s_total' % item + self.assertEqual(expected[key], usage[key], + 'Mismatch in %s' % key) + self.assertEqual(expected['image_count_uploading'], + usage['image_count_uploading']) + + def test_quota_with_usage(self): + self.set_limit({'image_size_total': 5, + 'image_count_total': 10, + 'image_stage_total': 15, + 'image_count_uploading': 20}) + + self.start_server() + + # Initially we expect no usage, but our limits in place. + expected = { + 'image_size_total': {'limit': 5, 'usage': 0}, + 'image_count_total': {'limit': 10, 'usage': 0}, + 'image_stage_total': {'limit': 15, 'usage': 0}, + 'image_count_uploading': {'limit': 20, 'usage': 0}, + } + self._assert_usage(expected) + + # Stage 1MiB and see our total count, uploading count, and + # staging area usage increase. + data = test_utils.FakeData(1 * units.Mi) + image_id = self._create_and_stage(data_iter=data) + expected['image_count_uploading']['usage'] = 1 + expected['image_count_total']['usage'] = 1 + expected['image_stage_total']['usage'] = 1 + self._assert_usage(expected) + + # Doing the import does not change anything (since we are + # synchronous and the task will not have run yet). + self._import_direct(image_id, ['store1']) + self._assert_usage(expected) + + # After the import is complete, our usage of the staging area + # drops to zero, and our consumption of actual store space + # reflects the new active image. + self._wait_for_import(image_id) + expected['image_count_uploading']['usage'] = 0 + expected['image_stage_total']['usage'] = 0 + expected['image_size_total']['usage'] = 1 + self._assert_usage(expected) + + # Upload also yields a new active image and store usage. + data = test_utils.FakeData(1 * units.Mi) + image_id = self._create_and_upload(data_iter=data) + expected['image_count_total']['usage'] = 2 + expected['image_size_total']['usage'] = 2 + self._assert_usage(expected) + + # Deleting an image drops the usage down. + self.api_delete('/v2/images/%s' % image_id) + expected['image_count_total']['usage'] = 1 + expected['image_size_total']['usage'] = 1 + self._assert_usage(expected) diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py index 32a2e5818e..8b117c8f04 100644 --- a/glance/tests/functional/v2/test_images.py +++ b/glance/tests/functional/v2/test_images.py @@ -24,6 +24,7 @@ import uuid import fixtures from oslo_limit import exception as ol_exc +from oslo_limit import limit from oslo_serialization import jsonutils from oslo_utils.secretutils import md5 from oslo_utils import units @@ -7018,6 +7019,13 @@ def get_enforcer_class(limits): over_limit_info_list=[ol_exc.OverLimitInfo( name, limits.get(name), current.get(name), delta)]) + def calculate_usage(self, project_id, names): + return { + name: limit.ProjectUsage( + limits.get(name, 0), + self._callback(project_id, [name])[name]) + for name in names} + return FakeEnforcer diff --git a/glance/tests/unit/test_versions.py b/glance/tests/unit/test_versions.py index e296bc7b6d..aeeeda4083 100644 --- a/glance/tests/unit/test_versions.py +++ b/glance/tests/unit/test_versions.py @@ -30,6 +30,12 @@ from glance.tests.unit import base # functional tests def get_versions_list(url, enabled_backends=False): image_versions = [ + { + 'id': 'v2.13', + 'status': 'CURRENT', + 'links': [{'rel': 'self', + 'href': '%s/v2/' % url}], + }, { 'id': 'v2.7', 'status': 'SUPPORTED', @@ -83,7 +89,7 @@ def get_versions_list(url, enabled_backends=False): image_versions = [ { 'id': 'v2.12', - 'status': 'CURRENT', + 'status': 'SUPPORTED', 'links': [{'rel': 'self', 'href': '%s/v2/' % url}], }, @@ -115,7 +121,7 @@ def get_versions_list(url, enabled_backends=False): else: image_versions.insert(0, { 'id': 'v2.9', - 'status': 'CURRENT', + 'status': 'SUPPORTED', 'links': [{'rel': 'self', 'href': '%s/v2/' % url}], }) @@ -322,15 +328,20 @@ class VersionNegotiationTest(base.IsolatedUnitTest): self.middleware.process_request(request) self.assertEqual('/v2/images', request.path_info) - # version 2.13 does not exist - def test_request_url_v2_13_default_unsupported(self): + def test_request_url_v2_13_enabled_supported(self): request = webob.Request.blank('/v2.13/images') + self.middleware.process_request(request) + self.assertEqual('/v2/images', request.path_info) + + # version 2.14 does not exist + def test_request_url_v2_14_default_unsupported(self): + request = webob.Request.blank('/v2.14/images') resp = self.middleware.process_request(request) self.assertIsInstance(resp, versions.Controller) - def test_request_url_v2_13_enabled_unsupported(self): + def test_request_url_v2_14_enabled_unsupported(self): self.config(enabled_backends='slow:one,fast:two') - request = webob.Request.blank('/v2.13/images') + request = webob.Request.blank('/v2.14/images') resp = self.middleware.process_request(request) self.assertIsInstance(resp, versions.Controller) diff --git a/releasenotes/notes/added-quota-usage-api-f1914054132f2021.yaml b/releasenotes/notes/added-quota-usage-api-f1914054132f2021.yaml new file mode 100644 index 0000000000..35da85ee88 --- /dev/null +++ b/releasenotes/notes/added-quota-usage-api-f1914054132f2021.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + This release brings additional functionality to the unified quota + work done in the previous release. A usage API is now available, + which provides a way for users to see their current quota limits + and their active resource usage towards them. For more + information, see the discovery section in the `api-ref + `_. diff --git a/requirements.txt b/requirements.txt index 798746a22c..177be65330 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ six>=1.11.0 # MIT oslo.db>=5.0.0 # Apache-2.0 oslo.i18n>=5.0.0 # Apache-2.0 -oslo.limit>=1.0.0 # Apache-2.0 +oslo.limit>=1.4.0 # Apache-2.0 oslo.log>=4.5.0 # Apache-2.0 oslo.messaging>=5.29.0,!=9.0.0 # Apache-2.0 oslo.middleware>=3.31.0 # Apache-2.0