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):