diff --git a/designate/api/v2/controllers/common.py b/designate/api/v2/controllers/common.py new file mode 100644 index 000000000..d500c712f --- /dev/null +++ b/designate/api/v2/controllers/common.py @@ -0,0 +1,47 @@ +# Copyright 2016 Rackspace +# +# Author: James Li +# +# 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 import utils + + +def retrieve_matched_rrsets(context, controller_obj, zone_id, **params): + if zone_id: + # NOTE: We need to ensure the zone actually exists, otherwise we may + # return deleted recordsets instead of a zone not found + controller_obj.central_api.get_zone(context, zone_id) + + # Extract the pagination params + marker, limit, sort_key, sort_dir = utils.get_paging_params( + params, controller_obj.SORT_KEYS) + + # Extract any filter params. + accepted_filters = ( + 'name', 'type', 'ttl', 'data', 'status', 'description', ) + criterion = controller_obj._apply_filter_params( + params, accepted_filters, {}) + + if zone_id: + criterion['zone_id'] = zone_id + + recordsets = controller_obj.central_api.find_recordsets( + context, criterion, marker, limit, sort_key, sort_dir) + + return recordsets + + +def get_rrset_canonical_location(request, zone_id, rrset_id): + return '{base_url}/v2/zones/{zone_id}/recordsets/{id}'.format( + base_url=request.host_url, zone_id=zone_id, + id=rrset_id) diff --git a/designate/api/v2/controllers/recordsets.py b/designate/api/v2/controllers/recordsets.py index 6518d3895..9233f8ba7 100644 --- a/designate/api/v2/controllers/recordsets.py +++ b/designate/api/v2/controllers/recordsets.py @@ -16,170 +16,43 @@ import pecan from oslo_log import log as logging -from designate import exceptions from designate import utils +from designate.api.v2.controllers import common from designate.api.v2.controllers import rest -from designate.objects import RecordSet from designate.objects.adapters import DesignateAdapter from designate.i18n import _LI LOG = logging.getLogger(__name__) -class RecordSetsController(rest.RestController): +class RecordSetsViewController(rest.RestController): SORT_KEYS = ['created_at', 'id', 'updated_at', 'zone_id', 'tenant_id', 'name', 'type', 'ttl', 'records'] @pecan.expose(template='json:', content_type='application/json') - @utils.validate_uuid('zone_id', 'recordset_id') - def get_one(self, zone_id, recordset_id): + @utils.validate_uuid('recordset_id') + def get_one(self, recordset_id): """Get RecordSet""" request = pecan.request context = request.environ['context'] - recordset = self.central_api.get_recordset(context, zone_id, - recordset_id) + rrset = self.central_api.get_recordset(context, None, recordset_id) - LOG.info(_LI("Retrieved %(recordset)s"), {'recordset': recordset}) + LOG.info(_LI("Retrieved %(recordset)s"), {'recordset': rrset}) - return DesignateAdapter.render('API_v2', recordset, request=request) + canonical_loc = common.get_rrset_canonical_location(request, + rrset.zone_id, + recordset_id) + pecan.core.redirect(location=canonical_loc, code=301) @pecan.expose(template='json:', content_type='application/json') - @utils.validate_uuid('zone_id') - def get_all(self, zone_id, **params): + def get_all(self, **params): """List RecordSets""" request = pecan.request context = request.environ['context'] - - # NOTE: We need to ensure the zone actually exists, otherwise we may - # return deleted recordsets instead of a zone not found - self.central_api.get_zone(context, zone_id) - - # Extract the pagination params - marker, limit, sort_key, sort_dir = utils.get_paging_params( - params, self.SORT_KEYS) - - # Extract any filter params. - accepted_filters = ( - 'name', 'type', 'ttl', 'data', 'status', 'description', ) - criterion = self._apply_filter_params( - params, accepted_filters, {}) - - criterion['zone_id'] = zone_id - - recordsets = self.central_api.find_recordsets( - context, criterion, marker, limit, sort_key, sort_dir) + recordsets = common.retrieve_matched_rrsets(context, self, None, + **params) LOG.info(_LI("Retrieved %(recordsets)s"), {'recordsets': recordsets}) return DesignateAdapter.render('API_v2', recordsets, request=request) - - @pecan.expose(template='json:', content_type='application/json') - @utils.validate_uuid('zone_id') - def post_all(self, zone_id): - """Create RecordSet""" - request = pecan.request - response = pecan.response - context = request.environ['context'] - - body = request.body_dict - - recordset = DesignateAdapter.parse('API_v2', body, RecordSet()) - - recordset.validate() - - # SOA recordsets cannot be created manually - if recordset.type == 'SOA': - raise exceptions.BadRequest( - "Creating a SOA recordset is not allowed") - - # Create the recordset - recordset = self.central_api.create_recordset( - context, zone_id, recordset) - - # Prepare the response headers - if recordset['status'] == 'PENDING': - response.status_int = 202 - else: - response.status_int = 201 - - LOG.info(_LI("Created %(recordset)s"), {'recordset': recordset}) - - recordset = DesignateAdapter.render( - 'API_v2', recordset, request=request) - - response.headers['Location'] = recordset['links']['self'] - - # Prepare and return the response body - return recordset - - @pecan.expose(template='json:', content_type='application/json') - @utils.validate_uuid('zone_id', 'recordset_id') - def put_one(self, zone_id, recordset_id): - """Update RecordSet""" - request = pecan.request - context = request.environ['context'] - body = request.body_dict - response = pecan.response - - # Fetch the existing recordset - recordset = self.central_api.get_recordset(context, zone_id, - recordset_id) - - # TODO(graham): Move this further down the stack - if recordset.managed and not context.edit_managed_records: - raise exceptions.BadRequest('Managed records may not be updated') - - # SOA recordsets cannot be updated manually - if recordset['type'] == 'SOA': - raise exceptions.BadRequest( - 'Updating SOA recordsets is not allowed') - - # NS recordsets at the zone root cannot be manually updated - if recordset['type'] == 'NS': - zone = self.central_api.get_zone(context, zone_id) - if recordset['name'] == zone['name']: - raise exceptions.BadRequest( - 'Updating a root zone NS record is not allowed') - - # Convert to APIv2 Format - - recordset = DesignateAdapter.parse('API_v2', body, recordset) - - recordset.validate() - - # Persist the resource - recordset = self.central_api.update_recordset(context, recordset) - - LOG.info(_LI("Updated %(recordset)s"), {'recordset': recordset}) - - if recordset['status'] == 'PENDING': - response.status_int = 202 - else: - response.status_int = 200 - - return DesignateAdapter.render('API_v2', recordset, request=request) - - @pecan.expose(template='json:', content_type='application/json') - @utils.validate_uuid('zone_id', 'recordset_id') - def delete_one(self, zone_id, recordset_id): - """Delete RecordSet""" - request = pecan.request - response = pecan.response - context = request.environ['context'] - - # Fetch the existing recordset - recordset = self.central_api.get_recordset(context, zone_id, - recordset_id) - if recordset['type'] == 'SOA': - raise exceptions.BadRequest( - 'Deleting a SOA recordset is not allowed') - - recordset = self.central_api.delete_recordset( - context, zone_id, recordset_id) - - LOG.info(_LI("Deleted %(recordset)s"), {'recordset': recordset}) - - response.status_int = 202 - - return DesignateAdapter.render('API_v2', recordset, request=request) diff --git a/designate/api/v2/controllers/root.py b/designate/api/v2/controllers/root.py index b7579cf24..8d05cd979 100644 --- a/designate/api/v2/controllers/root.py +++ b/designate/api/v2/controllers/root.py @@ -26,6 +26,7 @@ from designate.api.v2.controllers import pools from designate.api.v2.controllers import service_status from designate.api.v2.controllers import zones from designate.api.v2.controllers import tsigkeys +from designate.api.v2.controllers import recordsets LOG = logging.getLogger(__name__) @@ -60,3 +61,4 @@ class RootController(object): pools = pools.PoolsController() service_statuses = service_status.ServiceStatusController() tsigkeys = tsigkeys.TsigKeysController() + recordsets = recordsets.RecordSetsViewController() diff --git a/designate/api/v2/controllers/zones/__init__.py b/designate/api/v2/controllers/zones/__init__.py index 26608c724..ab64d730b 100644 --- a/designate/api/v2/controllers/zones/__init__.py +++ b/designate/api/v2/controllers/zones/__init__.py @@ -20,7 +20,7 @@ from oslo_log import log as logging from designate import exceptions from designate import utils from designate.api.v2.controllers import rest -from designate.api.v2.controllers import recordsets +from designate.api.v2.controllers.zones import recordsets from designate.api.v2.controllers.zones import tasks from designate.api.v2.controllers.zones import nameservers from designate import objects diff --git a/designate/api/v2/controllers/zones/recordsets.py b/designate/api/v2/controllers/zones/recordsets.py new file mode 100644 index 000000000..f0f0cfb9a --- /dev/null +++ b/designate/api/v2/controllers/zones/recordsets.py @@ -0,0 +1,158 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 oslo_log import log as logging + +from designate import exceptions +from designate import utils +from designate.api.v2.controllers import common +from designate.api.v2.controllers import rest +from designate.objects import RecordSet +from designate.objects.adapters import DesignateAdapter + +LOG = logging.getLogger(__name__) + + +class RecordSetsController(rest.RestController): + SORT_KEYS = ['created_at', 'id', 'updated_at', 'zone_id', 'tenant_id', + 'name', 'type', 'ttl', 'records'] + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id', 'recordset_id') + def get_one(self, zone_id, recordset_id): + """Get RecordSet""" + request = pecan.request + context = request.environ['context'] + + return DesignateAdapter.render( + 'API_v2', + self.central_api.get_recordset( + context, zone_id, recordset_id), + request=request) + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id') + def get_all(self, zone_id, **params): + """List RecordSets""" + request = pecan.request + context = request.environ['context'] + recordsets = common.retrieve_matched_rrsets(context, self, zone_id, + **params) + + return DesignateAdapter.render('API_v2', recordsets, request=request) + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id') + def post_all(self, zone_id): + """Create RecordSet""" + request = pecan.request + response = pecan.response + context = request.environ['context'] + + body = request.body_dict + + recordset = DesignateAdapter.parse('API_v2', body, RecordSet()) + + recordset.validate() + + # SOA recordsets cannot be created manually + if recordset.type == 'SOA': + raise exceptions.BadRequest( + "Creating a SOA recordset is not allowed") + + # Create the recordset + recordset = self.central_api.create_recordset( + context, zone_id, recordset) + + # Prepare the response headers + if recordset['status'] == 'PENDING': + response.status_int = 202 + else: + response.status_int = 201 + + recordset = DesignateAdapter.render( + 'API_v2', recordset, request=request) + + response.headers['Location'] = recordset['links']['self'] + + # Prepare and return the response body + return recordset + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id', 'recordset_id') + def put_one(self, zone_id, recordset_id): + """Update RecordSet""" + request = pecan.request + context = request.environ['context'] + body = request.body_dict + response = pecan.response + + # Fetch the existing recordset + recordset = self.central_api.get_recordset(context, zone_id, + recordset_id) + + # TODO(graham): Move this further down the stack + if recordset.managed and not context.edit_managed_records: + raise exceptions.BadRequest('Managed records may not be updated') + + # SOA recordsets cannot be updated manually + if recordset['type'] == 'SOA': + raise exceptions.BadRequest( + 'Updating SOA recordsets is not allowed') + + # NS recordsets at the zone root cannot be manually updated + if recordset['type'] == 'NS': + zone = self.central_api.get_zone(context, zone_id) + if recordset['name'] == zone['name']: + raise exceptions.BadRequest( + 'Updating a root zone NS record is not allowed') + + # Convert to APIv2 Format + + recordset = DesignateAdapter.parse('API_v2', body, recordset) + + recordset.validate() + + # Persist the resource + recordset = self.central_api.update_recordset(context, recordset) + + if recordset['status'] == 'PENDING': + response.status_int = 202 + else: + response.status_int = 200 + + return DesignateAdapter.render('API_v2', recordset, request=request) + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id', 'recordset_id') + def delete_one(self, zone_id, recordset_id): + """Delete RecordSet""" + request = pecan.request + response = pecan.response + context = request.environ['context'] + + # Fetch the existing recordset + recordset = self.central_api.get_recordset(context, zone_id, + recordset_id) + if recordset['type'] == 'SOA': + raise exceptions.BadRequest( + 'Deleting a SOA recordset is not allowed') + + recordset = self.central_api.delete_recordset( + context, zone_id, recordset_id) + response.status_int = 202 + + return DesignateAdapter.render('API_v2', recordset, request=request) diff --git a/designate/central/service.py b/designate/central/service.py index fc6479bb1..ba542ebeb 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -1243,6 +1243,9 @@ class Service(service.RPCService, service.Service): self.pool_manager_api.update_zone(context, zone) + recordset.zone_name = zone.name + recordset.obj_reset_changes(['zone_name']) + return recordset def _validate_recordset(self, context, zone, recordset): @@ -1306,15 +1309,18 @@ class Service(service.RPCService, service.Service): return (recordset, zone) def get_recordset(self, context, zone_id, recordset_id): - zone = self.storage.get_zone(context, zone_id) recordset = self.storage.get_recordset(context, recordset_id) - # Ensure the zone_id matches the record's zone_id - if zone.id != recordset.zone_id: - raise exceptions.RecordSetNotFound() + if zone_id: + zone = self.storage.get_zone(context, zone_id) + # Ensure the zone_id matches the record's zone_id + if zone.id != recordset.zone_id: + raise exceptions.RecordSetNotFound() + else: + zone = self.storage.get_zone(context, recordset.zone_id) target = { - 'zone_id': zone_id, + 'zone_id': zone.id, 'zone_name': zone.name, 'recordset_id': recordset.id, 'tenant_id': zone.tenant_id, @@ -1322,6 +1328,8 @@ class Service(service.RPCService, service.Service): policy.check('get_recordset', context, target) + recordset.zone_name = zone.name + recordset.obj_reset_changes(['zone_name']) recordset = recordset return recordset @@ -1367,7 +1375,7 @@ class Service(service.RPCService, service.Service): raise exceptions.BadRequest('Moving a recordset between tenants ' 'is not allowed') - if 'zone_id' in changes: + if 'zone_id' in changes or 'zone_name' in changes: raise exceptions.BadRequest('Moving a recordset between zones ' 'is not allowed') @@ -1460,6 +1468,9 @@ class Service(service.RPCService, service.Service): self.pool_manager_api.update_zone(context, zone) + recordset.zone_name = zone.name + recordset.obj_reset_changes(['zone_name']) + return recordset @transaction diff --git a/designate/objects/adapters/api_v2/base.py b/designate/objects/adapters/api_v2/base.py index 46176d7a1..92f2621a3 100644 --- a/designate/objects/adapters/api_v2/base.py +++ b/designate/objects/adapters/api_v2/base.py @@ -78,7 +78,7 @@ class APIv2Adapter(base.DesignateAdapter): ##################### @classmethod - def _get_resource_links(cls, object, request): + def _get_resource_links(cls, obj, request): if cfg.CONF['service:api'].enable_host_header: try: base_uri = request.host_url @@ -87,11 +87,11 @@ class APIv2Adapter(base.DesignateAdapter): else: base_uri = cls.BASE_URI - return {'self': '%s%s/%s' % - (base_uri, cls._get_path(request), object.id)} + path = cls._get_path(request, obj) + return {'self': '%s%s/%s' % (base_uri, path, obj.id)} @classmethod - def _get_path(cls, request): + def _get_path(cls, request, *args): path = request.path.lstrip('/').split('/') item_path = '' for part in path: diff --git a/designate/objects/adapters/api_v2/recordset.py b/designate/objects/adapters/api_v2/recordset.py index c2e42a540..7365d63e4 100644 --- a/designate/objects/adapters/api_v2/recordset.py +++ b/designate/objects/adapters/api_v2/recordset.py @@ -32,6 +32,9 @@ class RecordSetAPIv2Adapter(base.APIv2Adapter): "name": { 'immutable': True }, + "zone_name": { + 'read_only': True, + }, "type": { 'rename': 'type', 'immutable': True @@ -112,6 +115,27 @@ class RecordSetAPIv2Adapter(base.APIv2Adapter): return super(RecordSetAPIv2Adapter, cls)._parse_object( new_recordset, recordset, *args, **kwargs) + @classmethod + def _get_path(cls, request, obj): + ori_path = request.path + path = ori_path.lstrip('/').split('/') + insert_zones = False + to_insert = '' + if 'zones' not in path and obj is not None: + insert_zones = True + to_insert = 'zones/{0}'.format(obj.zone_id) + + item_path = '' + for part in path: + if part == cls.MODIFICATIONS['options']['collection_name']: + item_path += '/' + part + return item_path + elif insert_zones and to_insert and part == 'v2': + item_path += '/v2/{0}'.format(to_insert) + insert_zones = False # make sure only insert once if needed + else: + item_path += '/' + part + class RecordSetListAPIv2Adapter(base.APIv2Adapter): diff --git a/designate/objects/adapters/api_v2/zone_export.py b/designate/objects/adapters/api_v2/zone_export.py index 185af3857..0f60429bd 100644 --- a/designate/objects/adapters/api_v2/zone_export.py +++ b/designate/objects/adapters/api_v2/zone_export.py @@ -48,7 +48,7 @@ class ZoneExportAPIv2Adapter(base.APIv2Adapter): } @classmethod - def _get_path(cls, request): + def _get_path(cls, request, *args): return '/v2/zones/tasks/exports' @classmethod diff --git a/designate/objects/adapters/api_v2/zone_transfer_request.py b/designate/objects/adapters/api_v2/zone_transfer_request.py index e7ea939ce..84f024a80 100644 --- a/designate/objects/adapters/api_v2/zone_transfer_request.py +++ b/designate/objects/adapters/api_v2/zone_transfer_request.py @@ -84,7 +84,7 @@ class ZoneTransferRequestAPIv2Adapter(base.APIv2Adapter): return obj @classmethod - def _get_path(cls, request): + def _get_path(cls, request, *args): return '/v2/zones/tasks/transfer_requests' diff --git a/designate/objects/recordset.py b/designate/objects/recordset.py index eab98cdd2..c2b00fd73 100644 --- a/designate/objects/recordset.py +++ b/designate/objects/recordset.py @@ -94,10 +94,19 @@ class RecordSet(base.DictObjectMixin, base.PersistentObjectMixin, 'format': 'uuid' }, }, - 'name': { + 'zone_name': { 'schema': { 'type': 'string', 'description': 'Zone name', + 'format': 'domainname', + 'maxLength': 255, + }, + 'read_only': True + }, + 'name': { + 'schema': { + 'type': 'string', + 'description': 'Recordset name', 'format': 'hostname', 'maxLength': 255, }, diff --git a/designate/sqlalchemy/base.py b/designate/sqlalchemy/base.py index 30f4e58c5..8f012745d 100644 --- a/designate/sqlalchemy/base.py +++ b/designate/sqlalchemy/base.py @@ -287,7 +287,9 @@ class SQLAlchemy(object): records_table, recordsets_table.c.id == records_table.c.recordset_id) - inner_q = select([recordsets_table.c.id]).select_from(rzjoin).\ + inner_q = select([recordsets_table.c.id, # 0 - RS ID + zones_table.c.name] # 1 - ZONE NAME + ).select_from(rzjoin).\ where(zones_table.c.deleted == '0') count_q = select([func.count(distinct(recordsets_table.c.id))]).\ select_from(rzjoin).where(zones_table.c.deleted == '0') @@ -338,17 +340,18 @@ class SQLAlchemy(object): # http://dev.mysql.com/doc/mysql-reslimits-excerpt/5.6/en/subquery-restrictions.html # noqa inner_rproxy = self.session.execute(inner_q) - ids = inner_rproxy.fetchall() - if len(ids) == 0: + rows = inner_rproxy.fetchall() + if len(rows) == 0: return 0, objects.RecordSetList() + id_zname_map = {} + for r in rows: + id_zname_map[r[0]] = r[1] + formatted_ids = six.moves.map(operator.itemgetter(0), rows) resultproxy = self.session.execute(count_q) result = resultproxy.fetchone() total_count = 0 if result is None else result[0] - # formatted_ids = [id[0] for id in ids] - formatted_ids = six.moves.map(operator.itemgetter(0), ids) - # Join the 2 required tables rjoin = recordsets_table.outerjoin( records_table, @@ -465,6 +468,9 @@ class SQLAlchemy(object): for key, value in rs_map.items(): setattr(current_rrset, key, record[value]) + current_rrset.zone_name = id_zname_map[current_rrset.id] + current_rrset.obj_reset_changes(['zone_name']) + current_rrset.records = objects.RecordList() if record[r_map['id']] is not None: diff --git a/designate/tests/unit/test_central/test_basic.py b/designate/tests/unit/test_central/test_basic.py index eda709f11..e1d340116 100644 --- a/designate/tests/unit/test_central/test_basic.py +++ b/designate/tests/unit/test_central/test_basic.py @@ -1095,14 +1095,15 @@ class CentralZoneTestCase(CentralBasic): name='example.org.', tenant_id='2', ) - self.service.storage.get_recordset.return_value = RoObject( + self.service.storage.get_recordset.return_value = objects.RecordSet( zone_id='2', + zone_name='example.org.', id='3' ) self.service.get_recordset( self.context, - '1', '2', + '3', ) self.assertEqual( 'get_recordset', @@ -1111,7 +1112,7 @@ class CentralZoneTestCase(CentralBasic): t, ctx, target = designate.central.service.policy.check.call_args[0] self.assertEqual('get_recordset', t) self.assertEqual({ - 'zone_id': '1', + 'zone_id': '2', 'zone_name': 'example.org.', 'recordset_id': '3', 'tenant_id': '2'}, target) @@ -1342,22 +1343,26 @@ class CentralZoneTestCase(CentralBasic): self.service.delete_recordset(self.context, 'd', 'r') def test_delete_recordset(self): - self.service.storage.get_zone.return_value = RoObject( + mock_zone = RoObject( action='foo', id=4, name='example.org.', tenant_id='2', type='foo', ) - self.service.storage.get_recordset.return_value = RoObject( + mock_rs = objects.RecordSet( zone_id=4, + zone_name='example.org.', id='i', - managed=False, + records=[], ) + + self.service.storage.get_zone.return_value = mock_zone + self.service.storage.get_recordset.return_value = mock_rs self.context = Mock() self.context.edit_managed_records = False self.service._delete_recordset_in_storage = Mock( - return_value=('', '') + return_value=(mock_rs, mock_zone) ) with fx_pool_manager: self.service.delete_recordset(self.context, 'd', 'r') diff --git a/designate/tests/unit/test_objects/test_adapters.py b/designate/tests/unit/test_objects/test_adapters.py index bf22777d0..7cf10b3ab 100644 --- a/designate/tests/unit/test_objects/test_adapters.py +++ b/designate/tests/unit/test_objects/test_adapters.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +from mock import Mock from oslo_log import log as logging import oslotest.base @@ -41,3 +42,15 @@ class DesignateAdapterTest(oslotest.base.BaseTestCase): def test_object_render(self): adapters.DesignateAdapter.render('TEST_API', objects.DesignateObject()) + + +class RecordSetAPIv2AdapterTest(oslotest.base.BaseTestCase): + def test_get_path(self): + request = Mock() + request.path = '/v2/recordsets' + recordset = Mock() + recordset.zone_id = 'a-b-c-d' + expected_path = '/v2/zones/a-b-c-d/recordsets' + + path = adapters.RecordSetAPIv2Adapter._get_path(request, recordset) + self.assertEqual(expected_path, path) diff --git a/doc/source/install/ubuntu-dev.rst b/doc/source/install/ubuntu-dev.rst index 4dd8bbe1d..5160de2ee 100644 --- a/doc/source/install/ubuntu-dev.rst +++ b/doc/source/install/ubuntu-dev.rst @@ -306,8 +306,6 @@ Open up a new ssh window and log in to your server (or however you’re communic $ cd openstack/designate -:: - If Designate was installed into a virtualenv, make sure your virtualenv is sourced :: diff --git a/doc/source/rest/v2/recordsets.rst b/doc/source/rest/v2/recordsets.rst index 28dbcadb5..e0ad4ea79 100644 --- a/doc/source/rest/v2/recordsets.rst +++ b/doc/source/rest/v2/recordsets.rst @@ -99,6 +99,8 @@ The following format can be used for common record set types including A, AAAA, Get Record Set -------------- +Two APIs can be used to retrieve a single recordset. One with zone ID in url, the other without. + .. http:get:: /zones/(uuid:id)/recordsets/(uuid:id) Retrieves a record set with the specified record set ID. @@ -142,18 +144,62 @@ Get Record Set :statuscode 200: Success :statuscode 401: Access Denied +.. http:get:: /recordsets/(uuid:id) + + If http client follows redirect, API returns a 200. Otherwise it returns 301 with the canonical location of the requested recordset. + + **Example request:** + + .. sourcecode:: http + + GET /v2/recordsets/f7b10e9b-0cae-4a91-b162-562bc6096648 HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + Content-Type: application/json + + + **Example response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "description": "This is an example recordset.", + "links": { + "self": "https://127.0.0.1:9001/v2/zones/2150b1bf-dee2-4221-9d85-11f7886fb15f/recordsets/f7b10e9b-0cae-4a91-b162-562bc6096648" + }, + "updated_at": null, + "records": [ + "10.1.0.2" + ], + "ttl": 3600, + "id": "f7b10e9b-0cae-4a91-b162-562bc6096648", + "name": "example.org.", + "zone_id": "2150b1bf-dee2-4221-9d85-11f7886fb15f", + "created_at": "2014-10-24T19:59:44.000000", + "version": 1, + "type": "A" + } + + :statuscode 301: Moved Permanently + :statuscode 200: Success + :statuscode 401: Access Denied + List Record Sets ---------------- -.. http:get:: /zones/(uuid:id)/recordsets +**Lists all record sets for a given zone** - Lists all record sets for a given zone id. +.. http:get:: /zones/(uuid:id)/recordsets **Example Request:** .. sourcecode:: http - GET /v2/zones/2150b1bf-dee2-4221-9d85-11f7886fb15f/recordsets HTTP/1.1 + GET /v2/zones/c991f02b-ae05-4570-bf75-73def68fe700/recordsets HTTP/1.1 Host: 127.0.0.1:9001 Accept: application/json Content-Type: application/json @@ -237,6 +283,192 @@ List Record Sets :statuscode 200: Success :statuscode 401: Access Denied +**Lists record sets across all zones** + +.. http:get:: /recordsets + + **Example Request:** + + .. sourcecode:: http + + GET /v2/recordsets HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + Content-Type: application/json + + + **Example Response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "recordsets": [ + { + "description": null, + "links": { + "self": "https://127.0.0.1:9001/v2/zones/2150b1bf-dee2-4221-9d85-11f7886fb15f/recordsets/65ee6b49-bb4c-4e52-9799-31330c94161f" + }, + "updated_at": null, + "records": [ + "ns1.devstack.org." + ], + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "id": "65ee6b49-bb4c-4e52-9799-31330c94161f", + "name": "example.org.", + "zone_id": "2150b1bf-dee2-4221-9d85-11f7886fb15f", + "zone_name": "example.org.", + "created_at": "2014-10-24T19:59:11.000000", + "version": 1, + "type": "NS" + }, + { + "description": null, + "links": { + "self": "https://127.0.0.1:9001/v2/zones/2150b1bf-dee2-4221-9d85-11f7886fb15f/recordsets/14500cf9-bdff-48f6-b06b-5fc7491ffd9e" + }, + "updated_at": "2014-10-24T19:59:46.000000", + "records": [ + "ns1.devstack.org. jli.ex.com. 1458666091 3502 600 86400 3600" + ], + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "id": "14500cf9-bdff-48f6-b06b-5fc7491ffd9e", + "name": "example.org.", + "zone_id": "2150b1bf-dee2-4221-9d85-11f7886fb15f", + "zone_name": "example.org.", + "created_at": "2014-10-24T19:59:12.000000", + "version": 1, + "type": "SOA" + }, + { + "name": "example.com.", + "id": "12caacfd-f0fc-4bcb-aa24-c42769897822", + "type": "SOA", + "zone_name": "example.com.", + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "description": null, + "links": { + "self": "http://127.0.0.1:9001/v2/zones/b8d7eaf1-e5c7-4b15-be6e-4b2809f47ec3/recordsets/12caacfd-f0fc-4bcb-aa24-c42769897822" + }, + "created_at": "2016-03-22T16:12:35.000000", + "updated_at": "2016-03-22T17:01:31.000000", + "records": [ + "ns1.devstack.org. jli.ex.com. 1458666091 3502 600 86400 3600" + ], + "zone_id": "b8d7eaf1-e5c7-4b15-be6e-4b2809f47ec3", + "version": 2 + }, + { + "name": "example.com.", + "id": "f39c51d1-ec2c-48a8-b9f7-877d56b7b82a", + "type": "NS", + "zone_name": "example.com.", + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "description": null, + "links": { + "self": "http://127.0.0.1:9001/v2/zones/b8d7eaf1-e5c7-4b15-be6e-4b2809f47ec3/recordsets/f39c51d1-ec2c-48a8-b9f7-877d56b7b82a" + }, + "created_at": "2016-03-22T16:12:35.000000", + "updated_at": null, + "records": [ + "ns1.devstack.org." + ], + "zone_id": "b8d7eaf1-e5c7-4b15-be6e-4b2809f47ec3", + "version": 1 + }, + ], + "metadata": { + "total_count": 4 + }, + "links": { + "self": "https://127.0.0.1:9001/v2/recordsets" + } + } + +**Filtering record sets** + +.. http:get:: /recordsets?KEY=VALUE + + **Example Request:** + + .. sourcecode:: http + + GET /v2/recordsets?data=192.168* HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + Content-Type: application/json + + + **Example Response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "metadata": { + "total_count": 2 + }, + "links": { + "self": "http://127.0.0.1:9001/v2/recordsets?data=192.168%2A" + }, + "recordsets": [ + { + "name": "mail.example.net.", + "id": "a48588c5-5093-4585-b0fc-3e399d169c01", + "type": "A", + "zone_name": "example.net.", + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "description": null, + "links": { + "self": "http://127.0.0.1:9001/v2/zones/601a25f0-5c4d-4058-8d9c-e6a78f5ffbb8/recordsets/a48588c5-5093-4585-b0fc-3e399d169c01" + }, + "created_at": "2016-04-04T20:11:08.000000", + "updated_at": null, + "records": [ + "192.168.0.1" + ], + "zone_id": "601a25f0-5c4d-4058-8d9c-e6a78f5ffbb8", + "version": 1 + }, + { + "name": "www.example.net.", + "id": "f2c7a0f6-8ec7-4d14-b8ec-2a55a8129160", + "type": "A", + "zone_name": "example.net.", + "action": "NONE", + "ttl": null, + "status": "ACTIVE", + "description": null, + "links": { + "self": "http://127.0.0.1:9001/v2/zones/601a25f0-5c4d-4058-8d9c-e6a78f5ffbb8/recordsets/f2c7a0f6-8ec7-4d14-b8ec-2a55a8129160" + }, + "created_at": "2016-04-04T22:21:03.000000", + "updated_at": null, + "records": [ + "192.168.6.6" + ], + "zone_id": "601a25f0-5c4d-4058-8d9c-e6a78f5ffbb8", + "version": 1 + } + ] + } + Update Record Set ----------------- diff --git a/functionaltests/api/v2/clients/recordset_client.py b/functionaltests/api/v2/clients/recordset_client.py index 65a63dae6..e71f1a6f8 100644 --- a/functionaltests/api/v2/clients/recordset_client.py +++ b/functionaltests/api/v2/clients/recordset_client.py @@ -24,20 +24,27 @@ from functionaltests.common import utils class RecordsetClient(ClientMixin): - def recordsets_uri(self, zone_id, filters=None): - return self.create_uri("/zones/{0}/recordsets".format(zone_id), - filters=filters) + def recordsets_uri(self, zone_id, cross_zone=False, filters=None): + if cross_zone: + uri = self.create_uri("/recordsets", filters=filters) + else: + uri = self.create_uri("/zones/{0}/recordsets".format(zone_id), + filters=filters) + return uri - def recordset_uri(self, zone_id, recordset_id): - return "{0}/{1}".format(self.recordsets_uri(zone_id), recordset_id) + def recordset_uri(self, zone_id, recordset_id, cross_zone=False): + return "{0}/{1}".format(self.recordsets_uri(zone_id, cross_zone), + recordset_id) - def list_recordsets(self, zone_id, filters=None, **kwargs): + def list_recordsets(self, zone_id, cross_zone=False, filters=None, + **kwargs): resp, body = self.client.get( - self.recordsets_uri(zone_id, filters), **kwargs) + self.recordsets_uri(zone_id, cross_zone, filters), **kwargs) return self.deserialize(resp, body, RecordsetListModel) - def get_recordset(self, zone_id, recordset_id, **kwargs): - resp, body = self.client.get(self.recordset_uri(zone_id, recordset_id), + def get_recordset(self, zone_id, recordset_id, cross_zone=False, **kwargs): + resp, body = self.client.get(self.recordset_uri(zone_id, recordset_id, + cross_zone), **kwargs) return self.deserialize(resp, body, RecordsetModel) diff --git a/functionaltests/api/v2/test_recordset.py b/functionaltests/api/v2/test_recordset.py index 0cc11e67d..5116a0224 100644 --- a/functionaltests/api/v2/test_recordset.py +++ b/functionaltests/api/v2/test_recordset.py @@ -222,3 +222,65 @@ class RecordsetOwnershipTest(DesignateV2Test): self.assertRaises(exceptions.RestClientException, lambda: RecordsetClient.as_user('alt') .post_recordset(alt_zone.id, recordset)) + + +@utils.parameterized_class +class RecordsetCrossZoneTest(DesignateV2Test): + + def setUp(self): + super(RecordsetCrossZoneTest, self).setUp() + self.increase_quotas(user='default') + self.ensure_tld_exists('com') + self.zone = self.useFixture(ZoneFixture()).created_zone + self.alt_zone = self.useFixture(ZoneFixture()).created_zone + + def test_get_single_recordset(self): + post_model = datagen.random_a_recordset(self.zone.name) + _, resp_model = RecordsetClient.as_user('default').post_recordset( + self.zone.id, post_model) + rrset_id = resp_model.id + + resp, model = RecordsetClient.as_user('default').get_recordset( + self.zone.id, rrset_id, cross_zone=True) + self.assertEqual(200, resp.status) + + # clean up + RecordsetClient.as_user('default').delete_recordset(self.zone.id, + rrset_id) + + def test_list_recordsets(self): + post_model = datagen.random_a_recordset(self.zone.name) + self.useFixture(RecordsetFixture(self.zone.id, post_model)) + post_model = datagen.random_a_recordset(self.alt_zone.name) + self.useFixture(RecordsetFixture(self.alt_zone.id, post_model)) + + resp, model = RecordsetClient.as_user('default').list_recordsets( + 'zone_id', cross_zone=True) + self.assertEqual(200, resp.status) + zone_names = set() + for r in model.recordsets: + zone_names.add(r.zone_name) + self.assertGreaterEqual(len(zone_names), 2) + + def test_filter_recordsets(self): + # create one A recordset in 'zone' + post_model = datagen.random_a_recordset(self.zone.name, + ip='123.201.99.1') + self.useFixture(RecordsetFixture(self.zone.id, post_model)) + + # create two A recordsets in 'alt_zone' + post_model = datagen.random_a_recordset(self.alt_zone.name, + ip='10.0.1.1') + self.useFixture(RecordsetFixture(self.alt_zone.id, post_model)) + post_model = datagen.random_a_recordset(self.alt_zone.name, + ip='123.201.99.2') + self.useFixture(RecordsetFixture(self.alt_zone.id, post_model)) + + # Add limit in filter to make response paginated + filters = {"data": "123.201.99.*", "limit": 2} + resp, model = RecordsetClient.as_user('default') \ + .list_recordsets('zone_id', cross_zone=True, filters=filters) + self.assertEqual(200, resp.status) + self.assertEqual(2, model.metadata.total_count) + self.assertEqual(len(model.recordsets), 2) + self.assertIsNotNone(model.links.next) diff --git a/releasenotes/notes/recordset-api-2c82abf569f7623e.yaml b/releasenotes/notes/recordset-api-2c82abf569f7623e.yaml new file mode 100644 index 000000000..987476481 --- /dev/null +++ b/releasenotes/notes/recordset-api-2c82abf569f7623e.yaml @@ -0,0 +1,5 @@ +--- +features: + - A new recordset api ``/v2/recordsets`` is exposed with GET method + allowed only. The api can be used for retrieving recordsets across all the + zones under a tenant. Filtering on certain fields is supported as well. \ No newline at end of file