diff --git a/etc/magnum/policy.json b/etc/magnum/policy.json index c7f82c8283..86cea7519f 100644 --- a/etc/magnum/policy.json +++ b/etc/magnum/policy.json @@ -35,6 +35,12 @@ "clustertemplate:update": "rule:default", "clustertemplate:publish": "rule:admin_or_owner", + "quotas:get": "rule:default", + "quotas:get_all": "rule:admin_api", + "quotas:create": "rule:admin_api", + "quotas:update": "rule:admin_api", + "quotas:delete": "rule:admin_api", + "certificate:create": "rule:admin_or_user", "certificate:get": "rule:admin_or_user", "certificate:rotate_ca": "rule:admin_or_owner", diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index 6b8dcf5d66..3c99b4eebf 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -30,6 +30,7 @@ from magnum.api.controllers.v1 import certificate from magnum.api.controllers.v1 import cluster from magnum.api.controllers.v1 import cluster_template from magnum.api.controllers.v1 import magnum_services +from magnum.api.controllers.v1 import quota from magnum.api.controllers.v1 import stats from magnum.api.controllers import versions as ver from magnum.api import expose @@ -86,6 +87,9 @@ class V1(controllers_base.APIBase): clusters = [link.Link] """Links to the clusters resource""" + quotas = [link.Link] + """Links to the quotas resource""" + certificates = [link.Link] """Links to the certificates resource""" @@ -133,6 +137,12 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'clusters', '', bookmark=True)] + v1.quotas = [link.Link.make_link('self', pecan.request.host_url, + 'quotas', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'quotas', '', + bookmark=True)] v1.certificates = [link.Link.make_link('self', pecan.request.host_url, 'certificates', ''), link.Link.make_link('bookmark', @@ -161,6 +171,7 @@ class Controller(controllers_base.Controller): baymodels = baymodel.BayModelsController() clusters = cluster.ClustersController() clustertemplates = cluster_template.ClusterTemplatesController() + quotas = quota.QuotaController() certificates = certificate.CertificateController() mservices = magnum_services.MagnumServiceController() stats = stats.StatsController() diff --git a/magnum/api/controllers/v1/quota.py b/magnum/api/controllers/v1/quota.py new file mode 100644 index 0000000000..52ad51c174 --- /dev/null +++ b/magnum/api/controllers/v1/quota.py @@ -0,0 +1,211 @@ +# Copyright 2013 UnitedStack 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. + +from oslo_log import log as logging +import pecan +import wsme +from wsme import types as wtypes + +from magnum.api.controllers import base +from magnum.api.controllers.v1 import collection +from magnum.api import expose +from magnum.api import utils as api_utils +from magnum.common import exception +from magnum.common import policy +from magnum.i18n import _ +from magnum import objects +from magnum.objects import fields + +LOG = logging.getLogger(__name__) + + +class Quota(base.APIBase): + """API representation of a project Quota. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of Quota. + """ + id = wsme.wsattr(wtypes.IntegerType(minimum=1)) + """unique id""" + + hard_limit = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1) + """The hard limit for total number of clusters. Default to 1 if not set""" + + project_id = wsme.wsattr(wtypes.StringType(min_length=1, max_length=255), + default=None) + """The project id""" + + resource = wsme.wsattr(wtypes.Enum(str, *fields.QuotaResourceName.ALL), + default='Cluster') + """The resource name""" + + def __init__(self, **kwargs): + super(Quota, self).__init__() + self.fields = [] + for field in objects.Quota.fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + @classmethod + def convert(cls, quota): + return Quota(**quota.as_dict()) + + +class QuotaCollection(collection.Collection): + """API representation of a collection of quotas.""" + + quotas = [Quota] + """A list containing quota objects""" + + def __init__(self, **kwargs): + self._type = 'quotas' + + @staticmethod + def convert(quotas, limit, **kwargs): + collection = QuotaCollection() + collection.quotas = [Quota.convert(p) for p in quotas] + collection.next = collection.get_next(limit, **kwargs) + return collection + + +class QuotaController(base.Controller): + """REST controller for Quotas.""" + + def __init__(self): + super(QuotaController, self).__init__() + + _custom_actions = { + 'detail': ['GET'], + } + + def _get_quota_collection(self, marker, limit, sort_key, sort_dir, + filters): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.Quota.get_by_id(pecan.request.context, + marker) + + quotas = objects.Quota.list(pecan.request.context, + limit, + marker_obj, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + + return QuotaCollection.convert(quotas, + limit, + sort_key=sort_key, + sort_dir=sort_dir) + + @expose.expose(QuotaCollection, int, int, wtypes.text, wtypes.text, bool) + def get_all(self, marker=None, limit=None, sort_key='id', + sort_dir='asc', all_tenants=False): + """Retrieve a list of quotas. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + :param all_tenants: a flag to indicate all or current tenant. + """ + context = pecan.request.context + policy.enforce(context, 'quota:get_all', + action='quota:get_all') + + filters = {} + if not context.is_admin or not all_tenants: + filters = {"project_id": context.project_id} + + return self._get_quota_collection(marker, + limit, + sort_key, + sort_dir, + filters) + + @expose.expose(Quota, wtypes.text, wtypes.text) + def get_one(self, project_id, resource): + """Retrieve Quota information for the given project_id. + + :param id: project id. + :param resource: resource name. + """ + context = pecan.request.context + policy.enforce(context, 'quota:get', action='quota:get') + + if not context.is_admin and project_id != context.project_id: + raise exception.NotAuthorized() + + quota = objects.Quota.get_quota_by_project_id_resource(context, + project_id, + resource) + return Quota.convert(quota) + + @expose.expose(Quota, body=Quota, status_code=201) + def post(self, quota): + """Create Quota. + + :param quota: a json document to create this Quota. + """ + + context = pecan.request.context + policy.enforce(context, 'quota:create', action='quota:create') + + quota_dict = quota.as_dict() + if 'project_id'not in quota_dict or not quota_dict['project_id']: + msg = _('Must provide a valid project ID.') + raise exception.InvalidParameterValue(message=msg) + + new_quota = objects.Quota(context, **quota_dict) + new_quota.create() + return Quota.convert(new_quota) + + @expose.expose(Quota, wtypes.text, wtypes.text, body=Quota, + status_code=202) + def patch(self, project_id, resource, quotapatch): + """Update Quota for a given project_id. + + :param project_id: project id. + :param resource: resource name. + :param quotapatch: a json document to update Quota. + """ + + context = pecan.request.context + policy.enforce(context, 'quota:update', action='quota:update') + quota_dict = quotapatch.as_dict() + quota_dict['project_id'] = project_id + quota_dict['resource'] = resource + db_quota = objects.Quota.update_quota(context, project_id, quota_dict) + return Quota.convert(db_quota) + + @expose.expose(None, wtypes.text, wtypes.text, status_code=204) + def delete(self, project_id, resource): + """Delete Quota for a given project_id and resource. + + :param project_id: project id. + :param resource: resource name. + """ + + context = pecan.request.context + policy.enforce(context, 'quota:delete', action='quota:delete') + quota_dict = {"project_id": project_id, "resource": resource} + quota = objects.Quota(context, **quota_dict) + quota.delete() diff --git a/magnum/objects/__init__.py b/magnum/objects/__init__.py index 7b3cea7a26..6f33ab6a7d 100644 --- a/magnum/objects/__init__.py +++ b/magnum/objects/__init__.py @@ -16,6 +16,7 @@ from magnum.objects import certificate from magnum.objects import cluster from magnum.objects import cluster_template from magnum.objects import magnum_service +from magnum.objects import quota from magnum.objects import stats from magnum.objects import x509keypair @@ -23,6 +24,7 @@ from magnum.objects import x509keypair Cluster = cluster.Cluster ClusterTemplate = cluster_template.ClusterTemplate MagnumService = magnum_service.MagnumService +Quota = quota.Quota X509KeyPair = x509keypair.X509KeyPair Certificate = certificate.Certificate Stats = stats.Stats @@ -31,4 +33,5 @@ __all__ = (Cluster, MagnumService, X509KeyPair, Certificate, - Stats) + Stats, + Quota) diff --git a/magnum/objects/fields.py b/magnum/objects/fields.py index fdce77de45..6cb92e070a 100644 --- a/magnum/objects/fields.py +++ b/magnum/objects/fields.py @@ -84,6 +84,18 @@ class DockerStorageDriver(fields.Enum): valid_values=DockerStorageDriver.ALL) +class QuotaResourceName(fields.Enum): + ALL = ( + CLUSTER, + ) = ( + 'Cluster', + ) + + def __init__(self): + super(QuotaResourceName, self).__init__( + valid_values=QuotaResourceName.ALL) + + class ServerType(fields.Enum): ALL = ( VM, BM, diff --git a/magnum/objects/quota.py b/magnum/objects/quota.py new file mode 100644 index 0000000000..287b17e590 --- /dev/null +++ b/magnum/objects/quota.py @@ -0,0 +1,142 @@ +# 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_versionedobjects import fields + +from magnum.db import api as dbapi +from magnum.objects import base + + +@base.MagnumObjectRegistry.register +class Quota(base.MagnumPersistentObject, base.MagnumObject, + base.MagnumObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'id': fields.IntegerField(), + 'project_id': fields.StringField(nullable=False), + 'resource': fields.StringField(nullable=False), + 'hard_limit': fields.IntegerField(nullable=False), + } + + @base.remotable_classmethod + def get_quota_by_project_id_resource(cls, context, project_id, resource): + """Find a quota based on its integer id and return a Quota object. + + :param project_id: the id of a project. + :param resource: resource name. + :param context: Security context + :returns: a :class:`Quota` object. + """ + db_quota = cls.dbapi.get_quota_by_project_id_resource(project_id, + resource) + quota = Quota._from_db_object(cls(context), db_quota) + return quota + + @staticmethod + def _from_db_object(quota, db_quota): + """Converts a database entity to a formal object.""" + for field in quota.fields: + setattr(quota, field, db_quota[field]) + + quota.obj_reset_changes() + return quota + + @staticmethod + def _from_db_object_list(db_objects, cls, context): + """Converts a list of database entities to a list of formal objects.""" + return [Quota._from_db_object(cls(context), obj) + for obj in db_objects] + + @base.remotable_classmethod + def get_by_id(cls, context, quota_id): + """Find a quota based on its integer id and return a Quota object. + + :param quota_id: the id of a quota. + :param context: Security context + :returns: a :class:`Quota` object. + """ + db_quota = cls.dbapi.get_quota_by_id(context, quota_id) + quota = Quota._from_db_object(cls(context), db_quota) + return quota + + @base.remotable_classmethod + def list(cls, context, limit=None, marker=None, + sort_key=None, sort_dir=None, filters=None): + """Return a list of Quota objects. + + :param context: Security context. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :param filters: filter dict, can includes 'project_id', + 'resource'. + :returns: a list of :class:`Quota` object. + + """ + db_quotas = cls.dbapi.get_quota_list(context, + limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + return Quota._from_db_object_list(db_quotas, cls, context) + + @base.remotable_classmethod + def quota_get_all_by_project_id(cls, context, project_id): + """Find a quota based on project id. + + :param project_id: the project id. + :param context: Security context + :returns: a :class:`Quota` object. + """ + quotas = cls.dbapi.get_quota_by_project_id(context, project_id) + return Quota._from_db_object_list(quotas, cls, context) + + @base.remotable + def create(self, context=None): + """Save a quota based on project id. + + :param context: security context. + :returns: a :class:`Quota` object. + """ + values = self.obj_get_changes() + db_quota = self.dbapi.create_quota(values) + self._from_db_object(self, db_quota) + + @base.remotable + def delete(self, context=None): + """Delete the quota from the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Quota(context) + """ + self.dbapi.delete_quota(self.project_id, self.resource) + self.obj_reset_changes() + + @base.remotable_classmethod + def update_quota(cls, context, project_id, quota): + """Save a quota based on project id. + + :param quota: quota. + :returns: a :class:`Quota` object. + """ + db_quota = cls.dbapi.update_quota(project_id, quota) + return Quota._from_db_object(cls(context), db_quota) diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index f95a00c288..e0dae316a9 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -69,6 +69,10 @@ class TestRootController(api_base.FunctionalTest): u'rel': u'self'}, {u'href': u'http://localhost/clusters/', u'rel': u'bookmark'}], + u'quotas': [{u'href': u'http://localhost/v1/quotas/', + u'rel': u'self'}, + {u'href': u'http://localhost/quotas/', + u'rel': u'bookmark'}], u'clustertemplates': [{u'href': u'http://localhost/v1/clustertemplates/', u'rel': u'self'}, diff --git a/magnum/tests/unit/api/controllers/v1/test_quota.py b/magnum/tests/unit/api/controllers/v1/test_quota.py new file mode 100644 index 0000000000..23fae76b54 --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_quota.py @@ -0,0 +1,210 @@ +# 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 mock + +from magnum.api.controllers.v1 import quota as api_quota +from magnum.tests import base +from magnum.tests.unit.api import base as api_base +from magnum.tests.unit.api import utils as apiutils +from magnum.tests.unit.objects import utils as obj_utils + + +class TestQuotaObject(base.TestCase): + def test_quota_init(self): + quota_dict = apiutils.quota_post_data() + del quota_dict['hard_limit'] + quota = api_quota.Quota(**quota_dict) + self.assertEqual(1, quota.hard_limit) + + +class TestQuota(api_base.FunctionalTest): + _quota_attrs = ("project_id", "resource", "hard_limit") + + def setUp(self): + super(TestQuota, self).setUp() + + def test_empty(self): + response = self.get_json('/quotas') + self.assertEqual([], response['quotas']) + + def test_one(self): + quota = obj_utils.create_test_quota(self.context) + response = self.get_json('/quotas') + self.assertEqual(quota.project_id, response['quotas'][0]["project_id"]) + self._verify_attrs(self._quota_attrs, response['quotas'][0]) + + def test_get_one(self): + quota = obj_utils.create_test_quota(self.context) + response = self.get_json('/quotas/%s/%s' % (quota['project_id'], + quota['resource'])) + self.assertEqual(quota.project_id, response['project_id']) + self.assertEqual(quota.resource, response['resource']) + + def test_get_one_not_found(self): + response = self.get_json( + '/quotas/fake_project/invalid_res', + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_get_one_not_authorized(self): + obj_utils.create_test_quota(self.context) + response = self.get_json( + '/quotas/invalid_proj/invalid_res', + expect_errors=True) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + @mock.patch("magnum.common.policy.enforce") + @mock.patch("magnum.common.context.make_context") + def test_get_all_admin_all_tenants(self, mock_context, mock_policy): + mock_context.return_value = self.context + quota_list = [] + for i in range(4): + quota = obj_utils.create_test_quota(self.context, + project_id="proj-id-"+str(i)) + quota_list.append(quota) + + self.context.is_admin = True + response = self.get_json('/quotas?all_tenants=True') + self.assertEqual(4, len(response['quotas'])) + expected = [r.project_id for r in quota_list] + res_proj_ids = [r['project_id'] for r in response['quotas']] + self.assertEqual(sorted(expected), sorted(res_proj_ids)) + + @mock.patch("magnum.common.policy.enforce") + @mock.patch("magnum.common.context.make_context") + def test_get_all_admin_not_all_tenants(self, mock_context, mock_policy): + mock_context.return_value = self.context + quota_list = [] + for i in range(4): + quota = obj_utils.create_test_quota(self.context, + project_id="proj-id-"+str(i)) + quota_list.append(quota) + + self.context.is_admin = True + self.context.project_id = 'proj-id-1' + response = self.get_json('/quotas') + self.assertEqual(1, len(response['quotas'])) + self.assertEqual('proj-id-1', response['quotas'][0]['project_id']) + + @mock.patch("magnum.common.policy.enforce") + @mock.patch("magnum.common.context.make_context") + def test_get_all_admin_all_with_pagination_marker(self, mock_context, + mock_policy): + mock_context.return_value = self.context + quota_list = [] + for i in range(4): + quota = obj_utils.create_test_quota(self.context, + project_id="proj-id-"+str(i)) + quota_list.append(quota) + + self.context.is_admin = True + response = self.get_json('/quotas?limit=3&marker=%s&all_tenants=True' + % quota_list[2].id) + self.assertEqual(1, len(response['quotas'])) + self.assertEqual(quota_list[-1].project_id, + response['quotas'][0]['project_id']) + + def test_get_all_non_admin(self): + quota_list = [] + for i in range(4): + quota = obj_utils.create_test_quota(self.context, + project_id="proj-id-"+str(i)) + quota_list.append(quota) + + headers = {'X-Project-Id': 'proj-id-2'} + response = self.get_json('/quotas', headers=headers) + self.assertEqual(1, len(response['quotas'])) + self.assertEqual('proj-id-2', response['quotas'][0]['project_id']) + + def test_create_quota(self): + quota_dict = apiutils.quota_post_data() + response = self.post_json('/quotas', quota_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(201, response.status_int) + self.assertEqual(quota_dict['project_id'], response.json['project_id']) + + def test_create_quota_invalid_resource(self): + quota_dict = apiutils.quota_post_data() + quota_dict['resource'] = 'invalid-res' + response = self.post_json('/quotas', quota_dict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_quota_invalid_hard_limit(self): + quota_dict = apiutils.quota_post_data() + quota_dict['hard_limit'] = -10 + response = self.post_json('/quotas', quota_dict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_quota_no_project_id(self): + quota_dict = apiutils.quota_post_data() + del quota_dict['project_id'] + response = self.post_json('/quotas', quota_dict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_patch_quota(self): + quota_dict = apiutils.quota_post_data(hard_limit=5) + response = self.post_json('/quotas', quota_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(201, response.status_int) + self.assertEqual(quota_dict['project_id'], response.json['project_id']) + self.assertEqual(5, response.json['hard_limit']) + + quota_dict['hard_limit'] = 20 + response = self.patch_json('/quotas', quota_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + self.assertEqual(20, response.json['hard_limit']) + + def test_patch_quota_not_found(self): + quota_dict = apiutils.quota_post_data() + response = self.post_json('/quotas', quota_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(201, response.status_int) + + # update quota with non-existing project id + update_dict = {'project_id': 'not-found', + 'hard_limit': 20, + 'resource': 'Cluster'} + response = self.patch_json('/quotas', update_dict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(404, response.status_int) + self.assertTrue(response.json['errors']) + + def test_delete_quota(self): + quota_dict = apiutils.quota_post_data() + response = self.post_json('/quotas', quota_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(201, response.status_int) + + project_id = quota_dict['project_id'] + resource = quota_dict['resource'] + # delete quota + self.delete('/quotas/%s/%s' % (project_id, resource)) + + # now check that quota does not exist + response = self.get_json( + '/quotas/%s/%s' % (project_id, resource), + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) diff --git a/magnum/tests/unit/api/utils.py b/magnum/tests/unit/api/utils.py index 5c2b8a8121..2ad1293687 100644 --- a/magnum/tests/unit/api/utils.py +++ b/magnum/tests/unit/api/utils.py @@ -67,6 +67,10 @@ def cert_post_data(**kw): } +def quota_post_data(**kw): + return utils.get_test_quota(**kw) + + def mservice_get_data(**kw): """Simulate what the RPC layer will get from DB """ faketime = datetime.datetime(2001, 1, 1, tzinfo=pytz.UTC) diff --git a/magnum/tests/unit/objects/test_objects.py b/magnum/tests/unit/objects/test_objects.py index 329547934c..6012db6409 100644 --- a/magnum/tests/unit/objects/test_objects.py +++ b/magnum/tests/unit/objects/test_objects.py @@ -362,6 +362,7 @@ object_data = { 'X509KeyPair': '1.2-d81950af36c59a71365e33ce539d24f9', 'MagnumService': '1.0-2d397ec59b0046bd5ec35cd3e06efeca', 'Stats': '1.0-73a1cd6e3c0294c932a66547faba216c', + 'Quota': '1.0-94e100aebfa88f7d8428e007f2049c18', } diff --git a/magnum/tests/unit/objects/utils.py b/magnum/tests/unit/objects/utils.py index 6f72bf6b15..dbf0db525c 100644 --- a/magnum/tests/unit/objects/utils.py +++ b/magnum/tests/unit/objects/utils.py @@ -90,6 +90,33 @@ def create_test_cluster(context, **kw): return cluster +def get_test_quota(context, **kw): + """Return a Quota object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + db_quota = db_utils.get_test_quota(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del db_quota['id'] + quota = objects.Quota(context) + for key in db_quota: + setattr(quota, key, db_quota[key]) + return quota + + +def create_test_quota(context, **kw): + """Create and return a test Quota object. + + Create a quota in the DB and return a Quota object with appropriate + attributes. + """ + quota = get_test_quota(context, **kw) + quota.create() + return quota + + def get_test_x509keypair(context, **kw): """Return a X509KeyPair object with appropriate attributes. diff --git a/releasenotes/notes/quota-api-182cd1bc9e706b17.yaml b/releasenotes/notes/quota-api-182cd1bc9e706b17.yaml new file mode 100644 index 0000000000..87e4c64903 --- /dev/null +++ b/releasenotes/notes/quota-api-182cd1bc9e706b17.yaml @@ -0,0 +1,5 @@ +--- +features: + - This release introduces 'quota' endpoint that enable admin + users to set, update and show quota for a given tenant. + A non-admin user can get self quota limits.