Expose /v2/recordsets api endpoint

APIImpact
DocImpact

Implements blueprint expose-recordsets-api

Change-Id: I05f534d2fa0f40e6ec376b335591e6ec485079b2
This commit is contained in:
James Li 2016-03-22 03:02:42 +00:00
parent 3cf67d6e75
commit 3c325b0699
19 changed files with 633 additions and 181 deletions

View File

@ -0,0 +1,47 @@
# Copyright 2016 Rackspace
#
# Author: James Li <james.li@rackspace.com>
#
# 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)

View File

@ -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,
rrset = self.central_api.get_recordset(context, None, recordset_id)
LOG.info(_LI("Retrieved %(recordset)s"), {'recordset': rrset})
canonical_loc = common.get_rrset_canonical_location(request,
rrset.zone_id,
recordset_id)
LOG.info(_LI("Retrieved %(recordset)s"), {'recordset': recordset})
return DesignateAdapter.render('API_v2', recordset, request=request)
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)

View File

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

View File

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

View File

@ -0,0 +1,158 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hpe.com>
#
# 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -306,8 +306,6 @@ Open up a new ssh window and log in to your server (or however youre communic
$ cd openstack/designate
::
If Designate was installed into a virtualenv, make sure your virtualenv is sourced
::

View File

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

View File

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

View File

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

View File

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