diff --git a/tricircle/common/client.py b/tricircle/common/client.py index e2591f8..1f771ec 100644 --- a/tricircle/common/client.py +++ b/tricircle/common/client.py @@ -280,6 +280,11 @@ class Client(object): """ self._update_endpoint_from_keystone(cxt, False) + def get_keystone_client_by_context(self, ctx): + client_session = self._get_keystone_session() + return keystone_client.Client(auth_url=cfg.CONF.client.identity_url, + session=client_session) + @_safe_operation('client') def get_native_client(self, resource, cxt): """Get native python client instance diff --git a/tricircle/common/quota.py b/tricircle/common/quota.py index d0fe3c8..1ba0660 100644 --- a/tricircle/common/quota.py +++ b/tricircle/common/quota.py @@ -1024,7 +1024,7 @@ class AllQuotaEngine(QuotaEngine): ('injected_files', 'quota_injected_files'), ('injected_file_content_bytes', 'quota_injected_file_content_bytes'), - ('injected_file_path_length', + ('injected_file_path_bytes', 'quota_injected_file_path_length'), ] @@ -1344,12 +1344,12 @@ class QuotaSetOperation(object): target_project = self._get_project(context, target_project_id) parent_id = target_project.parent_id + context_project = self._get_project(context, + context.project_id, + subtree_as_ids=True) 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) @@ -1357,13 +1357,23 @@ class QuotaSetOperation(object): context, parent_id) else: - if context.project_id != target_project_id: + + # if the target project has no parent and descendant, then + # the operation is allowed only if the context project also + # has no parent and descendant, that means only flat mode + # (current mode without hierarchy) is allowed + + if not target_project.subtree and \ + not context_project.parent_id and \ + not context_project.subtree: + pass + elif context.project_id != target_project_id: param_msg = _("context.project_id = %(ctx_project_id)s, " "target_project_id = %(target_project_id)s ") % { "ctx_project_id": context.project_id, "target_project_id": target_project.id} - msg = _("context project is not a root project %s") % param_msg + msg = _("Can not update quota for %s") % param_msg LOG.error(msg=msg) raise t_exceptions.HTTPForbiddenError @@ -1450,19 +1460,53 @@ class QuotaSetOperation(object): if not context.is_admin: raise t_exceptions.AdminRequired - target_tenant_id = self.target_tenant_id + target_project_id = self.target_tenant_id # 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_tenant_id) + target_project = self._get_project(context, target_project_id) parent_id = target_project.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) + + if parent_id: + 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) + + else: + + # if the target project has no parent and descendant, then + # the operation is allowed only if the context project also + # has no parent and descendant, that means only flat mode + # (current mode without hierarchy) is allowed + + if not target_project.subtree and \ + not context_project.parent_id and \ + not context_project.subtree: + pass + elif context.project_id != target_project_id: + param_msg = _("context.project_id = %(ctx_project_id)s, " + "target_project_id = %(target_project_id)s ") % { + "ctx_project_id": context.project_id, + "target_project_id": target_project.id} + + msg = _("Can not delete quota for %s") % param_msg + LOG.error(msg=msg) + raise t_exceptions.HTTPForbiddenError + try: project_quotas = QUOTAS.get_project_quotas( context, target_project.id, usages=True, parent_project_id=parent_id, defaults=False) except t_exceptions.NotAuthorized: - msg = _("Not authorized to delete %s") % target_tenant_id + msg = _("Not authorized to delete %s") % target_project_id LOG.exception(msg) raise @@ -1474,33 +1518,22 @@ class QuotaSetOperation(object): if project_quotas[key]['allocated'] != 0: msg = _("About to delete child projects having " "non-zero quota. This should not be performed" - " %s") % target_tenant_id + " %s") % target_project_id LOG.exception(msg) raise t_exceptions.ChildQuotaNotZero + # Delete child quota first and later update parent's quota. + try: + # TODO(joehuang) support destroy quota by user + db_api.quota_destroy_by_project(context, target_project.id) + except t_exceptions.AdminRequired: + msg = _('Admin or tenant itself or parent tenant' + ' required to delete quota' + ' %s') % target_project.id + LOG.exception(msg) + raise + 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: - # TODO(joehuang) support destroy quota by user - db_api.quota_destroy_by_project(context, target_project.id) - except t_exceptions.AdminRequired: - msg = _('Admin or tenant itself or parent tenant' - ' required to delete quota' - ' %s') % target_project.id - LOG.exception(msg) - raise - # Update the allocated of the parent for key, value in project_quotas.items(): project_hard_limit = project_quotas[key]['limit'] @@ -1508,16 +1541,6 @@ class QuotaSetOperation(object): parent_allocated -= project_hard_limit db_api.quota_allocated_update(context, parent_id, key, parent_allocated) - else: - try: - # TODO(joehuang) support destroy quota by user - db_api.quota_destroy_by_project(context, target_project.id) - except t_exceptions.AdminRequired: - msg = _('Admin or tenant itself or parent tenant' - ' required to delete quota' - ' %s') % target_project.id - LOG.exception(msg) - raise def show_default_quota(self, context): try: diff --git a/tricircle/db/migrate_repo/versions/002_resource.py b/tricircle/db/migrate_repo/versions/002_resource.py index 8835d26..c4305b5 100644 --- a/tricircle/db/migrate_repo/versions/002_resource.py +++ b/tricircle/db/migrate_repo/versions/002_resource.py @@ -126,9 +126,6 @@ def upgrade(migrate_engine): sql.Column('updated_at', sql.DateTime), sql.Column('deleted_at', sql.DateTime), sql.Column('deleted', sql.Integer), - migrate.UniqueConstraint( - 'project_id', 'resource', 'deleted', - name='uniq_quotas0project_id0resource0deleted'), mysql_engine='InnoDB', mysql_charset='utf8') @@ -142,9 +139,6 @@ def upgrade(migrate_engine): sql.Column('updated_at', sql.DateTime), sql.Column('deleted_at', sql.DateTime), sql.Column('deleted', sql.Integer), - migrate.UniqueConstraint( - 'class_name', 'resource', 'deleted', - name='uniq_quota_classes0class_name0resource0deleted'), mysql_engine='InnoDB', mysql_charset='utf8') diff --git a/tricircle/db/models.py b/tricircle/db/models.py index 5cd5a13..5414589 100644 --- a/tricircle/db/models.py +++ b/tricircle/db/models.py @@ -174,10 +174,7 @@ class Quotas(core.ModelBase, QuotasBase): Null, then the resource is unlimited. """ __tablename__ = 'quotas' - __table_args__ = ( - schema.UniqueConstraint( - 'project_id', 'resource', 'deleted', - name='uniq_quotas0project_id0resource0deleted'),) + __table_args__ = () attributes = ['id', 'project_id', 'resource', 'hard_limit', 'allocated', 'created_at', 'updated_at', 'deleted_at', 'deleted'] @@ -196,11 +193,7 @@ class QuotaClasses(core.ModelBase, QuotasBase): quota-class-show and quota-class-update """ __tablename__ = 'quota_classes' - __table_args__ = ( - schema.UniqueConstraint( - 'class_name', 'resource', 'deleted', - name='uniq_quota_classes0class_name0resource0deleted'), - ) + __table_args__ = () attributes = ['id', 'class_name', 'resource', 'hard_limit', 'created_at', 'updated_at', 'deleted_at', 'deleted'] diff --git a/tricircle/nova_apigw/controllers/quota_sets.py b/tricircle/nova_apigw/controllers/quota_sets.py new file mode 100644 index 0000000..e62647d --- /dev/null +++ b/tricircle/nova_apigw/controllers/quota_sets.py @@ -0,0 +1,269 @@ +# Copyright (c) 2015 Huawei Tech. Co., Ltd. +# 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 six + +from pecan import expose +from pecan import request +from pecan import response +from pecan import Response +from pecan import rest + +from oslo_config import cfg +from oslo_log import log as logging + +import tricircle.common.context as t_context +from tricircle.common import exceptions as t_exceptions +from tricircle.common.i18n import _ +from tricircle.common import quota + + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +class QuotaSetsController(rest.RestController): + + def __init__(self, tenant_id): + self.tenant_id = tenant_id + + @expose() + def _lookup(self, target_tenant_id, *remainder): + return QuotaController(self.tenant_id, target_tenant_id), remainder + + +def build_absolute_limits(quotas): + + quota_map = { + 'maxTotalRAMSize': 'ram', + 'maxTotalInstances': 'instances', + 'maxTotalCores': 'cores', + 'maxTotalKeypairs': 'key_pairs', + 'maxTotalFloatingIps': 'floating_ips', + 'maxPersonality': 'injected_files', + 'maxPersonalitySize': 'injected_file_content_bytes', + 'maxSecurityGroups': 'security_groups', + 'maxSecurityGroupRules': 'security_group_rules', + 'maxServerMeta': 'metadata_items', + 'maxServerGroups': 'server_groups', + 'maxServerGroupMembers': 'server_group_members', + } + + limits = {} + for display_name, key in six.iteritems(quota_map): + if key in quotas: + limits[display_name] = quotas[key]['limit'] + return limits + + +def build_used_limits(quotas): + + quota_map = { + 'totalRAMUsed': 'ram', + 'totalCoresUsed': 'cores', + 'totalInstancesUsed': 'instances', + 'totalFloatingIpsUsed': 'floating_ips', + 'totalSecurityGroupsUsed': 'security_groups', + 'totalServerGroupsUsed': 'server_groups', + } + + # need to refresh usage from the bottom pods? Now from the data in top + used_limits = {} + for display_name, key in six.iteritems(quota_map): + if key in quotas: + reserved = quotas[key]['reserved'] + used_limits[display_name] = quotas[key]['in_use'] + reserved + + return used_limits + + +class LimitsController(rest.RestController): + + def __init__(self, tenant_id): + self.tenant_id = tenant_id + + @staticmethod + def _reserved(req): + try: + return int(req.GET['reserved']) + except (ValueError, KeyError): + return False + + @expose(generic=True, template='json') + def get_all(self): + + # TODO(joehuang): add policy controll here + + context = t_context.extract_context_from_environ() + context.project_id = self.tenant_id + target_tenant_id = request.params.get('tenant_id', None) + if target_tenant_id: + target_tenant_id.strip() + else: + return Response('tenant_id not given', 400) + + qs = quota.QuotaSetOperation(target_tenant_id, + None) + try: + quotas = qs.show_detail_quota(context, show_usage=True) + except t_exceptions.NotFound as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 404) + except (t_exceptions.AdminRequired, + t_exceptions.NotAuthorized, + t_exceptions.HTTPForbiddenError) as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 403) + except Exception as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 400) + + # TODO(joehuang): add API rate limits later + ret = { + 'limits': { + 'rate': {}, + 'absolute': {}, + }, + } + + ret['limits']['absolute'].update( + build_absolute_limits(quotas['quota_set'])) + ret['limits']['absolute'].update( + build_used_limits(quotas['quota_set'])) + + return ret + + +class QuotaController(rest.RestController): + + def __init__(self, owner_tenant_id, target_tenant_id): + self.owner_tenant_id = owner_tenant_id + self.target_tenant_id = target_tenant_id + + @expose(generic=True, template='json') + def put(self, **kw): + + context = t_context.extract_context_from_environ() + if not context.is_admin: + # TODO(joahuang): changed to policy control later + # to support reseller admin mode + return Response(_('Admin role required to update quota'), 409) + + return self._quota_action('put', **kw) + + @expose(generic=True, template='json') + def delete(self): + """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 id: target project id that needs to be deleted + """ + + context = t_context.extract_context_from_environ() + if not context.is_admin: + # TODO(joahuang): changed to policy control later + # to support reseller admin mode + return Response(_('Admin role required to delete quota'), 409) + + kw = {} + return self._quota_action('delete', **kw) + + @expose(generic=True, template='json') + def get_one(self, show_what): + kw = {} + if show_what == 'defaults' or show_what == 'detail': + return self._quota_action(show_what, **kw) + else: + return Response(_('Only show defaults or detail allowed'), 400) + + @expose(generic=True, template='json') + def get_all(self): + kw = {} + return self._quota_action('quota-show', **kw) + + def _quota_action(self, action, **kw): + + context = t_context.extract_context_from_environ() + context.project_id = self.owner_tenant_id + target_tenant_id = self.target_tenant_id + target_user_id = request.params.get('user_id', None) + if target_user_id: + target_user_id.strip() + + qs = quota.QuotaSetOperation(target_tenant_id, + target_user_id) + quotas = {} + try: + if action == 'put': + quotas = qs.update(context, **kw) + elif action == 'delete': + qs.delete(context) + response.status = 202 + return + elif action == 'defaults': + quotas = qs.show_default_quota(context) + elif action == 'detail': + quotas = qs.show_detail_quota(context, show_usage=True) + + # remove the allocated field which is not visible in Nova + for k, v in quotas['quota_set'].iteritems(): + if k != 'id': + v.pop('allocated', None) + + elif action == 'quota-show': + quotas = qs.show_detail_quota(context, show_usage=False) + else: + return Response('Resource not found', 404) + except t_exceptions.NotFound as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 404) + except (t_exceptions.AdminRequired, + t_exceptions.NotAuthorized, + t_exceptions.HTTPForbiddenError) as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 403) + except Exception as e: + msg = str(e) + LOG.exception(msg=msg) + return Response(msg, 400) + + return {'quota_set': self._build_visible_quota(quotas['quota_set'])} + + def _build_visible_quota(self, quota_set): + quota_map = [ + 'id', 'instances', 'ram', 'cores', 'key_pairs', + 'floating_ips', 'fixed_ips', + 'injected_files', 'injected_file_path_bytes', + 'injected_file_content_bytes', + 'security_groups', 'security_group_rules', + 'metadata_items', 'server_groups', 'server_group_members', + ] + + ret = {} + # only return Nova visible quota items + for k, v in quota_set.iteritems(): + if k in quota_map: + ret[k] = v + + return ret diff --git a/tricircle/nova_apigw/controllers/root.py b/tricircle/nova_apigw/controllers/root.py index 52ee00c..e062f6a 100755 --- a/tricircle/nova_apigw/controllers/root.py +++ b/tricircle/nova_apigw/controllers/root.py @@ -28,6 +28,7 @@ from tricircle.common import xrpcapi from tricircle.nova_apigw.controllers import aggregate from tricircle.nova_apigw.controllers import flavor from tricircle.nova_apigw.controllers import image +from tricircle.nova_apigw.controllers import quota_sets from tricircle.nova_apigw.controllers import server @@ -89,6 +90,8 @@ class V21Controller(object): 'os-aggregates': aggregate.AggregateController, 'servers': server.ServerController, 'images': image.ImageController, + 'os-quota-sets': quota_sets.QuotaSetsController, + 'limits': quota_sets.LimitsController, } def _get_resource_controller(self, project_id, remainder): diff --git a/tricircle/tests/functional/nova_apigw/controllers/test_quota_sets.py b/tricircle/tests/functional/nova_apigw/controllers/test_quota_sets.py new file mode 100644 index 0000000..1be1ba9 --- /dev/null +++ b/tricircle/tests/functional/nova_apigw/controllers/test_quota_sets.py @@ -0,0 +1,479 @@ +# Copyright (c) 2015 Huawei Technologies Co., Ltd. +# 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 copy +import mock +import pecan +from pecan.configuration import set_config +from pecan.testing import load_test_app + +from oslo_config import cfg +from oslo_config import fixture as fixture_config +from oslo_serialization import jsonutils + +from tricircle.nova_apigw import app +from tricircle.nova_apigw.controllers import quota_sets + +from tricircle.common import context +from tricircle.common import exceptions as t_exceptions +from tricircle.common import quota +from tricircle.db import core + +from tricircle.tests.unit.common import test_quota + +QUOTAS = quota.QUOTAS + + +def _make_body(tenant_id='foo', root=True, **kw): + resources = copy.copy(kw) + if tenant_id: + resources['id'] = tenant_id + if root: + result = {'quota_set': resources} + else: + result = resources + return result + + +def _update_body(src_body, root=True, **kw): + for k, v in kw.iteritems(): + if root: + src_body['quota_set'][k] = v + else: + src_body[k] = v + return src_body + + +def _update_subproject_body(src_body, root=True, **kw): + for k, v in kw.iteritems(): + if root: + src_body['quota_set'][k] = v + else: + src_body[k] = v + + if root: + for k, v in src_body['quota_set'].iteritems(): + if not kw.get(k): + src_body['quota_set'][k] = 0 + + else: + for k, v in src_body.iteritems(): + if not kw.get(k) and k != 'id': + src_body[k] = 0 + + return src_body + + +def _make_subproject_body(tenant_id='foo', root=True, **kw): + return _make_body(tenant_id=tenant_id, root=root, **kw) + + +class QuotaControllerTest(test_quota.QuotaSetsOperationTest): + + def setUp(self): + super(QuotaControllerTest, self).setUp() + + self.addCleanup(set_config, {}, overwrite=True) + + cfg.CONF.register_opts(app.common_opts) + self.CONF = self.useFixture(fixture_config.Config()).conf + self.CONF.set_override('auth_strategy', 'noauth') + + self.exception_string = 'NotFound' + self.test_exception = [ + {'exception_raise': 'NotFound', + 'expected_error': 404}, + {'exception_raise': 'AdminRequired', + 'expected_error': 403}, + {'exception_raise': 'NotAuthorized', + 'expected_error': 403}, + {'exception_raise': 'HTTPForbiddenError', + 'expected_error': 403}, + {'exception_raise': 'Conflict', + 'expected_error': 400}, ] + + self._flags_rest(use_default_quota_class=True) + + self.app = self._make_app() + + def _make_app(self, enable_acl=False): + self.config = { + 'app': { + 'root': + 'tricircle.nova_apigw.controllers.root.RootController', + 'modules': ['tricircle.nova_apigw'], + 'enable_acl': enable_acl, + 'errors': { + 400: '/error', + '__force_dict__': True + } + }, + } + + return load_test_app(self.config) + + def _override_config_rest(self, name, override, group=None): + """Cleanly override CONF variables.""" + self.CONF.set_override(name, override, group) + self.addCleanup(self.CONF.clear_override, name, group) + + def _flags_rest(self, **kw): + """Override CONF variables for a test.""" + for k, v in kw.items(): + self._override_config_rest(k, v, group='quota') + + def tearDown(self): + super(QuotaControllerTest, self).tearDown() + pecan.set_config({}, overwrite=True) + cfg.CONF.unregister_opts(app.common_opts) + core.ModelBase.metadata.drop_all(core.get_engine()) + + def _get_mock_ctx(self): + return self.ctx + + def _mock_func_and_obj(self): + quota.QuotaSetOperation._get_project = mock.Mock() + quota.QuotaSetOperation._get_project.side_effect = self._get_project + context.extract_context_from_environ = mock.Mock() + context.extract_context_from_environ.side_effect = self._get_mock_ctx + + def test_quota_set_update_show_defaults(self): + self._mock_func_and_obj() + + # show quota before update, should be equal to defaults + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url, expect_errors=True) + self.assertEqual(res.status_int, 200) + json_body = jsonutils.loads(res.body) + default_body = _make_body(tenant_id=self.A.id, root=True, + **self.default_quota) + result = self._DictIn(json_body['quota_set'], + default_body['quota_set']) + self.assertEqual(result, True) + + # quota update with wrong parameter + quota_a = dict(instances=5, cores=10, ram=25600) + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.put_json(url, + {'quota_s': quota_a}, + expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + + # quota update with non-admin + self.ctx.is_admin = False + quota_a = dict(instances=5, cores=10, ram=25600) + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.put_json(url, + {'quota_set': quota_a}, + expect_errors=True) + self.assertIn(res.status_int, [409]) + self.ctx.is_admin = True + + # show quota after update + quota_a = dict(instances=5, cores=10, ram=25600) + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.put_json(url, + {'quota_set': quota_a}, + expect_errors=True) + json_body = jsonutils.loads(res.body) + updated_a = _make_body(tenant_id=self.A.id, root=True, + **self.default_quota) + updated_a = _update_body(updated_a, root=True, **quota_a) + result = self._DictIn(json_body['quota_set'], updated_a['quota_set']) + self.assertEqual(result, True) + + self.ctx.is_admin = False + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url, expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + self.ctx.is_admin = True + + # show quota after update for child + quota_b = dict(instances=3, cores=5, ram=12800) + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.put_json(url, + {'quota_set': quota_b}, + expect_errors=True) + json_body = jsonutils.loads(res.body) + updated_b = _make_body(tenant_id=self.B.id, root=False, + **self.default_quota) + updated_b = _update_subproject_body(updated_b, root=False, **quota_b) + result = self._DictIn(json_body['quota_set'], updated_b) + self.assertEqual(result, True) + + # show default quota after update + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url + '/defaults', expect_errors=True) + json_body = jsonutils.loads(res.body) + result = self._DictIn(json_body['quota_set'], + default_body['quota_set']) + self.assertEqual(result, True) + + # show default quota for child, should be all 0 + quota_c = {} + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.get(url + '/defaults', expect_errors=True) + json_body = jsonutils.loads(res.body) + updated_c = _make_body(tenant_id=self.B.id, root=False, + **self.default_quota) + updated_c = _update_subproject_body(updated_c, root=False, **quota_c) + result = self._DictIn(json_body['quota_set'], updated_c) + self.assertEqual(result, True) + + # show quota after update + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url, expect_errors=True) + json_body = jsonutils.loads(res.body) + result = self._DictIn(json_body['quota_set'], updated_a['quota_set']) + self.assertEqual(result, True) + + # show quota for child, should be equal to update_b + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.get(url, expect_errors=True) + json_body = jsonutils.loads(res.body) + result = self._DictIn(json_body['quota_set'], updated_b) + self.assertEqual(result, True) + + # delete with non-admin + self.ctx.is_admin = False + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.delete(url, expect_errors=True) + self.assertIn(res.status_int, [409]) + self.ctx.is_admin = True + + # delete parent quota when child quota is not zero + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.delete(url, expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + + # delete child quota + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.delete(url, expect_errors=True) + self.assertEqual(res.status_int, 202) + + # delete parent quota when child quota is deleted + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.delete(url, expect_errors=True) + self.assertEqual(res.status_int, 202) + + # show quota for parent after delete, equal to defaults + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url, expect_errors=True) + json_body = jsonutils.loads(res.body) + result = self._DictIn(json_body['quota_set'], + default_body['quota_set']) + self.assertEqual(result, True) + + # show quota for child after delete, should be all 0 + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.get(url, expect_errors=True) + json_body = jsonutils.loads(res.body) + result = self._DictIn(json_body['quota_set'], updated_c) + self.assertEqual(result, True) + + def test_quota_detail_limits(self): + self._mock_func_and_obj() + + def _make_default_detail_body(tenant_id='foo'): + resources = copy.copy(self.default_quota) + + for k, v in self.default_quota.iteritems(): + resources[k] = {} + resources[k]['limit'] = v + resources[k]['reserved'] = 0 + resources[k]['in_use'] = 0 + + if tenant_id: + resources['id'] = tenant_id + + return resources + + def _update_usage_in_default_detail(quota_item, + reserved, in_use, **kw): + kw[quota_item]['reserved'] = reserved + kw[quota_item]['in_use'] = in_use + return kw + + # show quota usage before update, should be equal to defaults + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url + '/detail', expect_errors=True) + self.assertEqual(res.status_int, 200) + json_body = jsonutils.loads(res.body) + default_detail = _make_default_detail_body(self.A.id) + result = self._DictIn(json_body['quota_set'], default_detail) + self.assertEqual(result, True) + + # show quota usage after reserve and in_use update + inuse_opts = {'instances': 2, 'cores': 5} + reserve_opts = {'instances': 3, 'cores': 3} + self.ctx.project_id = self.A.id + reservations = QUOTAS.reserve(self.ctx, + project_id=self.A.id, + **inuse_opts) + QUOTAS.commit(self.ctx, reservations, self.A.id) + QUOTAS.reserve(self.ctx, project_id=self.A.id, **reserve_opts) + + url = self._url_for_quota_set(self.A.id, self.A.id) + res = self.app.get(url + '/detail', expect_errors=True) + self.assertEqual(res.status_int, 200) + json_body = jsonutils.loads(res.body) + default_detail = _make_default_detail_body(self.A.id) + update_detail = _update_usage_in_default_detail( + 'instances', + reserve_opts['instances'], + inuse_opts['instances'], + **default_detail) + update_detail = _update_usage_in_default_detail( + 'cores', + reserve_opts['cores'], + inuse_opts['cores'], + **update_detail) + result = self._DictIn(json_body['quota_set'], update_detail) + self.assertEqual(result, True) + + # Wrong parameter + url = '/v2.1/' + self.A.id + '/limits?_id=' + self.A.id + res = self.app.get(url, expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + + url = '/v2.1/' + self.A.id + '/limits' + res = self.app.get(url, expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + + self.ctx.is_admin = False + url = '/v2.1/' + self.B.id + '/limits?tenant_id=' + self.C.id + res = self.app.get(url, expect_errors=True) + self.assertIn(res.status_int, [400, 403, 404]) + self.ctx.is_admin = True + + # test absolute limits and usage + url = '/v2.1/' + self.A.id + '/limits?tenant_id=' + self.A.id + res = self.app.get(url, expect_errors=True) + self.assertEqual(res.status_int, 200) + json_body = jsonutils.loads(res.body) + ret_limits = json_body['limits']['absolute'] + + absolute = {} + absolute.update(quota_sets.build_absolute_limits(update_detail)) + absolute.update(quota_sets.build_used_limits(update_detail)) + + result = self._DictIn(absolute, ret_limits) + self.assertEqual(result, True) + + # test child limits, set child quota + quota_b = dict(instances=3, cores=5) + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.put_json(url, + {'quota_set': quota_b}, + expect_errors=True) + json_body = jsonutils.loads(res.body) + updated_b = _make_body(tenant_id=self.B.id, root=False, + **self.default_quota) + updated_b = _update_subproject_body(updated_b, root=False, **quota_b) + result = self._DictIn(json_body['quota_set'], updated_b) + self.assertEqual(result, True) + + # test child limits, use and reserve child quota + inuse_opts = {'instances': 1, 'cores': 1} + reserve_opts = {'instances': 1, 'cores': 2} + self.ctx.project_id = self.A.id + reservations = QUOTAS.reserve(self.ctx, + project_id=self.B.id, + **inuse_opts) + QUOTAS.commit(self.ctx, reservations, self.B.id) + QUOTAS.reserve(self.ctx, project_id=self.B.id, **reserve_opts) + url = self._url_for_quota_set(self.A.id, self.B.id) + res = self.app.get(url + '/detail', expect_errors=True) + self.assertEqual(res.status_int, 200) + child_json_body = jsonutils.loads(res.body) + + self.assertEqual( + child_json_body['quota_set']['instances']['limit'], + quota_b['instances']) + self.assertEqual( + child_json_body['quota_set']['instances']['in_use'], + inuse_opts['instances']) + self.assertEqual( + child_json_body['quota_set']['instances']['reserved'], + reserve_opts['instances']) + + self.assertEqual( + child_json_body['quota_set']['cores']['limit'], + quota_b['cores']) + self.assertEqual( + child_json_body['quota_set']['cores']['in_use'], + inuse_opts['cores']) + self.assertEqual( + child_json_body['quota_set']['cores']['reserved'], + reserve_opts['cores']) + + # test child limits, get child quota limits and compare + url = '/v2.1/' + self.A.id + '/limits?tenant_id=' + self.B.id + res = self.app.get(url, expect_errors=True) + self.assertEqual(res.status_int, 200) + json_body = jsonutils.loads(res.body) + ret_limits = json_body['limits']['absolute'] + + self.assertEqual( + ret_limits['maxTotalInstances'], + quota_b['instances']) + self.assertEqual( + ret_limits['maxTotalCores'], + quota_b['cores']) + self.assertEqual( + ret_limits['totalInstancesUsed'], + inuse_opts['instances'] + reserve_opts['instances']) + self.assertEqual( + ret_limits['totalCoresUsed'], + inuse_opts['cores'] + reserve_opts['cores']) + + def _show_detail_exception(self, context, show_usage=False): + for todo_exception in self.test_exception: + if todo_exception['exception_raise'] == self.exception_string: + e = getattr(t_exceptions, self.exception_string) + raise e() + + def test_quota_sets_exception_catch(self): + + orig_show = quota.QuotaSetOperation.show_detail_quota + quota.QuotaSetOperation.show_detail_quota = mock.Mock() + quota.QuotaSetOperation.show_detail_quota.side_effect = \ + self._show_detail_exception + + # show quota usage before update, should be equal to defaults + for todo_exception in self.test_exception: + self.exception_string = todo_exception['exception_raise'] + + url = self._url_for_quota_set(self.A.id, self.A.id) + + # exception raised in LimitsController + res = self.app.get(url + '/detail', expect_errors=True) + self.assertEqual(res.status_int, todo_exception['expected_error']) + + # exception raised in QuotaSetController + res = self.app.get(url, expect_errors=True) + self.assertEqual(res.status_int, todo_exception['expected_error']) + + quota.QuotaSetOperation.show_detail_quota = orig_show + + def _url_for_quota_set(self, owner_tenant_id, target_tenant_id): + return '/v2.1/' + owner_tenant_id + \ + '/os-quota-sets/' + target_tenant_id + + def _DictIn(self, dict_small, dict_full): + for k, v in dict_small.iteritems(): + if dict_full[k] != v: + return False + return True diff --git a/tricircle/tests/unit/common/test_quota.py b/tricircle/tests/unit/common/test_quota.py index 6da0de9..4873f55 100644 --- a/tricircle/tests/unit/common/test_quota.py +++ b/tricircle/tests/unit/common/test_quota.py @@ -664,7 +664,7 @@ class DbQuotaDriverTestCase(QuotaTestBase, base.TestCase): self.default_quota = dict( instances=10, cores=20, ram=51200, floating_ips=10, fixed_ips=-1, metadata_items=128, - injected_files=5, injected_file_path_length=255, + injected_files=5, injected_file_path_bytes=255, injected_file_content_bytes=10240, security_groups=10, security_group_rules=20, key_pairs=100, server_groups=10, server_group_members=10, @@ -686,7 +686,7 @@ class DbQuotaDriverTestCase(QuotaTestBase, base.TestCase): quota_metadata_items=self.default_quota['metadata_items'], quota_injected_files=self.default_quota['injected_files'], quota_injected_file_path_length=self.default_quota[ - 'injected_file_path_length'], + 'injected_file_path_bytes'], quota_injected_file_content_bytes=self.default_quota[ 'injected_file_content_bytes'], quota_security_groups=self.default_quota['security_groups'], @@ -1669,16 +1669,16 @@ class QuotaSetsOperationTest(DbQuotaDriverTestCase, base.TestCase): """Sets an environment used for nested quotas tests. Create a project hierarchy such as follows: - +-----------+ - | | - | A | - | / \ | - | B C | - | / | - | D | - | | | - | E | - +-----------+ + +-----------++-----------++-----------+ + | || || | + | A || F || G | + | / \ || || | + | B C || || | + | / || || | + | D || || | + | | || || | + | E || || | + +-----------++-----------++-----------+ """ self.A = self.FakeProject(id=uuidutils.generate_uuid(), parent_id=None) @@ -1690,6 +1690,10 @@ class QuotaSetsOperationTest(DbQuotaDriverTestCase, base.TestCase): parent_id=self.B.id) self.E = self.FakeProject(id=uuidutils.generate_uuid(), parent_id=self.D.id) + self.F = self.FakeProject(id=uuidutils.generate_uuid(), + parent_id=None) + self.G = self.FakeProject(id=uuidutils.generate_uuid(), + parent_id=None) # update projects subtrees self.D.subtree = {self.E.id: self.E.subtree} @@ -1699,7 +1703,8 @@ class QuotaSetsOperationTest(DbQuotaDriverTestCase, base.TestCase): # project_by_id attribute is used to recover a project based on its id self.project_by_id = {self.A.id: self.A, self.B.id: self.B, self.C.id: self.C, self.D.id: self.D, - self.E.id: self.E} + self.E.id: self.E, self.F.id: self.F, + self.G.id: self.G, } def _create_default_class(self): for k, v in self.default_quota.items(): @@ -1988,6 +1993,46 @@ class QuotaSetsOperationTest(DbQuotaDriverTestCase, base.TestCase): self.assertRaises(exceptions.HTTPForbiddenError, qso.update, self.ctx, **updated) + def test_update_subproject_not_in_hierarchy2(self): + qso = quota.QuotaSetOperation(self.F.id) + qso._get_project = mock.Mock() + qso._get_project.side_effect = self._get_project + self.ctx.project_id = self.A.id + + updated = _make_body(tenant_id=None, root=True, + **self.test_class_quota) + expected = _make_body(tenant_id=None, root=True, + **self.test_class_expected_result) + + # Update the quota of F is not allowed + self.assertRaises(exceptions.HTTPForbiddenError, qso.update, + self.ctx, **updated) + + self.ctx.project_id = self.B.id + self.assertRaises(exceptions.HTTPForbiddenError, qso.update, + self.ctx, **updated) + + # only admin is allowed yet + self.ctx.is_admin = False + self.ctx.project_id = self.G.id + self.assertRaises(exceptions.AdminRequired, qso.update, + self.ctx, **updated) + + self.ctx.is_admin = True + self.ctx.project_id = self.G.id + result = qso.update(self.ctx, **updated) + self.assertDictMatch(expected, result) + + self.ctx.is_admin = True + self.ctx.project_id = self.F.id + result = qso.update(self.ctx, **updated) + self.assertDictMatch(expected, result) + + self.ctx.is_admin = False + self.ctx.project_id = self.F.id + self.assertRaises(exceptions.AdminRequired, qso.update, + self.ctx, **updated) + def test_update_subproject_with_not_root_context_project(self): qso = quota.QuotaSetOperation(self.A.id) qso._get_project = mock.Mock() @@ -2163,6 +2208,53 @@ class QuotaSetsOperationTest(DbQuotaDriverTestCase, base.TestCase): result_show_after = qso.show_detail_quota(self.ctx) self.assertDictMatch(result_show, result_show_after) + def test_delete_subproject_not_in_hierarchy2(self): + qso = quota.QuotaSetOperation(self.F.id) + qso._get_project = mock.Mock() + qso._get_project.side_effect = self._get_project + self.ctx.project_id = self.A.id + + # delete the quota of F is not allowed + self.assertRaises(exceptions.HTTPForbiddenError, qso.delete, + self.ctx) + + self.ctx.project_id = self.B.id + self.assertRaises(exceptions.HTTPForbiddenError, qso.delete, + self.ctx) + + # only admin is allowed yet + self.ctx.project_id = self.G.id + self.ctx.is_admin = False + self.assertRaises(exceptions.AdminRequired, qso.delete, + self.ctx) + + self.ctx.project_id = self.G.id + self.ctx.is_admin = True + result_show = qso.show_detail_quota(self.ctx) + updated = _make_body(tenant_id=None, root=True, + **self.test_class_quota) + expected = _make_body(tenant_id=None, root=True, + **self.test_class_expected_result) + self.ctx.is_admin = True + self.ctx.project_id = self.G.id + result = qso.update(self.ctx, **updated) + self.assertDictMatch(expected, result) + + qso.delete(self.ctx) + result_show_after = qso.show_detail_quota(self.ctx) + self.assertDictMatch(result_show, result_show_after) + + self.ctx.project_id = self.F.id + self.ctx.is_admin = True + qso.delete(self.ctx) + result_show_after = qso.show_detail_quota(self.ctx) + self.assertDictMatch(result_show, result_show_after) + + self.ctx.project_id = self.F.id + self.ctx.is_admin = False + self.assertRaises(exceptions.AdminRequired, qso.delete, + self.ctx) + def test_subproject_delete(self): qso = quota.QuotaSetOperation(self.A.id) qso._get_project = mock.Mock()