From e4b19ad5b95043cd56f0e9485d370b4080d21cce Mon Sep 17 00:00:00 2001 From: Tim Simmons Date: Mon, 16 Jun 2014 21:30:43 +0000 Subject: [PATCH] Add Extensions to the v2 API *Provide a framework for adding extensions to the v2 API. *Port the Quotas extension *Add v2 extension unit tests *Fix small bug with noauth middleware and quota retrieval blueprint v2-extensions Closes-Bug: #1333892 Change-Id: I1dc395e7266a17c8c860e00ee470e14e94e82769 --- designate.sublime-project | 2 +- designate/api/v2/__init__.py | 6 + .../api/v2/controllers/extensions/__init__.py | 0 .../api/v2/controllers/extensions/quotas.py | 77 +++++++++++ designate/api/v2/controllers/root.py | 19 +++ designate/api/v2/views/extensions/__init__.py | 0 designate/api/v2/views/extensions/quotas.py | 55 ++++++++ designate/central/service.py | 3 + designate/resources/schemas/v2/quota.json | 47 +++++++ .../test_api/test_v2/extensions/__init__.py | 0 .../test_v2/extensions/test_quotas.py | 121 ++++++++++++++++++ etc/designate/designate.conf.sample | 3 + setup.cfg | 3 + 13 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 designate/api/v2/controllers/extensions/__init__.py create mode 100644 designate/api/v2/controllers/extensions/quotas.py create mode 100644 designate/api/v2/views/extensions/__init__.py create mode 100644 designate/api/v2/views/extensions/quotas.py create mode 100644 designate/resources/schemas/v2/quota.json create mode 100644 designate/tests/test_api/test_v2/extensions/__init__.py create mode 100644 designate/tests/test_api/test_v2/extensions/test_quotas.py diff --git a/designate.sublime-project b/designate.sublime-project index db31f01d..1f354114 100644 --- a/designate.sublime-project +++ b/designate.sublime-project @@ -23,7 +23,7 @@ "*.class", "*.psd", "*.db", - ".vagrant", + ".vagrant" ], "folder_exclude_patterns": [ diff --git a/designate/api/v2/__init__.py b/designate/api/v2/__init__.py index c9837ccf..dbc815e9 100644 --- a/designate/api/v2/__init__.py +++ b/designate/api/v2/__init__.py @@ -22,6 +22,12 @@ from designate.openstack.common import log as logging LOG = logging.getLogger(__name__) +OPTS = [ + cfg.ListOpt('enabled-extensions-v2', default=[], + help='Enabled API Extensions'), +] + +cfg.CONF.register_opts(OPTS, group='service:api') def factory(global_config, **local_conf): if not cfg.CONF['service:api'].enable_api_v2: diff --git a/designate/api/v2/controllers/extensions/__init__.py b/designate/api/v2/controllers/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/designate/api/v2/controllers/extensions/quotas.py b/designate/api/v2/controllers/extensions/quotas.py new file mode 100644 index 00000000..6f6f0d8c --- /dev/null +++ b/designate/api/v2/controllers/extensions/quotas.py @@ -0,0 +1,77 @@ +# COPYRIGHT 2014 Rackspace +# +# Author: Tim Simmons +# +# 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 designate.openstack.common import log as logging +from designate import schema +from designate.api.v2.controllers import rest +from designate.api.v2.views.extensions import quotas as quotas_view + +LOG = logging.getLogger(__name__) + + +class QuotasController(rest.RestController): + _view = quotas_view.QuotasView() + _resource_schema = schema.Schema('v2', 'quota') + + @staticmethod + def get_path(): + return '.quotas' + + @pecan.expose(template='json:', content_type='application/json') + def get_one(self, tenant_id): + request = pecan.request + context = pecan.request.environ['context'] + + quotas = self.central_api.get_quotas(context, tenant_id) + + return self._view.show(context, request, quotas) + + @pecan.expose(template='json:', content_type='application/json') + def patch_one(self, tenant_id): + """ Modify a Quota """ + request = pecan.request + response = pecan.response + context = request.environ['context'] + body = request.body_dict + + # Validate the request conforms to the schema + self._resource_schema.validate(body) + + values = self._view.load(context, request, body) + + for resource, hard_limit in values.iteritems(): + self.central_api.set_quota(context, tenant_id, resource, + hard_limit) + + response.status_int = 200 + + quotas = self.central_api.get_quotas(context, tenant_id) + + return self._view.show(context, request, quotas) + + @pecan.expose(template=None, content_type='application/json') + def delete_one(self, tenant_id): + """ Reset to the Default Quotas """ + request = pecan.request + response = pecan.response + context = request.environ['context'] + + self.central_api.reset_quotas(context, tenant_id) + + response.status_int = 204 + + return '' diff --git a/designate/api/v2/controllers/root.py b/designate/api/v2/controllers/root.py index d7e2d73d..b6e2a23e 100644 --- a/designate/api/v2/controllers/root.py +++ b/designate/api/v2/controllers/root.py @@ -13,6 +13,9 @@ # 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 stevedore import named + from designate import exceptions from designate.openstack.common import log as logging from designate.api.v2.controllers import limits @@ -32,6 +35,22 @@ class RootController(object): This is /v2/ Controller. Pecan will find all controllers via the object properties attached to this. """ + + def __init__(self): + enabled_ext = cfg.CONF['service:api'].enabled_extensions_v2 + if len(enabled_ext) > 0: + self._mgr = named.NamedExtensionManager( + namespace='designate.api.v2.extensions', + names=enabled_ext, + invoke_on_load=True) + for ext in self._mgr: + controller = self + path = ext.obj.get_path() + for p in path.split('.')[:-1]: + if p != '': + controller = getattr(controller, p) + setattr(controller, path.split('.')[-1], ext.obj) + limits = limits.LimitsController() schemas = schemas.SchemasController() reverse = reverse.ReverseController() diff --git a/designate/api/v2/views/extensions/__init__.py b/designate/api/v2/views/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/designate/api/v2/views/extensions/quotas.py b/designate/api/v2/views/extensions/quotas.py new file mode 100644 index 00000000..206c4f4a --- /dev/null +++ b/designate/api/v2/views/extensions/quotas.py @@ -0,0 +1,55 @@ +# COPYRIGHT 2014 Rackspace +# +# Author: Tim Simmons +# +# 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 designate.api.v2.views import base as base_view +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +class QuotasView(base_view.BaseView): + """ Model a Quota API response as a python dictionary """ + + _resource_name = 'quota' + _collection_name = 'quotas' + + def show_basic(self, context, request, quota): + """ Basic view of a quota """ + return { + "zones": quota['domains'], + "zone_records": quota['domain_records'], + "zone_recordsets": quota['domain_recordsets'], + "recordset_records": quota['recordset_records'] + } + + def load(self, context, request, body): + """ Extract a "central" compatible dict from an API call """ + valid_keys = ('domain_records', 'domain_recordsets', 'domains', + 'recordset_records') + + quota = body["quota"] + + old_keys = { + 'zones': 'domains', + 'zone_records': 'domain_records', + 'zone_recordsets': 'domain_recordsets', + 'recordset_records': 'recordset_records' + } + + for key in quota: + quota[old_keys[key]] = quota.pop(key) + + return self._load(context, request, body, valid_keys) diff --git a/designate/central/service.py b/designate/central/service.py index 613af8d7..46ea1668 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -290,6 +290,9 @@ class Service(service.Service): target = {'tenant_id': tenant_id} policy.check('get_quotas', context, target) + # This allows admins to get quota information correctly for all tenants + context.all_tenants = True + return self.quota.get_quotas(context, tenant_id) def get_quota(self, context, tenant_id, resource): diff --git a/designate/resources/schemas/v2/quota.json b/designate/resources/schemas/v2/quota.json new file mode 100644 index 00000000..f1482499 --- /dev/null +++ b/designate/resources/schemas/v2/quota.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-04/hyper-schema", + "id": "quota", + "title": "quota", + "description": "quota", + "additionalProperties": false, + "required": [ + "quota" + ], + "properties": { + "quota": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "zones": { + "type": "integer", + "description": "Number of zones allowed", + "min": 0, + "max": 2147483647, + "default": 10 + }, + "zone_recordsets": { + "type": "integer", + "description": "Number of zone recordsets allowed", + "min": 0, + "max": 2147483647, + "default": 500 + }, + "zone_records": { + "type": "integer", + "description": "Number of zone records allowed", + "min": 0, + "max": 2147483647, + "default": 500 + }, + "recordset_records": { + "type": "integer", + "description": "Number of recordset records allowed", + "min": 0, + "max": 2147483647, + "default": 20 + } + } + } + } +} \ No newline at end of file diff --git a/designate/tests/test_api/test_v2/extensions/__init__.py b/designate/tests/test_api/test_v2/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/designate/tests/test_api/test_v2/extensions/test_quotas.py b/designate/tests/test_api/test_v2/extensions/test_quotas.py new file mode 100644 index 00000000..371d50ce --- /dev/null +++ b/designate/tests/test_api/test_v2/extensions/test_quotas.py @@ -0,0 +1,121 @@ +# coding=utf-8 +# COPYRIGHT 2014 Rackspace +# +# Author: Tim Simmons +# +# 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 designate.tests.test_api.test_v2 import ApiV2TestCase + +cfg.CONF.import_opt('enabled_extensions_v2', 'designate.api.v2', + group='service:api') + + +class ApiV2QuotasTest(ApiV2TestCase): + def setUp(self): + self.config(enabled_extensions_v2=['quotas'], group='service:api') + super(ApiV2QuotasTest, self).setUp() + + def test_get_quotas(self): + self.policy({'get_quotas': '@'}) + context = self.get_admin_context() + + response = self.client.get('/quotas/%s' % context.tenant, + headers={'X-Test-Tenant-Id': + context.tenant}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + self.assertIn('quota', response.json) + self.assertIn('zones', response.json['quota']) + self.assertIn('zone_records', response.json['quota']) + self.assertIn('zone_recordsets', response.json['quota']) + self.assertIn('recordset_records', response.json['quota']) + + max_zones = response.json['quota']['zones'] + max_zone_records = response.json['quota']['zone_records'] + self.assertEqual(cfg.CONF.quota_domains, max_zones) + self.assertEqual(cfg.CONF.quota_domain_records, max_zone_records) + + def test_patch_quotas(self): + self.policy({'set_quotas': '@'}) + context = self.get_context(tenant='a', is_admin=True) + + response = self.client.get('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + self.assertIn('quota', response.json) + self.assertIn('zones', response.json['quota']) + current_count = response.json['quota']['zones'] + + body = {'quota': {"zones": 1337}} + response = self.client.patch_json('/quotas/%s' % 'a', body, + status=200, + headers={'X-Test-Tenant-Id': + context.tenant}) + self.assertEqual(200, response.status_int) + + response = self.client.get('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}) + + new_count = response.json['quota']['zones'] + + self.assertNotEqual(current_count, new_count) + + def test_reset_quotas(self): + self.policy({'reset_quotas': '@'}) + context = self.get_context(tenant='a', is_admin=True) + + response = self.client.get('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + self.assertIn('quota', response.json) + self.assertIn('zones', response.json['quota']) + current_count = response.json['quota']['zones'] + + body = {'quota': {"zones": 1337}} + response = self.client.patch_json('/quotas/%s' % 'a', body, + status=200, + headers={'X-Test-Tenant-Id': + context.tenant}) + self.assertEqual(200, response.status_int) + + response = self.client.get('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}) + + new_count = response.json['quota']['zones'] + + self.assertNotEqual(current_count, new_count) + + response = self.client.delete('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}, status=204) + response = self.client.get('/quotas/%s' % 'a', + headers={'X-Test-Tenant-Id': + context.tenant}) + + newest_count = response.json['quota']['zones'] + self.assertNotEqual(new_count, newest_count) + self.assertEqual(current_count, newest_count) diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index 6be6594c..66b4fc6d 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -90,6 +90,9 @@ debug = False # Enabled API Version 1 extensions #enabled_extensions_v1 = diagnostics, quotas, reports, sync, touch +# Enabled API Version 2 extensions +#enabled_extensions_v2 = quotas + #----------------------- # Keystone Middleware #----------------------- diff --git a/setup.cfg b/setup.cfg index 5ad37ba5..c511c224 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,6 +52,9 @@ designate.api.v1.extensions = reports = designate.api.v1.extensions.reports:blueprint touch = designate.api.v1.extensions.touch:blueprint +designate.api.v2.extensions = + quotas = designate.api.v2.controllers.extensions.quotas:QuotasController + designate.storage = sqlalchemy = designate.storage.impl_sqlalchemy:SQLAlchemyStorage