Merge "Resource Quota - Adding quota API"

This commit is contained in:
Jenkins 2017-01-25 21:22:24 +00:00 committed by Gerrit Code Review
commit 1a2a72f787
12 changed files with 637 additions and 1 deletions

View File

@ -35,6 +35,12 @@
"clustertemplate:update": "rule:default",
"clustertemplate:publish": "rule:admin_or_owner",
"quotas:get": "rule:default",
"quotas:get_all": "rule:admin_api",
"quotas:create": "rule:admin_api",
"quotas:update": "rule:admin_api",
"quotas:delete": "rule:admin_api",
"certificate:create": "rule:admin_or_user",
"certificate:get": "rule:admin_or_user",
"certificate:rotate_ca": "rule:admin_or_owner",

View File

@ -30,6 +30,7 @@ from magnum.api.controllers.v1 import certificate
from magnum.api.controllers.v1 import cluster
from magnum.api.controllers.v1 import cluster_template
from magnum.api.controllers.v1 import magnum_services
from magnum.api.controllers.v1 import quota
from magnum.api.controllers.v1 import stats
from magnum.api.controllers import versions as ver
from magnum.api import expose
@ -86,6 +87,9 @@ class V1(controllers_base.APIBase):
clusters = [link.Link]
"""Links to the clusters resource"""
quotas = [link.Link]
"""Links to the quotas resource"""
certificates = [link.Link]
"""Links to the certificates resource"""
@ -133,6 +137,12 @@ class V1(controllers_base.APIBase):
pecan.request.host_url,
'clusters', '',
bookmark=True)]
v1.quotas = [link.Link.make_link('self', pecan.request.host_url,
'quotas', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'quotas', '',
bookmark=True)]
v1.certificates = [link.Link.make_link('self', pecan.request.host_url,
'certificates', ''),
link.Link.make_link('bookmark',
@ -161,6 +171,7 @@ class Controller(controllers_base.Controller):
baymodels = baymodel.BayModelsController()
clusters = cluster.ClustersController()
clustertemplates = cluster_template.ClusterTemplatesController()
quotas = quota.QuotaController()
certificates = certificate.CertificateController()
mservices = magnum_services.MagnumServiceController()
stats = stats.StatsController()

View File

@ -0,0 +1,211 @@
# Copyright 2013 UnitedStack Inc.
# 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.
from oslo_log import log as logging
import pecan
import wsme
from wsme import types as wtypes
from magnum.api.controllers import base
from magnum.api.controllers.v1 import collection
from magnum.api import expose
from magnum.api import utils as api_utils
from magnum.common import exception
from magnum.common import policy
from magnum.i18n import _
from magnum import objects
from magnum.objects import fields
LOG = logging.getLogger(__name__)
class Quota(base.APIBase):
"""API representation of a project Quota.
This class enforces type checking and value constraints, and converts
between the internal object model and the API representation of Quota.
"""
id = wsme.wsattr(wtypes.IntegerType(minimum=1))
"""unique id"""
hard_limit = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1)
"""The hard limit for total number of clusters. Default to 1 if not set"""
project_id = wsme.wsattr(wtypes.StringType(min_length=1, max_length=255),
default=None)
"""The project id"""
resource = wsme.wsattr(wtypes.Enum(str, *fields.QuotaResourceName.ALL),
default='Cluster')
"""The resource name"""
def __init__(self, **kwargs):
super(Quota, self).__init__()
self.fields = []
for field in objects.Quota.fields:
# Skip fields we do not expose.
if not hasattr(self, field):
continue
self.fields.append(field)
setattr(self, field, kwargs.get(field, wtypes.Unset))
@classmethod
def convert(cls, quota):
return Quota(**quota.as_dict())
class QuotaCollection(collection.Collection):
"""API representation of a collection of quotas."""
quotas = [Quota]
"""A list containing quota objects"""
def __init__(self, **kwargs):
self._type = 'quotas'
@staticmethod
def convert(quotas, limit, **kwargs):
collection = QuotaCollection()
collection.quotas = [Quota.convert(p) for p in quotas]
collection.next = collection.get_next(limit, **kwargs)
return collection
class QuotaController(base.Controller):
"""REST controller for Quotas."""
def __init__(self):
super(QuotaController, self).__init__()
_custom_actions = {
'detail': ['GET'],
}
def _get_quota_collection(self, marker, limit, sort_key, sort_dir,
filters):
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
marker_obj = None
if marker:
marker_obj = objects.Quota.get_by_id(pecan.request.context,
marker)
quotas = objects.Quota.list(pecan.request.context,
limit,
marker_obj,
sort_key=sort_key,
sort_dir=sort_dir,
filters=filters)
return QuotaCollection.convert(quotas,
limit,
sort_key=sort_key,
sort_dir=sort_dir)
@expose.expose(QuotaCollection, int, int, wtypes.text, wtypes.text, bool)
def get_all(self, marker=None, limit=None, sort_key='id',
sort_dir='asc', all_tenants=False):
"""Retrieve a list of quotas.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param all_tenants: a flag to indicate all or current tenant.
"""
context = pecan.request.context
policy.enforce(context, 'quota:get_all',
action='quota:get_all')
filters = {}
if not context.is_admin or not all_tenants:
filters = {"project_id": context.project_id}
return self._get_quota_collection(marker,
limit,
sort_key,
sort_dir,
filters)
@expose.expose(Quota, wtypes.text, wtypes.text)
def get_one(self, project_id, resource):
"""Retrieve Quota information for the given project_id.
:param id: project id.
:param resource: resource name.
"""
context = pecan.request.context
policy.enforce(context, 'quota:get', action='quota:get')
if not context.is_admin and project_id != context.project_id:
raise exception.NotAuthorized()
quota = objects.Quota.get_quota_by_project_id_resource(context,
project_id,
resource)
return Quota.convert(quota)
@expose.expose(Quota, body=Quota, status_code=201)
def post(self, quota):
"""Create Quota.
:param quota: a json document to create this Quota.
"""
context = pecan.request.context
policy.enforce(context, 'quota:create', action='quota:create')
quota_dict = quota.as_dict()
if 'project_id'not in quota_dict or not quota_dict['project_id']:
msg = _('Must provide a valid project ID.')
raise exception.InvalidParameterValue(message=msg)
new_quota = objects.Quota(context, **quota_dict)
new_quota.create()
return Quota.convert(new_quota)
@expose.expose(Quota, wtypes.text, wtypes.text, body=Quota,
status_code=202)
def patch(self, project_id, resource, quotapatch):
"""Update Quota for a given project_id.
:param project_id: project id.
:param resource: resource name.
:param quotapatch: a json document to update Quota.
"""
context = pecan.request.context
policy.enforce(context, 'quota:update', action='quota:update')
quota_dict = quotapatch.as_dict()
quota_dict['project_id'] = project_id
quota_dict['resource'] = resource
db_quota = objects.Quota.update_quota(context, project_id, quota_dict)
return Quota.convert(db_quota)
@expose.expose(None, wtypes.text, wtypes.text, status_code=204)
def delete(self, project_id, resource):
"""Delete Quota for a given project_id and resource.
:param project_id: project id.
:param resource: resource name.
"""
context = pecan.request.context
policy.enforce(context, 'quota:delete', action='quota:delete')
quota_dict = {"project_id": project_id, "resource": resource}
quota = objects.Quota(context, **quota_dict)
quota.delete()

View File

@ -16,6 +16,7 @@ from magnum.objects import certificate
from magnum.objects import cluster
from magnum.objects import cluster_template
from magnum.objects import magnum_service
from magnum.objects import quota
from magnum.objects import stats
from magnum.objects import x509keypair
@ -23,6 +24,7 @@ from magnum.objects import x509keypair
Cluster = cluster.Cluster
ClusterTemplate = cluster_template.ClusterTemplate
MagnumService = magnum_service.MagnumService
Quota = quota.Quota
X509KeyPair = x509keypair.X509KeyPair
Certificate = certificate.Certificate
Stats = stats.Stats
@ -31,4 +33,5 @@ __all__ = (Cluster,
MagnumService,
X509KeyPair,
Certificate,
Stats)
Stats,
Quota)

