From 8a7ab267a6bbd48fc5e9ccb91a1472cf1e017e3f Mon Sep 17 00:00:00 2001 From: Vilobh Meshram Date: Thu, 23 Jul 2015 21:09:02 -0700 Subject: [PATCH] Cinder Nested Quota Driver Cinder Nested Quota Driver patch adds hierarchical support. Quota API's now can also handle hierarchical projects. For the first pass only a user with admin role in a parent project will be able to update and delete its children quotas. Even after these changes are made, the existing DbQuotaDriver functionalities will remain unchanged. The same driver will be able to handle flat projects as well as hierarchical projects. Co-Authored-By: Erickson Santos Change-Id: Ie669d7d74d40c0ff1f1e54f673e7f3ae96b3b950 Implements: bp cinder-nested-quota-driver --- cinder/api/contrib/quotas.py | 243 +++++++++++++++++-- cinder/db/api.py | 15 ++ cinder/db/sqlalchemy/api.py | 19 ++ cinder/quota.py | 15 +- cinder/tests/unit/api/contrib/test_quotas.py | 182 +++++++++++++- cinder/tests/unit/test_quota.py | 72 +++++- 6 files changed, 516 insertions(+), 30 deletions(-) diff --git a/cinder/api/contrib/quotas.py b/cinder/api/contrib/quotas.py index 85bc70880..27f0401bb 100644 --- a/cinder/api/contrib/quotas.py +++ b/cinder/api/contrib/quotas.py @@ -36,7 +36,6 @@ CONF = cfg.CONF QUOTAS = quota.QUOTAS NON_QUOTA_KEYS = ['tenant_id', 'id'] - authorize_update = extensions.extension_authorizer('volume', 'quotas:update') authorize_show = extensions.extension_authorizer('volume', 'quotas:show') authorize_delete = extensions.extension_authorizer('volume', 'quotas:delete') @@ -72,6 +71,26 @@ class QuotaSetsController(wsgi.Controller): "resources.") % key raise webob.exc.HTTPBadRequest(explanation=msg) + def _validate_quota_limit(self, quota, key, project_quotas=None, + parent_project_quotas=None): + limit = self.validate_integer(quota[key], key, min_value=-1, + max_value=db.MAX_INT) + + if parent_project_quotas: + free_quota = (parent_project_quotas[key]['limit'] - + parent_project_quotas[key]['in_use'] - + parent_project_quotas[key]['reserved'] - + parent_project_quotas[key]['allocated']) + + current = 0 + if project_quotas.get(key): + current = project_quotas[key]['limit'] + + if limit - current > free_quota: + msg = _("Free quota available is %s.") % free_quota + raise webob.exc.HTTPBadRequest(explanation=msg) + return limit + def _get_quotas(self, context, id, usages=False, parent_project_id=None): values = QUOTAS.get_project_quotas(context, id, usages=usages, parent_project_id=parent_project_id) @@ -81,6 +100,78 @@ class QuotaSetsController(wsgi.Controller): else: return {k: v['limit'] for k, v in values.items()} + def _authorize_update_or_delete(self, context_project, + target_project_id, + parent_id): + """Checks if update or delete are allowed in the current hierarchy. + + With hierarchical projects, only the admin of the parent or the root + project has privilege to perform quota update and delete operations. + + :param context_project: The project in which the user is scoped to. + :param target_project_id: The id of the project in which the + user want to perform an update or + delete operation. + :param parent_id: The parent id of the project in which the user + want to perform an update or delete operation. + """ + if context_project.parent_id and parent_id != context_project.id: + msg = _("Update and delete quota operations can only be made " + "by an admin of immediate parent or by the CLOUD admin.") + raise webob.exc.HTTPForbidden(explanation=msg) + + if context_project.id != target_project_id: + if not self._is_descendant(target_project_id, + context_project.subtree): + msg = _("Update and delete quota operations can only be made " + "to projects in the same hierarchy of the project in " + "which users are scoped to.") + raise webob.exc.HTTPForbidden(explanation=msg) + else: + msg = _("Update and delete quota operations can only be made " + "by an admin of immediate parent or by the CLOUD admin.") + raise webob.exc.HTTPForbidden(explanation=msg) + + def _authorize_show(self, context_project, target_project): + """Checks if show is allowed in the current hierarchy. + + With hierarchical projects, are allowed to perform quota show operation + users with admin role in, at least, one of the following projects: the + current project; the immediate parent project; or the root project. + + :param context_project: The project in which the user + is scoped to. + :param target_project: The project in which the user wants + to perform a show operation. + """ + if target_project.parent_id: + if target_project.id != context_project.id: + if not self._is_descendant(target_project.id, + context_project.subtree): + msg = _("Show operations can only be made to projects in " + "the same hierarchy of the project in which users " + "are scoped to.") + raise webob.exc.HTTPForbidden(explanation=msg) + if context_project.id != target_project.parent_id: + if context_project.parent_id: + msg = _("Only users with token scoped to immediate " + "parents or root projects are allowed to see " + "its children quotas.") + raise webob.exc.HTTPForbidden(explanation=msg) + elif context_project.parent_id: + msg = _("An user with a token scoped to a subproject is not " + "allowed to see the quota of its parents.") + raise webob.exc.HTTPForbidden(explanation=msg) + + def _is_descendant(self, target_project_id, subtree): + if subtree is not None: + for key, value in subtree.items(): + if key == target_project_id: + return True + if self._is_descendant(target_project_id, value): + return True + return False + def _get_project(self, context, id, subtree_as_ids=False): """A Helper method to get the project hierarchy. @@ -100,30 +191,62 @@ class QuotaSetsController(wsgi.Controller): @wsgi.serializers(xml=QuotaTemplate) def show(self, req, id): + """Show quota for a particular tenant + + This works for hierarchical and non-hierarchical projects. For + hierarchical projects admin of current project, immediate + parent of the project or the CLOUD admin are able to perform + a show. + + :param req: request + :param id: target project id that needs to be updated + """ context = req.environ['cinder.context'] authorize_show(context) - params = req.params + target_project_id = id + if not hasattr(params, '__call__') and 'usage' in params: usage = strutils.bool_from_string(params['usage']) else: usage = False + # With hierarchical projects, only the admin of the current project or + # the root project has privilege to perform quota show operations. + target_project = self._get_project(context, target_project_id) + context_project = self._get_project(context, context.project_id, + subtree_as_ids=True) + + self._authorize_show(context_project, target_project) try: - sqlalchemy_api.authorize_project_context(context, id) + sqlalchemy_api.authorize_project_context(context, + target_project_id) except exception.NotAuthorized: raise webob.exc.HTTPForbidden() - return self._format_quota_set(id, self._get_quotas(context, id, usage)) + quotas = self._get_quotas(context, target_project_id, usage, + parent_project_id=target_project.parent_id) + return self._format_quota_set(target_project_id, quotas) @wsgi.serializers(xml=QuotaTemplate) def update(self, req, id, body): + """Update Quota for a particular tenant + + This works for hierarchical and non-hierarchical projects. For + hierarchical projects only immediate parent admin or the + CLOUD admin are able to perform an update. + + :param req: request + :param id: target project id that needs to be updated + :param body: key, value pair that that will be + applied to the resources if the update + succeeds + """ context = req.environ['cinder.context'] authorize_update(context) self.validate_string_length(id, 'quota_set_name', min_length=1, max_length=255) - project_id = id self.assert_valid_body(body, 'quota_set') # Get the optional argument 'skip_validation' from body, @@ -134,6 +257,7 @@ class QuotaSetsController(wsgi.Controller): raise exception.InvalidParameterValue(err=msg) skip_flag = strutils.bool_from_string(skip_flag) + target_project_id = id bad_keys = [] # NOTE(ankit): Pass #1 - In this loop for body['quota_set'].items(), @@ -147,36 +271,70 @@ class QuotaSetsController(wsgi.Controller): msg = _("Bad key(s) in quota set: %s") % ",".join(bad_keys) raise webob.exc.HTTPBadRequest(explanation=msg) + # Get the parent_id of the target project to verify whether we are + # dealing with hierarchical namespace or non-hierarchical namespace. + target_project = self._get_project(context, target_project_id) + parent_id = target_project.parent_id + + if parent_id: + # Get the children of the project which the token is scoped to in + # order to know if the target_project is in its hierarchy. + context_project = self._get_project(context, + context.project_id, + subtree_as_ids=True) + self._authorize_update_or_delete(context_project, + target_project.id, + parent_id) + parent_project_quotas = QUOTAS.get_project_quotas( + context, parent_id, parent_project_id=parent_id) + # NOTE(ankit): Pass #2 - In this loop for body['quota_set'].keys(), # we validate the quota limits to ensure that we can bail out if # any of the items in the set is bad. Meanwhile we validate value # to ensure that the value can't be lower than number of existing # resources. - quota_values = QUOTAS.get_project_quotas(context, project_id) + quota_values = QUOTAS.get_project_quotas(context, target_project_id, + defaults=False) valid_quotas = {} + allocated_quotas = {} for key in body['quota_set'].keys(): if key in NON_QUOTA_KEYS: continue - valid_quotas[key] = self.validate_integer( - body['quota_set'][key], key, min_value=-1, - max_value=db.MAX_INT) - if not skip_flag: self._validate_existing_resource(key, value, quota_values) + if parent_id: + value = self._validate_quota_limit(body['quota_set'], key, + quota_values, + parent_project_quotas) + allocated_quotas[key] = ( + parent_project_quotas[key]['allocated'] + value) + else: + value = self._validate_quota_limit(body['quota_set'], key) + valid_quotas[key] = value + # NOTE(ankit): Pass #3 - At this point we know that all the keys and # values are valid and we can iterate and update them all in one shot # without having to worry about rolling back etc as we have done # the validation up front in the 2 loops above. for key, value in valid_quotas.items(): try: - db.quota_update(context, project_id, key, value) + db.quota_update(context, target_project_id, key, value) except exception.ProjectQuotaNotFound: - db.quota_create(context, project_id, key, value) + db.quota_create(context, target_project_id, key, value) except exception.AdminRequired: raise webob.exc.HTTPForbidden() - return {'quota_set': self._get_quotas(context, id)} + # If hierarchical projects, update child's quota first + # and then parents quota. In future this needs to be an + # atomic operation. + if parent_id: + if key in allocated_quotas.keys(): + db.quota_allocated_update(context, parent_id, key, + allocated_quotas[key]) + + return {'quota_set': self._get_quotas(context, target_project_id, + parent_project_id=parent_id)} @wsgi.serializers(xml=QuotaTemplate) def defaults(self, req, id): @@ -188,15 +346,70 @@ class QuotaSetsController(wsgi.Controller): @wsgi.serializers(xml=QuotaTemplate) def delete(self, req, id): + """Delete Quota for a particular tenant. + + This works for hierarchical and non-hierarchical projects. For + hierarchical projects only immediate parent admin or the + CLOUD admin are able to perform a delete. + :param req: request + :param id: target project id that needs to be updated + """ context = req.environ['cinder.context'] authorize_delete(context) + # Get the parent_id of the target project to verify whether we are + # dealing with hierarchical namespace or non-hierarchical namespace. + target_project = self._get_project(context, id) + parent_id = target_project.parent_id + try: - db.quota_destroy_by_project(context, id) - except exception.AdminRequired: + project_quotas = QUOTAS.get_project_quotas( + context, target_project.id, usages=True, + parent_project_id=parent_id) + except exception.NotAuthorized: raise webob.exc.HTTPForbidden() + # If the project which is being deleted has allocated part of its quota + # to its subprojects, then subprojects' quotas should be deleted first. + for key, value in project_quotas.items(): + if 'allocated' in project_quotas[key].keys(): + if project_quotas[key]['allocated'] != 0: + msg = _("About to delete child projects having " + "non-zero quota. This should not be performed") + raise webob.exc.HTTPBadRequest(explanation=msg) + + if parent_id: + # Get the children of the project which the token is scoped to in + # order to know if the target_project is in its hierarchy. + context_project = self._get_project(context, + context.project_id, + subtree_as_ids=True) + self._authorize_update_or_delete(context_project, + target_project.id, + parent_id) + parent_project_quotas = QUOTAS.get_project_quotas( + context, parent_id, parent_project_id=parent_id) + + # Delete child quota first and later update parent's quota. + try: + db.quota_destroy_by_project(context, target_project.id) + except exception.AdminRequired: + raise webob.exc.HTTPForbidden() + + # Update the allocated of the parent + for key, value in project_quotas.items(): + project_hard_limit = project_quotas[key]['limit'] + parent_allocated = parent_project_quotas[key]['allocated'] + parent_allocated -= project_hard_limit + db.quota_allocated_update(context, parent_id, key, + parent_allocated) + else: + try: + db.quota_destroy_by_project(context, target_project.id) + except exception.AdminRequired: + raise webob.exc.HTTPForbidden() + class Quotas(extensions.ExtensionDescriptor): """Quota management support.""" diff --git a/cinder/db/api.py b/cinder/db/api.py index 4e7e6969f..78edfd28b 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -731,6 +731,21 @@ def quota_get_all_by_project(context, project_id): return IMPL.quota_get_all_by_project(context, project_id) +def quota_allocated_get_all_by_project(context, project_id): + """Retrieve all allocated quotas associated with a given project.""" + return IMPL.quota_allocated_get_all_by_project(context, project_id) + + +def quota_allocated_update(context, project_id, + resource, allocated): + """Update allocated quota to subprojects or raise if it does not exist. + + :raises: cinder.exception.ProjectQuotaNotFound + """ + return IMPL.quota_allocated_update(context, project_id, + resource, allocated) + + def quota_update(context, project_id, resource, limit): """Update a quota or raise if it does not exist.""" return IMPL.quota_update(context, project_id, resource, limit) diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 868909892..1abea0943 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -541,6 +541,16 @@ def quota_get_all_by_project(context, project_id): return result +@require_context +def quota_allocated_get_all_by_project(context, project_id): + rows = model_query(context, models.Quota, read_deleted='no').filter_by( + project_id=project_id).all() + result = {'project_id': project_id} + for row in rows: + result[row.resource] = row.allocated + return result + + @require_admin_context def quota_create(context, project_id, resource, limit): quota_ref = models.Quota() @@ -563,6 +573,15 @@ def quota_update(context, project_id, resource, limit): return quota_ref +@require_admin_context +def quota_allocated_update(context, project_id, resource, allocated): + session = get_session() + with session.begin(): + quota_ref = _quota_get(context, project_id, resource, session=session) + quota_ref.allocated = allocated + return quota_ref + + @require_admin_context def quota_destroy(context, project_id, resource): session = get_session() diff --git a/cinder/quota.py b/cinder/quota.py index 8feeddb2d..f32931db2 100644 --- a/cinder/quota.py +++ b/cinder/quota.py @@ -188,8 +188,8 @@ class DbQuotaDriver(object): default value, if there is no value from the quota class) will be reported if there is no specific value for the resource. - :param usages: If True, the current in_use and reserved counts - will also be returned. + :param usages: If True, the current in_use, reserved and allocated + counts will also be returned. :param parent_project_id: The id of the current project's parent, if any. """ @@ -199,6 +199,9 @@ class DbQuotaDriver(object): if usages: project_usages = db.quota_usage_get_all_by_project(context, project_id) + allocated_quotas = db.quota_allocated_get_all_by_project( + context, project_id) + allocated_quotas.pop('project_id') # Get the quotas for the appropriate class. If the project ID # matches the one in the context, we use the quota_class from @@ -235,6 +238,10 @@ class DbQuotaDriver(object): in_use=usage.get('in_use', 0), reserved=usage.get('reserved', 0), ) + if parent_project_id or allocated_quotas: + quotas[resource.name].update( + allocated=allocated_quotas.get(resource.name, 0), ) + return quotas def _get_quotas(self, context, resources, keys, has_sync, project_id=None, @@ -700,8 +707,8 @@ class QuotaEngine(object): default value, if there is no value from the quota class) will be reported if there is no specific value for the resource. - :param usages: If True, the current in_use and reserved counts - will also be returned. + :param usages: If True, the current in_use, reserved and + allocated counts will also be returned. :param parent_project_id: The id of the current project's parent, if any. """ diff --git a/cinder/tests/unit/api/contrib/test_quotas.py b/cinder/tests/unit/api/contrib/test_quotas.py index fa4d8d8fc..cb39108fe 100644 --- a/cinder/tests/unit/api/contrib/test_quotas.py +++ b/cinder/tests/unit/api/contrib/test_quotas.py @@ -89,6 +89,7 @@ class QuotaSetsControllerTest(test.TestCase): self.req.environ = {'cinder.context': context.get_admin_context()} self.req.environ['cinder.context'].is_admin = True self.req.environ['cinder.context'].auth_token = uuid.uuid4().hex + self.req.environ['cinder.context'].project_id = 'foo' self._create_project_hierarchy() self.auth_url = CONF.keymgr.encryption_auth_url @@ -142,7 +143,7 @@ class QuotaSetsControllerTest(test.TestCase): self.controller._get_project = mock.Mock() self.controller._get_project.side_effect = self._get_project result = self.controller.defaults(self.req, 'foo') - self.assertDictMatch(result, make_body()) + self.assertDictMatch(make_body(), result) def test_subproject_defaults(self): self.controller._get_project = mock.Mock() @@ -151,13 +152,45 @@ class QuotaSetsControllerTest(test.TestCase): context.project_id = self.B.id result = self.controller.defaults(self.req, self.B.id) expected = make_subproject_body(tenant_id=self.B.id) - self.assertDictMatch(result, expected) + self.assertDictMatch(expected, result) def test_show(self): self.controller._get_project = mock.Mock() self.controller._get_project.side_effect = self._get_project result = self.controller.show(self.req, 'foo') - self.assertDictMatch(result, make_body()) + self.assertDictMatch(make_body(), result) + + def test_subproject_show(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + self.req.environ['cinder.context'].project_id = self.A.id + result = self.controller.show(self.req, self.B.id) + expected = make_subproject_body(tenant_id=self.B.id) + self.assertDictMatch(expected, result) + + def test_subproject_show_in_hierarchy(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + # An user scoped to a root project in an hierarchy can see its children + # quotas. + self.req.environ['cinder.context'].project_id = self.A.id + result = self.controller.show(self.req, self.D.id) + expected = make_subproject_body(tenant_id=self.D.id) + self.assertDictMatch(result, expected) + # An user scoped to a parent project can see its immediate children + # quotas. + self.req.environ['cinder.context'].project_id = self.B.id + result = self.controller.show(self.req, self.D.id) + expected = make_subproject_body(tenant_id=self.D.id) + self.assertDictMatch(result, expected) + + def test_subproject_show_target_project_equals_to_context_project(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + self.req.environ['cinder.context'].project_id = self.B.id + result = self.controller.show(self.req, self.B.id) + expected = make_subproject_body(tenant_id=self.B.id) + self.assertDictMatch(result, expected) def test_show_not_authorized(self): self.controller._get_project = mock.Mock() @@ -168,23 +201,112 @@ class QuotaSetsControllerTest(test.TestCase): self.assertRaises(webob.exc.HTTPForbidden, self.controller.show, self.req, 'foo') + def test_subproject_show_not_authorized(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + self.req.environ['cinder.context'].project_id = self.B.id + self.assertRaises(webob.exc.HTTPForbidden, self.controller.show, + self.req, self.C.id) + self.req.environ['cinder.context'].project_id = self.B.id + self.assertRaises(webob.exc.HTTPForbidden, self.controller.show, + self.req, self.A.id) + def test_update(self): self.controller._get_project = mock.Mock() self.controller._get_project.side_effect = self._get_project body = make_body(gigabytes=2000, snapshots=15, volumes=5, backups=5, tenant_id=None) result = self.controller.update(self.req, 'foo', body) - self.assertDictMatch(result, body) + self.assertDictMatch(body, result) body = make_body(gigabytes=db.MAX_INT, tenant_id=None) result = self.controller.update(self.req, 'foo', body) + self.assertDictMatch(body, result) + + def test_update_subproject(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + # Update the project A quota. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + result = self.controller.update(self.req, self.A.id, body) + self.assertDictMatch(result, body) + # Update the quota of B to be equal to its parent quota + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + result = self.controller.update(self.req, self.B.id, body) + self.assertDictMatch(result, body) + # Try to update the quota of C, it will not be allowed, since the + # project A doesn't have free quota available. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.update, + self.req, self.C.id, body) + # Successfully update the quota of D. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=1000, snapshots=7, + volumes=3, backups=3, tenant_id=None) + result = self.controller.update(self.req, self.D.id, body) self.assertDictMatch(result, body) + # An admin of B can also update the quota of D, since D is its an + # immediate child. + self.req.environ['cinder.context'].project_id = self.B.id + body = make_body(gigabytes=1500, snapshots=10, + volumes=4, backups=4, tenant_id=None) + result = self.controller.update(self.req, self.D.id, body) + + def test_update_subproject_not_in_hierarchy(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + + # Create another project hierarchy + E = self.FakeProject(id=uuid.uuid4().hex, parent_id=None) + F = self.FakeProject(id=uuid.uuid4().hex, parent_id=E.id) + E.subtree = {F.id: F.subtree} + self.project_by_id[E.id] = E + self.project_by_id[F.id] = F + + # Update the project A quota. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + result = self.controller.update(self.req, self.A.id, body) + self.assertDictMatch(result, body) + # Try to update the quota of F, it will not be allowed, since the + # project E doesn't belongs to the project hierarchy of A. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.update, + self.req, F.id, body) + + def test_update_subproject_with_not_root_context_project(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + # Update the project A quota. + self.req.environ['cinder.context'].project_id = self.A.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + result = self.controller.update(self.req, self.A.id, body) + self.assertDictMatch(result, body) + # Try to update the quota of B, it will not be allowed, since the + # project in the context (B) is not a root project. + self.req.environ['cinder.context'].project_id = self.B.id + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, tenant_id=None) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.update, + self.req, self.B.id, body) @mock.patch( 'cinder.api.openstack.wsgi.Controller.validate_string_length') @mock.patch( 'cinder.api.openstack.wsgi.Controller.validate_integer') def test_update_limit(self, mock_validate_integer, mock_validate): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project mock_validate_integer.return_value = 10 body = {'quota_set': {'volumes': 10}} @@ -267,6 +389,8 @@ class QuotaSetsControllerTest(test.TestCase): db.quota_usage_get_all_by_project(ctxt, 'foo')) def test_update_lower_than_existing_resources_when_skip_false(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project self._commit_quota_reservation() body = {'quota_set': {'volumes': 0}, 'skip_validation': 'false'} @@ -278,6 +402,8 @@ class QuotaSetsControllerTest(test.TestCase): self.req, 'foo', body) def test_update_lower_than_existing_resources_when_skip_true(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project self._commit_quota_reservation() body = {'quota_set': {'volumes': 0}, 'skip_validation': 'true'} @@ -286,6 +412,8 @@ class QuotaSetsControllerTest(test.TestCase): result['quota_set']['volumes']) def test_update_lower_than_existing_resources_without_skip_argument(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project self._commit_quota_reservation() body = {'quota_set': {'volumes': 0}} result = self.controller.update(self.req, 'foo', body) @@ -309,6 +437,52 @@ class QuotaSetsControllerTest(test.TestCase): result_show_after = self.controller.show(self.req, 'foo') self.assertDictMatch(result_show, result_show_after) + def test_subproject_delete(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + self.req.environ['cinder.context'].project_id = self.A.id + + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, + backup_gigabytes=1000, tenant_id=None) + result_update = self.controller.update(self.req, self.A.id, body) + self.assertDictMatch(result_update, body) + + # Set usage param to True in order to see get allocated values. + self.req.params = {'usage': 'True'} + result_show = self.controller.show(self.req, self.A.id) + + result_update = self.controller.update(self.req, self.B.id, body) + self.assertDictMatch(result_update, body) + + self.controller.delete(self.req, self.B.id) + + result_show_after = self.controller.show(self.req, self.A.id) + self.assertDictMatch(result_show, result_show_after) + + def test_delete_with_allocated_quota_different_from_zero(self): + self.controller._get_project = mock.Mock() + self.controller._get_project.side_effect = self._get_project + self.req.environ['cinder.context'].project_id = self.A.id + + body = make_body(gigabytes=2000, snapshots=15, + volumes=5, backups=5, + backup_gigabytes=1000, tenant_id=None) + result_update = self.controller.update(self.req, self.A.id, body) + self.assertDictMatch(result_update, body) + + # Set usage param to True in order to see get allocated values. + self.req.params = {'usage': 'True'} + result_show = self.controller.show(self.req, self.A.id) + + result_update = self.controller.update(self.req, self.B.id, body) + self.assertDictMatch(result_update, body) + + self.controller.delete(self.req, self.B.id) + + result_show_after = self.controller.show(self.req, self.A.id) + self.assertDictMatch(result_show, result_show_after) + def test_delete_no_admin(self): self.controller._get_project = mock.Mock() self.controller._get_project.side_effect = self._get_project diff --git a/cinder/tests/unit/test_quota.py b/cinder/tests/unit/test_quota.py index c0f0c753c..bd02a8d1e 100644 --- a/cinder/tests/unit/test_quota.py +++ b/cinder/tests/unit/test_quota.py @@ -1025,15 +1025,27 @@ class DbQuotaDriverTestCase(test.TestCase): self._stub_quota_class_get_all_by_name() + def _stub_allocated_get_all_by_project(self, allocated_quota=False): + def fake_qagabp(context, project_id): + self.calls.append('quota_allocated_get_all_by_project') + self.assertEqual('test_project', project_id) + if allocated_quota: + return dict(project_id=project_id, volumes=3) + return dict(project_id=project_id) + + self.stubs.Set(db, 'quota_allocated_get_all_by_project', fake_qagabp) + def test_get_project_quotas(self): self._stub_get_by_project() self._stub_volume_type_get_all() + self._stub_allocated_get_all_by_project() result = self.driver.get_project_quotas( FakeContext('test_project', 'test_class'), quota.QUOTAS.resources, 'test_project') self.assertEqual(['quota_get_all_by_project', 'quota_usage_get_all_by_project', + 'quota_allocated_get_all_by_project', 'quota_class_get_all_by_name', 'quota_class_get_default', ], self.calls) self.assertEqual(dict(volumes=dict(limit=10, @@ -1056,9 +1068,48 @@ class DbQuotaDriverTestCase(test.TestCase): reserved= 0) ), result) + def test_get_root_project_with_subprojects_quotas(self): + self._stub_get_by_project() + self._stub_volume_type_get_all() + self._stub_allocated_get_all_by_project(allocated_quota=True) + result = self.driver.get_project_quotas( + FakeContext('test_project', None), + quota.QUOTAS.resources, 'test_project') + + self.assertEqual(['quota_get_all_by_project', + 'quota_usage_get_all_by_project', + 'quota_allocated_get_all_by_project', + 'quota_class_get_default', ], self.calls) + self.assertEqual(dict(volumes=dict(limit=10, + in_use=2, + reserved=0, + allocated=3, ), + snapshots=dict(limit=10, + in_use=2, + reserved=0, + allocated=0, ), + gigabytes=dict(limit=50, + in_use=10, + reserved=0, + allocated=0, ), + backups=dict(limit=10, + in_use=2, + reserved=0, + allocated=0, ), + backup_gigabytes=dict(limit=50, + in_use=10, + reserved=0, + allocated=0, ), + per_volume_gigabytes=dict(in_use=0, + limit=-1, + reserved=0, + allocated=0) + ), result) + def test_get_subproject_quotas(self): self._stub_get_by_subproject() self._stub_volume_type_get_all() + self._stub_allocated_get_all_by_project(allocated_quota=True) parent_project_id = 'test_parent_project_id' result = self.driver.get_project_quotas( FakeContext('test_project', None), @@ -1066,25 +1117,32 @@ class DbQuotaDriverTestCase(test.TestCase): parent_project_id=parent_project_id) self.assertEqual(['quota_get_all_by_project', - 'quota_usage_get_all_by_project', ], self.calls) + 'quota_usage_get_all_by_project', + 'quota_allocated_get_all_by_project', ], self.calls) self.assertEqual(dict(volumes=dict(limit=10, in_use=2, - reserved=0, ), + reserved=0, + allocated=3, ), snapshots=dict(limit=0, in_use=0, - reserved=0, ), + reserved=0, + allocated=0, ), gigabytes=dict(limit=50, in_use=10, - reserved=0, ), + reserved=0, + allocated=0, ), backups=dict(limit=0, in_use=0, - reserved=0, ), + reserved=0, + allocated=0, ), backup_gigabytes=dict(limit=0, in_use=0, - reserved=0, ), + reserved=0, + allocated=0, ), per_volume_gigabytes=dict(in_use=0, limit=0, - reserved= 0) + reserved=0, + allocated=0) ), result) def test_get_project_quotas_alt_context_no_class(self): -- 2.45.2