Add API controller for quotas and quota classes

Change-Id: Ie451ec88f65819ca91d6042ea2229eae17cebf2e
Partial-Implements: blueprint quota-support
This commit is contained in:
Kien Nguyen 2018-08-11 14:58:53 +07:00
parent 0d3336661e
commit 8af77465c3
13 changed files with 401 additions and 9 deletions

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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