View File

@ -84,6 +84,18 @@ class DockerStorageDriver(fields.Enum):
valid_values=DockerStorageDriver.ALL)
class QuotaResourceName(fields.Enum):
ALL = (
CLUSTER,
) = (
'Cluster',
)
def __init__(self):
super(QuotaResourceName, self).__init__(
valid_values=QuotaResourceName.ALL)
class ServerType(fields.Enum):
ALL = (
VM, BM,

142
magnum/objects/quota.py Normal file
View File

@ -0,0 +1,142 @@
# 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_versionedobjects import fields
from magnum.db import api as dbapi
from magnum.objects import base
@base.MagnumObjectRegistry.register
class Quota(base.MagnumPersistentObject, base.MagnumObject,
base.MagnumObjectDictCompat):
# Version 1.0: Initial version
VERSION = '1.0'
dbapi = dbapi.get_instance()
fields = {
'id': fields.IntegerField(),
'project_id': fields.StringField(nullable=False),
'resource': fields.StringField(nullable=False),
'hard_limit': fields.IntegerField(nullable=False),
}
@base.remotable_classmethod
def get_quota_by_project_id_resource(cls, context, project_id, resource):
"""Find a quota based on its integer id and return a Quota object.
:param project_id: the id of a project.
:param resource: resource name.
:param context: Security context
:returns: a :class:`Quota` object.
"""
db_quota = cls.dbapi.get_quota_by_project_id_resource(project_id,
resource)
quota = Quota._from_db_object(cls(context), db_quota)
return quota
@staticmethod
def _from_db_object(quota, db_quota):
"""Converts a database entity to a formal object."""
for field in quota.fields:
setattr(quota, field, db_quota[field])
quota.obj_reset_changes()
return quota
@staticmethod
def _from_db_object_list(db_objects, cls, context):
"""Converts a list of database entities to a list of formal objects."""
return [Quota._from_db_object(cls(context), obj)
for obj in db_objects]
@base.remotable_classmethod
def get_by_id(cls, context, quota_id):
"""Find a quota based on its integer id and return a Quota object.
:param quota_id: the id of a quota.
:param context: Security context
:returns: a :class:`Quota` object.
"""
db_quota = cls.dbapi.get_quota_by_id(context, quota_id)
quota = Quota._from_db_object(cls(context), db_quota)
return quota
@base.remotable_classmethod
def list(cls, context, limit=None, marker=None,
sort_key=None, sort_dir=None, filters=None):
"""Return a list of Quota objects.
:param context: Security context.
:param limit: maximum number of resources to return in a single result.
:param marker: pagination marker for large data sets.
:param sort_key: column to sort results by.
:param sort_dir: direction to sort. "asc" or "desc".
:param filters: filter dict, can includes 'project_id',
'resource'.
:returns: a list of :class:`Quota` object.
"""
db_quotas = cls.dbapi.get_quota_list(context,
limit=limit,
marker=marker,
sort_key=sort_key,
sort_dir=sort_dir,
filters=filters)
return Quota._from_db_object_list(db_quotas, cls, context)
@base.remotable_classmethod
def quota_get_all_by_project_id(cls, context, project_id):
"""Find a quota based on project id.
:param project_id: the project id.
:param context: Security context
:returns: a :class:`Quota` object.
"""
quotas = cls.dbapi.get_quota_by_project_id(context, project_id)
return Quota._from_db_object_list(quotas, cls, context)
@base.remotable
def create(self, context=None):
"""Save a quota based on project id.
:param context: security context.
:returns: a :class:`Quota` object.
"""
values = self.obj_get_changes()
db_quota = self.dbapi.create_quota(values)
self._from_db_object(self, db_quota)
@base.remotable
def delete(self, context=None):
"""Delete the quota from the DB.
:param context: Security context. NOTE: This should only
be used internally by the indirection_api.
Unfortunately, RPC requires context as the first
argument, even though we don't use it.
A context should be set when instantiating the
object, e.g.: Quota(context)
"""
self.dbapi.delete_quota(self.project_id, self.resource)
self.obj_reset_changes()
@base.remotable_classmethod
def update_quota(cls, context, project_id, quota):
"""Save a quota based on project id.
:param quota: quota.
:returns: a :class:`Quota` object.
"""
db_quota = cls.dbapi.update_quota(project_id, quota)
return Quota._from_db_object(cls(context), db_quota)

View File

@ -69,6 +69,10 @@ class TestRootController(api_base.FunctionalTest):
u'rel': u'self'},
{u'href': u'http://localhost/clusters/',
u'rel': u'bookmark'}],
u'quotas': [{u'href': u'http://localhost/v1/quotas/',
u'rel': u'self'},
{u'href': u'http://localhost/quotas/',
u'rel': u'bookmark'}],
u'clustertemplates':
[{u'href': u'http://localhost/v1/clustertemplates/',
u'rel': u'self'},

View File

@ -0,0 +1,210 @@
# 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 magnum.api.controllers.v1 import quota as api_quota
from magnum.tests import base
from magnum.tests.unit.api import base as api_base
from magnum.tests.unit.api import utils as apiutils
from magnum.tests.unit.objects import utils as obj_utils
class TestQuotaObject(base.TestCase):
def test_quota_init(self):
quota_dict = apiutils.quota_post_data()
del quota_dict['hard_limit']
quota = api_quota.Quota(**quota_dict)
self.assertEqual(1, quota.hard_limit)
class TestQuota(api_base.FunctionalTest):
_quota_attrs = ("project_id", "resource", "hard_limit")
def setUp(self):
super(TestQuota, self).setUp()
def test_empty(self):
response = self.get_json('/quotas')
self.assertEqual([], response['quotas'])
def test_one(self):
quota = obj_utils.create_test_quota(self.context)
response = self.get_json('/quotas')
self.assertEqual(quota.project_id, response['quotas'][0]["project_id"])
self._verify_attrs(self._quota_attrs, response['quotas'][0])
def test_get_one(self):
quota = obj_utils.create_test_quota(self.context)
response = self.get_json('/quotas/%s/%s' % (quota['project_id'],
quota['resource']))
self.assertEqual(quota.project_id, response['project_id'])
self.assertEqual(quota.resource, response['resource'])
def test_get_one_not_found(self):
response = self.get_json(
'/quotas/fake_project/invalid_res',
expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['errors'])
def test_get_one_not_authorized(self):
obj_utils.create_test_quota(self.context)
response = self.get_json(
'/quotas/invalid_proj/invalid_res',
expect_errors=True)
self.assertEqual(403, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['errors'])
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_get_all_admin_all_tenants(self, mock_context, mock_policy):
mock_context.return_value = self.context
quota_list = []
for i in range(4):
quota = obj_utils.create_test_quota(self.context,
project_id="proj-id-"+str(i))
quota_list.append(quota)
self.context.is_admin = True
response = self.get_json('/quotas?all_tenants=True')
self.assertEqual(4, len(response['quotas']))
expected = [r.project_id for r in quota_list]
res_proj_ids = [r['project_id'] for r in response['quotas']]
self.assertEqual(sorted(expected), sorted(res_proj_ids))
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_get_all_admin_not_all_tenants(self, mock_context, mock_policy):
mock_context.return_value = self.context
quota_list = []
for i in range(4):
quota = obj_utils.create_test_quota(self.context,
project_id="proj-id-"+str(i))
quota_list.append(quota)
self.context.is_admin = True
self.context.project_id = 'proj-id-1'
response = self.get_json('/quotas')
self.assertEqual(1, len(response['quotas']))
self.assertEqual('proj-id-1', response['quotas'][0]['project_id'])
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_get_all_admin_all_with_pagination_marker(self, mock_context,
mock_policy):
mock_context.return_value = self.context
quota_list = []
for i in range(4):
quota = obj_utils.create_test_quota(self.context,
project_id="proj-id-"+str(i))
quota_list.append(quota)
self.context.is_admin = True
response = self.get_json('/quotas?limit=3&marker=%s&all_tenants=True'
% quota_list[2].id)
self.assertEqual(1, len(response['quotas']))
self.assertEqual(quota_list[-1].project_id,
response['quotas'][0]['project_id'])
def test_get_all_non_admin(self):
quota_list = []
for i in range(4):
quota = obj_utils.create_test_quota(self.context,
project_id="proj-id-"+str(i))
quota_list.append(quota)
headers = {'X-Project-Id': 'proj-id-2'}
response = self.get_json('/quotas', headers=headers)
self.assertEqual(1, len(response['quotas']))
self.assertEqual('proj-id-2', response['quotas'][0]['project_id'])
def test_create_quota(self):
quota_dict = apiutils.quota_post_data()
response = self.post_json('/quotas', quota_dict)
self.assertEqual('application/json', response.content_type)
self.assertEqual(201, response.status_int)
self.assertEqual(quota_dict['project_id'], response.json['project_id'])
def test_create_quota_invalid_resource(self):
quota_dict = apiutils.quota_post_data()
quota_dict['resource'] = 'invalid-res'
response = self.post_json('/quotas', quota_dict, expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(400, response.status_int)
self.assertTrue(response.json['errors'])
def test_create_quota_invalid_hard_limit(self):
quota_dict = apiutils.quota_post_data()
quota_dict['hard_limit'] = -10
response = self.post_json('/quotas', quota_dict, expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(400, response.status_int)
self.assertTrue(response.json['errors'])
def test_create_quota_no_project_id(self):
quota_dict = apiutils.quota_post_data()
del quota_dict['project_id']
response = self.post_json('/quotas', quota_dict, expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(400, response.status_int)
self.assertTrue(response.json['errors'])
def test_patch_quota(self):
quota_dict = apiutils.quota_post_data(hard_limit=5)
response = self.post_json('/quotas', quota_dict)
self.assertEqual('application/json', response.content_type)
self.assertEqual(201, response.status_int)
self.assertEqual(quota_dict['project_id'], response.json['project_id'])
self.assertEqual(5, response.json['hard_limit'])
quota_dict['hard_limit'] = 20
response = self.patch_json('/quotas', quota_dict)
self.assertEqual('application/json', response.content_type)
self.assertEqual(202, response.status_int)
self.assertEqual(20, response.json['hard_limit'])
def test_patch_quota_not_found(self):
quota_dict = apiutils.quota_post_data()
response = self.post_json('/quotas', quota_dict)
self.assertEqual('application/json', response.content_type)
self.assertEqual(201, response.status_int)
# update quota with non-existing project id
update_dict = {'project_id': 'not-found',
'hard_limit': 20,
'resource': 'Cluster'}
response = self.patch_json('/quotas', update_dict, expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(404, response.status_int)
self.assertTrue(response.json['errors'])
def test_delete_quota(self):
quota_dict = apiutils.quota_post_data()
response = self.post_json('/quotas', quota_dict)
self.assertEqual('application/json', response.content_type)
self.assertEqual(201, response.status_int)
project_id = quota_dict['project_id']
resource = quota_dict['resource']
# delete quota
self.delete('/quotas/%s/%s' % (project_id, resource))
# now check that quota does not exist
response = self.get_json(
'/quotas/%s/%s' % (project_id, resource),
expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['errors'])

View File

@ -67,6 +67,10 @@ def cert_post_data(**kw):
}
def quota_post_data(**kw):
return utils.get_test_quota(**kw)
def mservice_get_data(**kw):
"""Simulate what the RPC layer will get from DB """
faketime = datetime.datetime(2001, 1, 1, tzinfo=pytz.UTC)

View File

@ -362,6 +362,7 @@ object_data = {
'X509KeyPair': '1.2-d81950af36c59a71365e33ce539d24f9',
'MagnumService': '1.0-2d397ec59b0046bd5ec35cd3e06efeca',
'Stats': '1.0-73a1cd6e3c0294c932a66547faba216c',
'Quota': '1.0-94e100aebfa88f7d8428e007f2049c18',
}

View File

@ -90,6 +90,33 @@ def create_test_cluster(context, **kw):
return cluster
def get_test_quota(context, **kw):
"""Return a Quota object with appropriate attributes.
NOTE: The object leaves the attributes marked as changed, such
that a create() could be used to commit it to the DB.
"""
db_quota = db_utils.get_test_quota(**kw)
# Let DB generate ID if it isn't specified explicitly
if 'id' not in kw:
del db_quota['id']
quota = objects.Quota(context)
for key in db_quota:
setattr(quota, key, db_quota[key])
return quota
def create_test_quota(context, **kw):
"""Create and return a test Quota object.
Create a quota in the DB and return a Quota object with appropriate
attributes.
"""
quota = get_test_quota(context, **kw)
quota.create()
return quota
def get_test_x509keypair(context, **kw):
"""Return a X509KeyPair object with appropriate attributes.

View File

@ -0,0 +1,5 @@
---
features:
- This release introduces 'quota' endpoint that enable admin
users to set, update and show quota for a given tenant.
A non-admin user can get self quota limits.