diff --git a/zun/api/controllers/v1/__init__.py b/zun/api/controllers/v1/__init__.py index 4dc33b41f..1544a4a26 100644 --- a/zun/api/controllers/v1/__init__.py +++ b/zun/api/controllers/v1/__init__.py @@ -29,6 +29,8 @@ from zun.api.controllers.v1 import containers as container_controller from zun.api.controllers.v1 import hosts as host_controller from zun.api.controllers.v1 import images as image_controller from zun.api.controllers.v1 import networks as network_controller +from zun.api.controllers.v1 import quota_classes as quota_classes_controller +from zun.api.controllers.v1 import quotas as quotas_controller from zun.api.controllers.v1 import zun_services from zun.api.controllers import versions as ver from zun.api import http_error @@ -71,7 +73,9 @@ class V1(controllers_base.APIBase): 'networks', 'hosts', 'capsules', - 'availability_zones' + 'availability_zones', + 'quotas', + 'quota_classes' ) @staticmethod @@ -85,8 +89,9 @@ class V1(controllers_base.APIBase): 'developer/zun/dev', 'api-spec-v1.html', bookmark=True, type='text/html')] - v1.media_types = [MediaType(base='application/json', - type='application/vnd.openstack.zun.v1+json')] + v1.media_types = [MediaType( + base='application/json', + type='application/vnd.openstack.zun.v1+json')] v1.services = [link.make_link('self', pecan.request.host_url, 'services', ''), link.make_link('bookmark', @@ -129,6 +134,18 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'capsules', '', bookmark=True)] + v1.quotas = [link.make_link('self', pecan.request.host_url, + 'quotas', ''), + link.make_link('bookmark', + pecan.request.host_url, + 'quotas', '', + bookmark=True)] + v1.quota_classes = [link.make_link('self', pecan.request.host_url, + 'quota_classes', ''), + link.make_link('bookmark', + pecan.request.host_url, + 'quota_classes', '', + bookmark=True)] return v1 @@ -142,6 +159,8 @@ class Controller(controllers_base.Controller): hosts = host_controller.HostController() availability_zones = a_zone.AvailabilityZoneController() capsules = capsule_controller.CapsuleController() + quotas = quotas_controller.QuotaController() + quota_classes = quota_classes_controller.QuotaClassController() @pecan.expose('json') def get(self): diff --git a/zun/api/controllers/v1/containers.py b/zun/api/controllers/v1/containers.py index 7a9fed69f..82ed63388 100644 --- a/zun/api/controllers/v1/containers.py +++ b/zun/api/controllers/v1/containers.py @@ -36,6 +36,7 @@ from zun.common.i18n import _ from zun.common import name_generator from zun.common.policies import container as policies from zun.common import policy +from zun.common import quota from zun.common import utils import zun.conf from zun.network import model as network_model @@ -46,6 +47,7 @@ from zun.volume import cinder_api as cinder CONF = zun.conf.CONF LOG = logging.getLogger(__name__) +QUOTAS = quota.QUOTAS def check_policy_on_container(container, action): @@ -329,6 +331,9 @@ class ContainersController(base.Controller): raise exception.InvalidValue(_('Valid run or interactive ' 'values are: %s') % bools) + # Check container quotas + self._check_container_quotas(context, container_dict) + auto_remove = container_dict.pop('auto_remove', None) if auto_remove is not None: api_utils.version_check('auto_remove', '1.3') @@ -428,6 +433,52 @@ class ContainersController(base.Controller): return view.format_container(context, pecan.request.host_url, new_container.as_dict()) + def _check_container_quotas(self, context, container_delta_dict, + update_container=False): + deltas = { + 'containers': 0 if update_container else 1, + 'cpu': container_delta_dict.get('cpu', 0), + 'memory': container_delta_dict.get('memory', 0), + 'disk': container_delta_dict.get('disk', 0) + } + + def _check_deltas(context, deltas): + """Check usage deltas against quota limits. + + This does QUOTAS.count() followed by a QUOTAS.limit_check() + using the provided deltas. + + :param context: The request context, for access check. + :param deltas: A dict of {resource_name: delta, ...} to check + against the quota limits. + """ + check_kwargs = {} + count_as_dict = {} + project_id = context.project_id + for res_name, res_delta in deltas.items(): + # TODO(kiennt): Apply count_as_dict method, query count usage + # once rather than count each resource. + count_as_dict[res_name] = QUOTAS.count(context, res_name, + project_id) + total = None + try: + if isinstance(count_as_dict[res_name], six.integer_types): + total = count_as_dict[res_name] + int(res_delta) + else: + total = float(count_as_dict[res_name]) + \ + float(res_delta) + except TypeError as e: + raise e + check_kwargs[res_name] = total + try: + QUOTAS.limit_check(context, project_id, **check_kwargs) + except exception.OverQuota as exc: + # Set HTTP response status code + pecan.response.status = 403 + raise exc + + _check_deltas(context, deltas) + def _set_default_resource_limit(self, container_dict): # NOTE(kiennt): Default disk size will be set later. container_dict['disk'] = container_dict.get('disk') @@ -630,21 +681,28 @@ class ContainersController(base.Controller): :param patch: a json PATCH document to apply to this container. """ container = utils.get_container(container_ident) + context = pecan.request.context + container_deltas = {} check_policy_on_container(container.as_dict(), "container:update") utils.validate_container_state(container, 'update') if 'memory' in patch: + container_deltas['memory'] = patch['memory'] - container.memory patch['memory'] = str(patch['memory']) if 'cpu' in patch: patch['cpu'] = float(patch['cpu']) + container_deltas['cpu'] = patch['cpu'] - container.cpu if 'name' in patch: patch['name'] = str(patch['name']) - context = pecan.request.context + if 'memory' not in patch and 'cpu' not in patch: for field, patch_val in patch.items(): if getattr(container, field) != patch_val: setattr(container, field, patch_val) container.save(context) else: + # Check container quotas + self._check_container_quotas(context, container_deltas, + update_container=True) compute_api = pecan.request.compute_api container = compute_api.container_update(context, container, patch) return view.format_container(context, pecan.request.host_url, diff --git a/zun/api/controllers/v1/quota_classes.py b/zun/api/controllers/v1/quota_classes.py new file mode 100644 index 000000000..f098ff9d6 --- /dev/null +++ b/zun/api/controllers/v1/quota_classes.py @@ -0,0 +1,57 @@ +# 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 pecan + +from zun.api.controllers import base +from zun.api.controllers.v1.schemas import quota_classes as schema +from zun.api import validation +from zun.common import exception +from zun.common import policy +from zun.common import quota +from zun import objects + +QUOTAS = quota.QUOTAS + + +class QuotaClassController(base.Controller): + """Controller for QuotaClass""" + + def _get_quotas(self, context, quota_class): + return QUOTAS.get_class_quotas(context, quota_class) + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + @validation.validate_query_param(pecan.request, schema.query_param_update) + @validation.validated(schema.query_param_update) + def put(self, quota_class_name, **quota_classes_dict): + context = pecan.request.context + policy.enforce(context, 'quota_class:update', + action='quota_class:update') + for key, value in quota_classes_dict.items(): + value = int(value) + quota_class = objects.QuotaClass( + context, class_name=quota_class_name, + resource=key, hard_limit=value) + try: + quota_class.update(context) + except exception.QuotaClassNotFound: + quota_class.create(context) + return self._get_quotas(context, quota_class_name) + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def get(self, quota_class_name): + context = pecan.request.context + policy.enforce(context, 'quota_class:get', + action='quota_class:get') + return self._get_quotas(context, quota_class_name) diff --git a/zun/api/controllers/v1/quotas.py b/zun/api/controllers/v1/quotas.py new file mode 100644 index 000000000..cc3148b89 --- /dev/null +++ b/zun/api/controllers/v1/quotas.py @@ -0,0 +1,85 @@ +# 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 pecan + +from zun.api.controllers import base +from zun.api.controllers.v1.schemas import quotas as schema +from zun.api import validation +from zun.common import exception +from zun.common import policy +from zun.common import quota +from zun import objects + +QUOTAS = quota.QUOTAS + + +class QuotaController(base.Controller): + """Controller for Quotas""" + + _custom_actions = { + 'defaults': ['GET'], + } + + def _get_quotas(self, context, usages=False): + values = QUOTAS.get_project_quotas(context, context.project_id, + usages=usages) + + if usages: + return values + else: + return {k: v['limit'] for k, v in values.items()} + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + @validation.validate_query_param(pecan.request, schema.query_param_update) + @validation.validated(schema.query_param_update) + def put(self, **quotas_dict): + context = pecan.request.context + policy.enforce(context, 'quota:update', + action='quota:update') + project_id = context.project_id + for key, value in quotas_dict.items(): + value = int(value) + quota = objects.Quota(context, project_id=project_id, resource=key, + hard_limit=value) + try: + quota.create(context) + except exception.QuotaExists: + quota.update(context) + return self._get_quotas(context) + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def get(self, **kwargs): + context = pecan.request.context + usages = kwargs.get('usages', False) + policy.enforce(context, 'quota:get', + action='quota:get') + return self._get_quotas(context, usages=usages) + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def defaults(self): + context = pecan.request.context + policy.enforce(context, 'quota:get_default', + action='quota:get_default') + values = QUOTAS.get_defaults(context) + return values + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def delete(self): + context = pecan.request.context + policy.enforce(context, 'quota:delete', + action='quota:delete') + QUOTAS.destroy_all_by_project(context, context.project_id) diff --git a/zun/api/controllers/v1/schemas/quota_classes.py b/zun/api/controllers/v1/schemas/quota_classes.py new file mode 100644 index 000000000..ca3ec8511 --- /dev/null +++ b/zun/api/controllers/v1/schemas/quota_classes.py @@ -0,0 +1,20 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from zun.api.controllers.v1.schemas import quotas + + +query_param_update = { + 'type': 'object', + 'properties': quotas.quota_resources, + 'additionalProperties': False +} diff --git a/zun/api/controllers/v1/schemas/quotas.py b/zun/api/controllers/v1/schemas/quotas.py new file mode 100644 index 000000000..a4c9c2342 --- /dev/null +++ b/zun/api/controllers/v1/schemas/quotas.py @@ -0,0 +1,31 @@ +# 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. + +common_quota = { + 'type': ['integer', 'string'], + 'pattern': '^-?[0-9]+$', + # -1 is a flag value for unlimited + 'minimum': -1 +} + +quota_resources = { + 'containers': common_quota, + 'memory': common_quota, + 'cpu': common_quota, + 'disk': common_quota +} + +query_param_update = { + 'type': 'object', + 'properties': quota_resources, + 'additionalProperties': False +} diff --git a/zun/api/controllers/versions.py b/zun/api/controllers/versions.py index 6e3b7ba67..bba8895c7 100644 --- a/zun/api/controllers/versions.py +++ b/zun/api/controllers/versions.py @@ -58,10 +58,11 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 1.23 - Add attribute 'type' to parameter 'mounts' * 1.24 - Add exposed_ports to container * 1.25 - Encode/Decode archive file + * 1.26 - Introduce Quota support """ BASE_VER = '1.1' -CURRENT_MAX_VER = '1.25' +CURRENT_MAX_VER = '1.26' class Version(object): diff --git a/zun/api/rest_api_version_history.rst b/zun/api/rest_api_version_history.rst index 0dea85a2f..84886a774 100644 --- a/zun/api/rest_api_version_history.rst +++ b/zun/api/rest_api_version_history.rst @@ -209,3 +209,8 @@ user documentation. The get_archive endpoint returns a encoded archived file data by using Base64 algorithm. The put_archive endpoint take a Base64-encoded archived file data as input. + +1.26 +---- + + Introduce Quota support API diff --git a/zun/conf/__init__.py b/zun/conf/__init__.py index 9e6172a9c..93cba62fc 100644 --- a/zun/conf/__init__.py +++ b/zun/conf/__init__.py @@ -30,6 +30,7 @@ from zun.conf import neutron_client from zun.conf import path from zun.conf import pci from zun.conf import profiler +from zun.conf import quota from zun.conf import scheduler from zun.conf import services from zun.conf import ssl @@ -58,6 +59,7 @@ neutron_client.register_opts(CONF) network.register_opts(CONF) websocket_proxy.register_opts(CONF) pci.register_opts(CONF) +quota.register_opts(CONF) volume.register_opts(CONF) cinder_client.register_opts(CONF) netconf.register_opts(CONF) diff --git a/zun/tests/unit/api/base.py b/zun/tests/unit/api/base.py index 603a3f34c..c776451b9 100644 --- a/zun/tests/unit/api/base.py +++ b/zun/tests/unit/api/base.py @@ -26,7 +26,7 @@ from zun.tests.unit.db import base PATH_PREFIX = '/v1' -CURRENT_VERSION = "container 1.25" +CURRENT_VERSION = "container 1.26" class FunctionalTest(base.DbTestCase): diff --git a/zun/tests/unit/api/controllers/test_root.py b/zun/tests/unit/api/controllers/test_root.py index 9c39fb5d1..6e134736f 100644 --- a/zun/tests/unit/api/controllers/test_root.py +++ b/zun/tests/unit/api/controllers/test_root.py @@ -28,7 +28,7 @@ class TestRootController(api_base.FunctionalTest): 'default_version': {'id': 'v1', 'links': [{'href': 'http://localhost/v1/', 'rel': 'self'}], - 'max_version': '1.25', + 'max_version': '1.26', 'min_version': '1.1', 'status': 'CURRENT'}, 'description': 'Zun is an OpenStack project which ' @@ -37,7 +37,7 @@ class TestRootController(api_base.FunctionalTest): 'versions': [{'id': 'v1', 'links': [{'href': 'http://localhost/v1/', 'rel': 'self'}], - 'max_version': '1.25', + 'max_version': '1.26', 'min_version': '1.1', 'status': 'CURRENT'}]} @@ -80,7 +80,16 @@ class TestRootController(api_base.FunctionalTest): 'capsules': [{'href': 'http://localhost/v1/capsules/', 'rel': 'self'}, {'href': 'http://localhost/capsules/', - 'rel': 'bookmark'}]} + 'rel': 'bookmark'}], + 'quotas': [{'href': 'http://localhost/v1/quotas/', + 'rel': 'self'}, + {'href': 'http://localhost/quotas/', + 'rel': 'bookmark'}], + 'quota_classes': [{'href': 'http://localhost/v1/quota_classes/', + 'rel': 'self'}, + {'href': 'http://localhost/quota_classes/', + 'rel': 'bookmark'}] + } def make_app(self, paste_file): file_name = self.get_path(paste_file) diff --git a/zun/tests/unit/api/controllers/v1/test_quota_classes.py b/zun/tests/unit/api/controllers/v1/test_quota_classes.py new file mode 100644 index 000000000..f6e8fe198 --- /dev/null +++ b/zun/tests/unit/api/controllers/v1/test_quota_classes.py @@ -0,0 +1,48 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +from zun.tests.unit.api import base as api_base + + +class TestQuotaClassController(api_base.FunctionalTest): + def setUp(self): + super(TestQuotaClassController, self).setUp() + self.default_quotas = { + 'containers': 40, + 'cpu': 20, + 'memory': 51200, + 'disk': 100 + } + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_put_quota(self, mock_policy): + update_quota_dicts = { + 'containers': '50', + 'memory': '61440' + } + + admin_project_id = 'fakeadminprojectid' + path = '/quota_classes/' + admin_project_id + response = self.put_json(path, update_quota_dicts) + self.assertEqual(200, response.status_int) + self.assertEqual(50, response.json['containers']) + self.assertEqual(61440, response.json['memory']) + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_get_quota(self, mock_policy): + admin_project_id = 'fakeadminprojectid' + path = '/quota_classes/' + admin_project_id + response = self.get(path) + self.assertEqual(200, response.status_int) + self.assertEqual(self.default_quotas, response.json) diff --git a/zun/tests/unit/api/controllers/v1/test_quotas.py b/zun/tests/unit/api/controllers/v1/test_quotas.py new file mode 100644 index 000000000..67253943c --- /dev/null +++ b/zun/tests/unit/api/controllers/v1/test_quotas.py @@ -0,0 +1,57 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +from zun.tests.unit.api import base as api_base + + +class TestQuotaController(api_base.FunctionalTest): + def setUp(self): + super(TestQuotaController, self).setUp() + self.default_quotas = { + 'containers': 40, + 'cpu': 20, + 'memory': 51200, + 'disk': 100 + } + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_get_defaults_quota(self, mock_policy): + response = self.get('/quotas/defaults') + self.assertEqual(200, response.status_int) + self.assertEqual(self.default_quotas, response.json) + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_put_quota(self, mock_policy): + update_quota_dicts = { + 'containers': '50', + 'memory': '61440' + } + + response = self.put_json('/quotas', update_quota_dicts) + self.assertEqual(200, response.status_int) + self.assertEqual(50, response.json['containers']) + self.assertEqual(61440, response.json['memory']) + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_get_quota(self, mock_policy): + response = self.get('/quotas') + self.assertEqual(200, response.status_int) + self.assertEqual(self.default_quotas, response.json) + + @mock.patch('zun.common.policy.enforce', return_value=True) + def test_delete_quota(self, mock_policy): + response = self.delete('/quotas') + self.assertEqual(200, response.status_int) + response = self.get('/quotas') + self.assertEqual(self.default_quotas, response.json)