[APIImpact] Quota usage API
This adds a /v2/info/usage API endpoint which exposes to the user their current limits and usage. The discovery API does not (appear to) have existing tests, so this adds a module for that, although only usage tests are added currently. Implements: blueprint quota-api Change-Id: I50c98bac50f815bdb9baae024e77afd388f74554
This commit is contained in:
parent
d7368446e4
commit
f865b8cac7
@ -103,3 +103,26 @@ Response Example
|
|||||||
|
|
||||||
.. literalinclude:: samples/stores-list-response.json
|
.. literalinclude:: samples/stores-list-response.json
|
||||||
:language: 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
|
||||||
|
20
api-ref/source/v2/samples/usage-response.json
Normal file
20
api-ref/source/v2/samples/usage-response.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -82,6 +82,7 @@ class VersionNegotiationFilter(wsgi.Middleware):
|
|||||||
allowed_versions['v2.6'] = 2
|
allowed_versions['v2.6'] = 2
|
||||||
allowed_versions['v2.7'] = 2
|
allowed_versions['v2.7'] = 2
|
||||||
allowed_versions['v2.9'] = 2
|
allowed_versions['v2.9'] = 2
|
||||||
|
allowed_versions['v2.13'] = 2
|
||||||
if CONF.enabled_backends:
|
if CONF.enabled_backends:
|
||||||
allowed_versions['v2.8'] = 2
|
allowed_versions['v2.8'] = 2
|
||||||
allowed_versions['v2.10'] = 2
|
allowed_versions['v2.10'] = 2
|
||||||
|
@ -13,12 +13,16 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
|
import oslo_serialization.jsonutils as json
|
||||||
import webob.exc
|
import webob.exc
|
||||||
|
|
||||||
from glance.common import wsgi
|
from glance.common import wsgi
|
||||||
|
import glance.db
|
||||||
from glance.i18n import _
|
from glance.i18n import _
|
||||||
|
from glance.quota import keystone as ks_quota
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
|
||||||
@ -63,6 +67,49 @@ class InfoController(object):
|
|||||||
|
|
||||||
return {'stores': backends}
|
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():
|
def create_resource():
|
||||||
return wsgi.Resource(InfoController())
|
usage_schema = get_usage_schema()
|
||||||
|
serializer = ResponseSerializer(usage_schema)
|
||||||
|
return wsgi.Resource(InfoController(), None, serializer)
|
||||||
|
@ -588,5 +588,9 @@ class API(wsgi.Router):
|
|||||||
controller=reject_method_resource,
|
controller=reject_method_resource,
|
||||||
action='reject',
|
action='reject',
|
||||||
allowed_methods='GET')
|
allowed_methods='GET')
|
||||||
|
mapper.connect('/info/usage',
|
||||||
|
controller=info_resource,
|
||||||
|
action='get_usage',
|
||||||
|
conditions={'method': ['GET']})
|
||||||
|
|
||||||
super(API, self).__init__(mapper)
|
super(API, self).__init__(mapper)
|
||||||
|
@ -78,7 +78,7 @@ class Controller(object):
|
|||||||
version_objs = []
|
version_objs = []
|
||||||
if CONF.enabled_backends:
|
if CONF.enabled_backends:
|
||||||
version_objs.extend([
|
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.11, 'v2', 'SUPPORTED'),
|
||||||
build_version_object('2.10', 'v2', 'SUPPORTED'),
|
build_version_object('2.10', 'v2', 'SUPPORTED'),
|
||||||
build_version_object(2.9, 'v2', 'SUPPORTED'),
|
build_version_object(2.9, 'v2', 'SUPPORTED'),
|
||||||
@ -86,9 +86,10 @@ class Controller(object):
|
|||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
version_objs.extend([
|
version_objs.extend([
|
||||||
build_version_object(2.9, 'v2', 'CURRENT'),
|
build_version_object(2.9, 'v2', 'SUPPORTED'),
|
||||||
])
|
])
|
||||||
version_objs.extend([
|
version_objs.extend([
|
||||||
|
build_version_object(2.13, 'v2', 'CURRENT'),
|
||||||
build_version_object(2.7, 'v2', 'SUPPORTED'),
|
build_version_object(2.7, 'v2', 'SUPPORTED'),
|
||||||
build_version_object(2.6, 'v2', 'SUPPORTED'),
|
build_version_object(2.6, 'v2', 'SUPPORTED'),
|
||||||
build_version_object(2.5, 'v2', 'SUPPORTED'),
|
build_version_object(2.5, 'v2', 'SUPPORTED'),
|
||||||
|
@ -142,3 +142,29 @@ def enforce_image_count_uploading(context, project_id):
|
|||||||
context, project_id, QUOTA_IMAGE_COUNT_UPLOADING,
|
context, project_id, QUOTA_IMAGE_COUNT_UPLOADING,
|
||||||
lambda: db.user_get_uploading_count(context, project_id),
|
lambda: db.user_get_uploading_count(context, project_id),
|
||||||
delta=0)
|
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()))
|
||||||
|
98
glance/tests/functional/v2/test_discovery.py
Normal file
98
glance/tests/functional/v2/test_discovery.py
Normal file
@ -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)
|
@ -22,6 +22,7 @@ import uuid
|
|||||||
|
|
||||||
import fixtures
|
import fixtures
|
||||||
from oslo_limit import exception as ol_exc
|
from oslo_limit import exception as ol_exc
|
||||||
|
from oslo_limit import limit
|
||||||
from oslo_serialization import jsonutils
|
from oslo_serialization import jsonutils
|
||||||
from oslo_utils.secretutils import md5
|
from oslo_utils.secretutils import md5
|
||||||
from oslo_utils import units
|
from oslo_utils import units
|
||||||
@ -7037,6 +7038,13 @@ def get_enforcer_class(limits):
|
|||||||
over_limit_info_list=[ol_exc.OverLimitInfo(
|
over_limit_info_list=[ol_exc.OverLimitInfo(
|
||||||
name, limits.get(name), current.get(name), delta)])
|
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
|
return FakeEnforcer
|
||||||
|
|
||||||
|
|
||||||
|
@ -29,6 +29,12 @@ from glance.tests.unit import base
|
|||||||
# functional tests
|
# functional tests
|
||||||
def get_versions_list(url, enabled_backends=False):
|
def get_versions_list(url, enabled_backends=False):
|
||||||
image_versions = [
|
image_versions = [
|
||||||
|
{
|
||||||
|
'id': 'v2.13',
|
||||||
|
'status': 'CURRENT',
|
||||||
|
'links': [{'rel': 'self',
|
||||||
|
'href': '%s/v2/' % url}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'id': 'v2.7',
|
'id': 'v2.7',
|
||||||
'status': 'SUPPORTED',
|
'status': 'SUPPORTED',
|
||||||
@ -82,7 +88,7 @@ def get_versions_list(url, enabled_backends=False):
|
|||||||
image_versions = [
|
image_versions = [
|
||||||
{
|
{
|
||||||
'id': 'v2.12',
|
'id': 'v2.12',
|
||||||
'status': 'CURRENT',
|
'status': 'SUPPORTED',
|
||||||
'links': [{'rel': 'self',
|
'links': [{'rel': 'self',
|
||||||
'href': '%s/v2/' % url}],
|
'href': '%s/v2/' % url}],
|
||||||
},
|
},
|
||||||
@ -114,7 +120,7 @@ def get_versions_list(url, enabled_backends=False):
|
|||||||
else:
|
else:
|
||||||
image_versions.insert(0, {
|
image_versions.insert(0, {
|
||||||
'id': 'v2.9',
|
'id': 'v2.9',
|
||||||
'status': 'CURRENT',
|
'status': 'SUPPORTED',
|
||||||
'links': [{'rel': 'self',
|
'links': [{'rel': 'self',
|
||||||
'href': '%s/v2/' % url}],
|
'href': '%s/v2/' % url}],
|
||||||
})
|
})
|
||||||
@ -321,15 +327,20 @@ class VersionNegotiationTest(base.IsolatedUnitTest):
|
|||||||
self.middleware.process_request(request)
|
self.middleware.process_request(request)
|
||||||
self.assertEqual('/v2/images', request.path_info)
|
self.assertEqual('/v2/images', request.path_info)
|
||||||
|
|
||||||
# version 2.13 does not exist
|
def test_request_url_v2_13_enabled_supported(self):
|
||||||
def test_request_url_v2_13_default_unsupported(self):
|
|
||||||
request = webob.Request.blank('/v2.13/images')
|
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)
|
resp = self.middleware.process_request(request)
|
||||||
self.assertIsInstance(resp, versions.Controller)
|
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')
|
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)
|
resp = self.middleware.process_request(request)
|
||||||
self.assertIsInstance(resp, versions.Controller)
|
self.assertIsInstance(resp, versions.Controller)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
<https://developer.openstack.org/api-ref/image/v2/index.html#image-service-info-discovery>`_.
|
@ -38,7 +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.limit>=1.4.0 # Apache-2.0
|
||||||
oslo.log>=4.5.0 # Apache-2.0
|
oslo.log>=4.5.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