Implement Configuration, Controllers, and Validators for Resource Quotas

In the interest of smaller CRs, this CR partially implements the
quota support blueprint.  It includes code for configuration,
controller, and validator.  Also, the framework for unit and functional
tests.

The controllers process the URL rsources /qoutas and /project-quotas.
The configuration code reads the quota default values from the [quotas]
section of barbican.conf.  The validator code checks the validity of
the JSON sent with a POST /project-quotas/ API command.

Implements: blueprint quota-support-on-barbican-resources
Change-Id: Iad09b19cf6b9a6fa6b29d8b99e3f72172f801070
This commit is contained in:
Dave McCowan
2015-07-13 16:24:55 +00:00
parent db5c805465
commit ce5b32ac13
15 changed files with 671 additions and 10 deletions
+131
View File
@@ -0,0 +1,131 @@
# Copyright (c) 2015 Cisco Systems
#
# 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 barbican import api
from barbican.api import controllers
from barbican.common import quota
from barbican.common import utils
from barbican.common import validators
from barbican import i18n as u
LOG = utils.getLogger(__name__)
class QuotasController(controllers.ACLMixin):
"""Handles quota retrieval requests."""
def __init__(self, quota_repo=None):
LOG.debug('=== Creating QuotasController ===')
self.repo = quota_repo
self.quota_driver = quota.QuotaDriver()
@pecan.expose(generic=True)
def index(self, **kwargs):
pecan.abort(405) # HTTP 405 Method Not Allowed as default
@index.when(method='GET', template='json')
@controllers.handle_exceptions(u._('Quotas'))
@controllers.enforce_rbac('quotas:get')
def on_get(self, external_project_id, **kwargs):
# TODO(dave) implement
resp = {'quotas': self.quota_driver.get_defaults()}
return resp
class ProjectQuotasController(controllers.ACLMixin):
"""Handles project quota requests."""
def __init__(self, project_id, project_quota_repo=None):
LOG.debug('=== Creating ProjectQuotasController ===')
self.passed_project_id = project_id
self.repo = project_quota_repo
self.validator = validators.ProjectQuotaValidator()
self.quota_driver = quota.QuotaDriver()
@pecan.expose(generic=True)
def index(self, **kwargs):
pecan.abort(405) # HTTP 405 Method Not Allowed as default
@index.when(method='GET', template='json')
@controllers.handle_exceptions(u._('Project Quotas'))
@controllers.enforce_rbac('project_quotas:get')
def on_get(self, external_project_id, **kwargs):
# TODO(dave) implement
LOG.debug('=== ProjectQuotasController GET ===')
resp = {'project_quotas': self.quota_driver.get_defaults()}
return resp
@index.when(method='POST', template='json')
@controllers.handle_exceptions(u._('Project Quotas'))
@controllers.enforce_rbac('project_quotas:post')
def on_post(self, external_project_id, **kwargs):
LOG.debug('=== ProjectQuotasController POST ===')
api.load_body(pecan.request,
validator=self.validator)
# TODO(dave) implement
resp = {'project_quotas': {
'secrets': 10,
'orders': 20,
'containers': 10,
'transport_keys': 10,
'consumers': -1}
}
LOG.info(u._LI('Post Project Quotas'))
return resp
@index.when(method='DELETE', template='json')
@utils.allow_all_content_types
@controllers.handle_exceptions(u._('Project Quotas'))
@controllers.enforce_rbac('project_quotas:delete')
def on_delete(self, external_project_id, **kwargs):
LOG.debug('=== ProjectQuotasController DELETE ===')
# TODO(dave) implement
LOG.info(u._LI('Delete Project Quotas'))
pecan.response.status = 204
class ProjectsQuotasController(controllers.ACLMixin):
"""Handles projects quota retrieval requests."""
def __init__(self, project_quota_repo=None):
LOG.debug('=== Creating ProjectsQuotaController ===')
self.repo = project_quota_repo
self.quota_driver = quota.QuotaDriver()
@pecan.expose()
def _lookup(self, project_id, *remainder):
return ProjectQuotasController(project_id,
project_quota_repo=self.repo), remainder
@pecan.expose(generic=True)
def index(self, **kwargs):
pecan.abort(405) # HTTP 405 Method Not Allowed as default
@index.when(method='GET', template='json')
@controllers.handle_exceptions(u._('Project Quotas'))
@controllers.enforce_rbac('project_quotas:get')
def on_get(self, external_project_id, **kwargs):
# TODO(dave) implement
project1 = {'project_id': "1234",
'project_quotas': self.quota_driver.get_defaults()}
project2 = {'project_id': "5678",
'project_quotas': self.quota_driver.get_defaults()}
project_quotas = {"project_quotas": [project1, project2]}
resp = project_quotas
return resp
+3 -8
View File
@@ -17,6 +17,7 @@ from barbican.api import controllers
from barbican.api.controllers import cas
from barbican.api.controllers import containers
from barbican.api.controllers import orders
from barbican.api.controllers import quotas
from barbican.api.controllers import secrets
from barbican.api.controllers import transportkeys
from barbican.common import utils
@@ -91,14 +92,8 @@ class V1Controller(BaseVersionController):
self.containers = containers.ContainersController()
self.transport_keys = transportkeys.TransportKeysController()
self.cas = cas.CertificateAuthoritiesController()
self.__controllers = [
self.secrets,
self.orders,
self.containers,
self.transport_keys,
self.cas,
]
self.quotas = quotas.QuotasController()
setattr(self, 'project-quotas', quotas.ProjectsQuotasController())
@pecan.expose(generic=True)
def index(self):
+71
View File
@@ -0,0 +1,71 @@
# Copyright (c) 2015 Cisco Systems
#
# 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 oslo_config import cfg
from oslo_log import log as logging
LOG = logging.getLogger(__name__)
UNLIMITED_VALUE = -1
quota_opt_group = cfg.OptGroup(name='quotas',
title='Quota Options')
quota_opts = [
cfg.BoolOpt('enabled',
default=False,
help='When True, quotas are enforced.'),
cfg.IntOpt('quota_secrets',
default=500,
help='Number of secrets allowed per project'),
cfg.IntOpt('quota_orders',
default=100,
help='Number of orders allowed per project'),
cfg.IntOpt('quota_containers',
default=-1,
help='Number of containers allowed per project'),
cfg.IntOpt('quota_transport_keys',
default=100,
help='Number of transport keys allowed per project'),
cfg.IntOpt('quota_consumers',
default=100,
help='Number of consumers allowed per project'),
]
CONF = cfg.CONF
CONF.register_group(quota_opt_group)
CONF.register_opts(quota_opts, group=quota_opt_group)
class QuotaDriver(object):
"""Driver to enforce quotas and obtain quota information."""
def get_defaults(self):
"""Return list of default quotas"""
quotas = {
'secrets': CONF.quotas.quota_secrets,
'orders': CONF.quotas.quota_orders,
'containers': CONF.quotas.quota_containers,
'transport_keys': CONF.quotas.quota_transport_keys,
'consumers': CONF.quotas.quota_consumers
}
return quotas
def _is_unlimited_value(self, v):
"""A helper method to check for unlimited value."""
return v <= UNLIMITED_VALUE
+33
View File
@@ -846,3 +846,36 @@ class NewTransportKeyValidator(ValidatorBase):
json_data['transport_key'] = transport_key
return json_data
class ProjectQuotaValidator(ValidatorBase):
"""Validate a new project quota."""
def __init__(self):
self.name = 'Project Quota'
self.schema = {
'type': 'object',
'properties': {
'project_quotas': {
'type': 'object',
'properties': {
'secrets': {'type': 'integer'},
'orders': {'type': 'integer'},
'containers': {'type': 'integer'},
'transport_keys': {'type': 'integer'},
'consumers': {'type': 'integer'}
},
'additionalProperties': False,
}
},
'required': ['project_quotas'],
'additionalProperties': False
}
def validate(self, json_data, parent_schema=None):
schema_name = self._full_name(parent_schema)
self._assert_schema_is_valid(json_data, schema_name)
return json_data
@@ -0,0 +1,76 @@
# Copyright (c) 2015 Cisco Systems
#
# 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 unittest
from barbican.tests import utils
class WhenTestingQuotas(utils.BarbicanAPIBaseTestCase):
def test_should_get_quotas(self):
params = {}
resp = self.app.get('/quotas', params)
self.assertIn('quotas', resp.namespace)
def test_should_get_specific_project_quotas(self):
params = {}
resp = self.app.get(
'/project-quotas/{0}'.format(self.project_id),
params)
self.assertEqual(200, resp.status_int)
self.assertIn('project_quotas', resp.namespace)
def test_should_get_project_quotas_list(self):
params = {}
resp = self.app.get('/project-quotas', params)
self.assertEqual(200, resp.status_int)
self.assertIn('project_quotas', resp.namespace)
def test_should_post_project_quotas(self):
request = {'project_quotas': {}}
resp = self.app.post_json(
'/project-quotas/{0}'.format(self.project_id), request)
self.assertEqual(200, resp.status_int)
def test_should_delete_specific_project_quotas(self):
params = {}
resp = self.app.delete(
'/project-quotas/{0}'.format(self.project_id), params)
self.assertEqual(204, resp.status_int)
def test_check_post_quotas_not_allowed(self):
"""POST not allowed operation for /quotas"""
params = {}
resp = self.app.post('/quotas/', params, expect_errors=True)
self.assertEqual(405, resp.status_int)
def test_check_put_project_quotas_not_allowed(self):
"""PUT not allowed operation for /project-quotas/{project-id}"""
params = {}
resp = self.app.put(
'/project-quotas/{0}'.format(self.project_id),
params, expect_errors=True)
self.assertEqual(405, resp.status_int)
def test_check_post_project_quotas_list_not_allowed(self):
"""POST not allowed operation for /project-quotas"""
params = {}
resp = self.app.post('/project-quotas', params, expect_errors=True)
self.assertEqual(405, resp.status_int)
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
# Copyright (c) 2015 Cisco Systems
#
# 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 unittest
from barbican.common import quota
from barbican.tests import utils
class WhenTestingQuotaFunctions(utils.BaseTestCase):
def setUp(self):
super(WhenTestingQuotaFunctions, self).setUp()
self.quota_driver = quota.QuotaDriver()
def test_get_defaults(self):
quotas = self.quota_driver.get_defaults()
self.assertEqual(500, quotas['secrets'])
self.assertEqual(100, quotas['orders'])
self.assertEqual(-1, quotas['containers'])
self.assertEqual(100, quotas['transport_keys'])
self.assertEqual(100, quotas['consumers'])
def test_is_unlimited_true(self):
self.assertTrue(self.quota_driver._is_unlimited_value(-1))
def test_is_unlimited_false(self):
self.assertFalse(self.quota_driver._is_unlimited_value(1))
if __name__ == '__main__':
unittest.main()
+27
View File
@@ -1343,5 +1343,32 @@ class WhenTestingAclValidator(utils.BaseTestCase):
acl_req)
class WhenTestingProjectQuotasValidator(utils.BaseTestCase):
def setUp(self):
super(WhenTestingProjectQuotasValidator, self).setUp()
self.good_project_quotas = {"project_quotas":
{"secrets": 50,
"orders": 10,
"containers": 20}}
self.bad_project_quotas = {"bad key": "bad value"}
self.validator = validators.ProjectQuotaValidator()
def test_should_pass_good_data(self):
self.validator.validate(self.good_project_quotas)
def test_should_pass_empty_properties(self):
self.validator.validate({"project_quotas": {}})
def test_should_raise_bad_data(self):
self.assertRaises(excep.InvalidObject,
self.validator.validate,
self.bad_project_quotas)
def test_should_raise_empty_dict(self):
self.assertRaises(excep.InvalidObject,
self.validator.validate,
{})
if __name__ == '__main__':
unittest.main()
+27
View File
@@ -192,6 +192,33 @@ initial_delay_seconds = 10.0
periodic_interval_max_seconds = 10.0
# ====================== Quota Options ===============================
[quotas]
enabled = true
# True enforces quotas for the number of resources used by each project.
# For each resource, the default maximum number that can be used for
# a project is set below. This value can be overridden for each
# project through the API. A negative value means no limit. A zero
# value effectively disables the resource.
# default number of secrets allowed per project
quota_secrets = 500
# default number of orders allowed per project
quota_orders = 100
# default number of containers allowed per project
quota_containers = -1
# Note, a negative value signifies unlimited
# default number of transport_keys allowed per project
quota_transport_keys = 100
# default number of consumers allowed per project
quota_consumers = 100
# ================= Keystone Notification Options - Application ===============
[keystone_notifications]
+5 -1
View File
@@ -66,5 +66,9 @@
"secret_acls:get": "rule:all_but_audit and rule:secret_project_match",
"container_acls:put_patch": "rule:container_project_admin or rule:container_project_creator",
"container_acls:delete": "rule:container_project_admin or rule:container_project_creator",
"container_acls:get": "rule:all_but_audit and rule:container_project_match"
"container_acls:get": "rule:all_but_audit and rule:container_project_match",
"quotas:get": "rule:all_users",
"project_quotas:get": "rule:admin",
"project_quotas:post": "rule:admin",
"project_quotas:delete": "rule:admin"
}
@@ -53,3 +53,7 @@ class BaseBehaviors(object):
def get_user_id_from_name(self, user_name):
"""From a configured user name, get the unique user id from keystone"""
return self.client.get_user_id_from_name(user_name)
def get_project_id_from_name(self, user_name):
"""From a configured user name, get the project id from keystone"""
return self.client.get_project_id_from_name(user_name)
@@ -0,0 +1,110 @@
"""
Copyright 2015 Cisco Systems
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 functionaltests.api.v1.behaviors import base_behaviors
from functionaltests.api.v1.models import quota_models
class QuotaBehaviors(base_behaviors.BaseBehaviors):
def get_quotas(self, extra_headers=None,
use_auth=True, user_name=None):
"""Handles getting quotas
:param extra_headers: extra HTTP headers for the REST request
:param use_auth: Boolean for whether to send authentication headers
:param user_name: The user name used for REST command
:return: a request Response object
"""
resp = self.client.get('quotas',
response_model_type=quota_models.QuotaModel,
extra_headers=extra_headers,
use_auth=use_auth, user_name=user_name)
return resp
def get_project_quotas_list(self, limit=10, offset=0, extra_headers=None,
use_auth=True, user_name=None):
"""Handles getting project quotas
:param limit: limits number of returned orders (default 10)
:param offset: represents how many records to skip before retrieving
the list (default 0)
:param extra_headers: extra HTTP headers for the REST request
:param use_auth: Boolean for whether to send authentication headers
:param user_name: The user name used for REST command
:return: a request Response object
"""
params = {'limit': limit, 'offset': offset}
resp = self.client.get(
'project-quotas',
response_model_type=quota_models.ProjectQuotaModel,
params=params,
extra_headers=extra_headers,
use_auth=use_auth, user_name=user_name)
response = self.get_json(resp)
project_quotas, next_ref, prev_ref = self.client.get_list_of_models(
response, quota_models.ProjectQuotaModel)
return resp, project_quotas
def get_project_quotas(self, project_id, extra_headers=None,
use_auth=True, user_name=None):
"""Handles getting project quotas
:param extra_headers: extra HTTP headers for the REST request
:param use_auth: Boolean for whether to send authentication headers
:param user_name: The user name used for REST command
:return: a request Response object
"""
resp = self.client.get(
'project-quotas/' + project_id,
response_model_type=quota_models.ProjectQuotaModel,
extra_headers=extra_headers,
use_auth=use_auth, user_name=user_name)
return resp
def set_project_quotas(self, project_id, request_model, extra_headers=None,
use_auth=True, user_name=None):
"""Handles setting project quotas
:param project_id: id of project whose quotas are to be set
:param extra_headers: extra HTTP headers for the REST request
:param use_auth: Boolean for whether to send authentication headers
:param user_name: The user name used for REST command
:return: a request Response object
"""
resp = self.client.post(
'project-quotas/' + project_id,
request_model=request_model,
response_model_type=quota_models.ProjectQuotaModel,
extra_headers=extra_headers,
use_auth=use_auth, user_name=user_name)
return resp
def delete_project_quotas(self, project_id, extra_headers=None,
use_auth=True, user_name=None):
"""Handles deleting project quotas
:param project_id: id of project whose quotas are to be deleted
:param extra_headers: extra HTTP headers for the REST request
:param use_auth: Boolean for whether to send authentication headers
:param user_name: The user name used for REST command
:return: a request Response object
"""
resp = self.client.delete('project-quotas/' + project_id,
extra_headers=extra_headers,
use_auth=use_auth, user_name=user_name)
return resp
@@ -0,0 +1,89 @@
# Copyright (c) 2015 Cisco Systems
#
# 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 functionaltests.api import base
from functionaltests.api.v1.behaviors import quota_behaviors
from functionaltests.api.v1.models import quota_models
def get_set_project_quotas_request():
return {"project_quotas":
{"secrets": 50,
"orders": 10,
"containers": 20}}
class QuotasTestCase(base.TestCase):
def setUp(self):
super(QuotasTestCase, self).setUp()
self.behaviors = quota_behaviors.QuotaBehaviors(self.client)
self.project_id = self.behaviors.get_project_id_from_name('admin')
def tearDown(self):
super(QuotasTestCase, self).tearDown()
def test_get_quotas(self):
"""Get quota information"""
resp = self.behaviors.get_quotas()
self.assertEqual(200, resp.status_code)
self.assertEqual(500, resp.model.quotas['secrets'])
self.assertEqual(100, resp.model.quotas['transport_keys'])
self.assertEqual(100, resp.model.quotas['orders'])
self.assertEqual(-1, resp.model.quotas['containers'])
self.assertEqual(100, resp.model.quotas['consumers'])
def test_get_project_quota_list(self):
"""Get list of all project quotas"""
resp, project_quotas_list = self.behaviors.get_project_quotas_list()
self.assertEqual(200, resp.status_code)
for project_quotas in project_quotas_list:
self.assertEqual(500, project_quotas.project_quotas['secrets'])
self.assertEqual(100,
project_quotas.project_quotas['transport_keys'])
self.assertEqual(100, project_quotas.project_quotas['orders'])
self.assertEqual(-1, project_quotas.project_quotas['containers'])
self.assertEqual(100, project_quotas.project_quotas['consumers'])
def test_get_one_project_quotas(self):
"""Get project quota information for specific project"""
resp = self.behaviors.get_project_quotas(self.project_id)
self.assertEqual(200, resp.status_code)
self.assertEqual(500, resp.model.project_quotas['secrets'])
self.assertEqual(100, resp.model.project_quotas['transport_keys'])
self.assertEqual(100, resp.model.project_quotas['orders'])
self.assertEqual(-1, resp.model.project_quotas['containers'])
self.assertEqual(100, resp.model.project_quotas['consumers'])
def test_set_project_quotas(self):
"""Get project quota information"""
request_model = quota_models.ProjectQuotaRequestModel(
**get_set_project_quotas_request())
resp = self.behaviors.set_project_quotas(self.project_id,
request_model)
self.assertEqual(200, resp.status_code)
def test_delete_project_quotas(self):
"""Get project quota information"""
resp = self.behaviors.delete_project_quotas(self.project_id)
self.assertEqual(204, resp.status_code)
@@ -0,0 +1,39 @@
"""
Copyright 2015 Cisco Systems
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 functionaltests.api.v1.models.base_models import BaseModel
class QuotaModel(BaseModel):
def __init__(self, quotas=None):
super(QuotaModel, self).__init__()
self.quotas = quotas
class ProjectQuotaModel(BaseModel):
def __init__(self, project_quotas=None, project_id=None):
super(ProjectQuotaModel, self).__init__()
self.project_quotas = project_quotas
self.project_id = project_id
class ProjectQuotaRequestModel(BaseModel):
def __init__(self, project_quotas=None):
super(ProjectQuotaRequestModel, self).__init__()
self.project_quotas = project_quotas
+4
View File
@@ -89,6 +89,10 @@ class FunctionalTestAuth(auth.AuthBase):
"""Return the UID used by keystone to uniquely identify the user"""
return self.authenticate()['user_id']
def get_project_id(self):
"""Return the UID used by keystone to identify the user's project"""
return self.authenticate()['project_id']
def __call__(self, r):
creds = self.authenticate()
+8 -1
View File
@@ -221,7 +221,8 @@ class BarbicanClient(object):
next_ref = item_list.get('next')
elif 'previous' == item:
prev_ref = item_list.get('previous')
elif item in ('secrets', 'orders', 'containers', 'consumers'):
elif item in ('secrets', 'orders', 'containers',
'consumers', 'project_quotas'):
for entity in item_list.get(item):
models.append(model_type(**entity))
@@ -287,3 +288,9 @@ class BarbicanClient(object):
return self._auth[user_name].get_user_id()
else:
return None
def get_project_id_from_name(self, user_name):
if user_name and self._auth[user_name]:
return self._auth[user_name].get_project_id()
else:
return None