Move Zone Import / Export to /admin API

* Moves zonefile import/export to admin API due to issues with non
  async action of import / export
* Added policy checks to allow restriction of usage
* Fixed 500 Error on invalid Content-Type when using body_dict()
Closes-Bug: #1443366
Change-Id: Iea0f2077f24da9bb1a93d5ac9aadced402343405
APIImpact: Move zone import / export to /admin
This commit is contained in:
Graham Hayes 2015-04-14 10:47:38 +02:00
parent 9ca327945d
commit aba646b94f
12 changed files with 380 additions and 250 deletions

View File

@ -0,0 +1,61 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.P.
#
# 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.api.v2.controllers import rest
from designate import utils
from designate import policy
LOG = logging.getLogger(__name__)
class ExportController(rest.RestController):
@pecan.expose(template=None, content_type='text/dns')
@utils.validate_uuid('zone_id')
def get_one(self, zone_id):
context = pecan.request.environ['context']
policy.check('zone_export', context)
servers = self.central_api.get_domain_servers(context, zone_id)
domain = self.central_api.get_domain(context, zone_id)
criterion = {'domain_id': zone_id}
recordsets = self.central_api.find_recordsets(context, criterion)
records = []
for recordset in recordsets:
criterion = {
'domain_id': domain['id'],
'recordset_id': recordset['id']
}
raw_records = self.central_api.find_records(context, criterion)
for record in raw_records:
records.append({
'name': recordset['name'],
'type': recordset['type'],
'ttl': recordset['ttl'],
'data': record['data'],
})
return utils.render_template('bind9-zone.jinja2',
servers=servers,
domain=domain,
records=records)

View File

@ -0,0 +1,77 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.P.
#
# 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 dns import zone as dnszone
from dns import exception as dnsexception
import pecan
from oslo_log import log as logging
from oslo.config import cfg
from designate.api.v2.controllers import rest
from designate import dnsutils
from designate import exceptions
from designate.objects.adapters import DesignateAdapter
from designate import policy
LOG = logging.getLogger(__name__)
class ImportController(rest.RestController):
BASE_URI = cfg.CONF['service:api'].api_base_uri.rstrip('/')
@pecan.expose(template='json:', content_type='application/json')
def post_all(self):
request = pecan.request
response = pecan.response
context = pecan.request.environ['context']
policy.check('zone_import', context)
try:
dnspython_zone = dnszone.from_text(
request.body,
# Don't relativize, otherwise we end up with '@' record names.
relativize=False,
# Dont check origin, we allow missing NS records (missing SOA
# records are taken care of in _create_zone).
check_origin=False)
domain = dnsutils.from_dnspython_zone(dnspython_zone)
domain.type = 'PRIMARY'
for rrset in list(domain.recordsets):
if rrset.type in ('NS', 'SOA'):
domain.recordsets.remove(rrset)
except dnszone.UnknownOrigin:
raise exceptions.BadRequest('The $ORIGIN statement is required and'
' must be the first statement in the'
' zonefile.')
except dnsexception.SyntaxError:
raise exceptions.BadRequest('Malformed zonefile.')
zone = self.central_api.create_domain(context, domain)
if zone['status'] == 'PENDING':
response.status_int = 202
else:
response.status_int = 201
zone = DesignateAdapter.render('API_v2', zone, request=request)
zone['links']['self'] = '%s%s/%s' % (
self.BASE_URI, 'v2/zones', zone['id'])
response.headers['Location'] = zone['links']['self']
return zone

View File

@ -0,0 +1,39 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.P.
#
# 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
from designate.api.v2.controllers import rest
from designate.api.admin.controllers.extensions import import_
from designate.api.admin.controllers.extensions import export
LOG = logging.getLogger(__name__)
class ZonesController(rest.RestController):
@staticmethod
def get_path():
return '.zones'
def __init__(self):
# Import is a keyword - so we have to do a setattr instead
setattr(self, 'import', import_.ImportController())
super(ZonesController, self).__init__()
# We cannot do an assignment as import is a keyword. it is done as part of
# the __init__() above
#
# import = import_.CountsController()
export = export.ExportController()

View File

@ -14,13 +14,10 @@
# License for the specific language governing permissions and limitations
# under the License.
import pecan
from dns import zone as dnszone
from dns import exception as dnsexception
from oslo.config import cfg
from designate import exceptions
from designate import utils
from designate import dnsutils
from designate.api.v2.controllers import rest
from designate.api.v2.controllers import recordsets
from designate.api.v2.controllers.zones import tasks
@ -40,7 +37,6 @@ class ZonesController(rest.RestController):
tasks = tasks.TasksController()
@pecan.expose(template='json:', content_type='application/json')
@pecan.expose(template=None, content_type='text/dns')
@utils.validate_uuid('zone_id')
def get_one(self, zone_id):
"""Get Zone"""
@ -48,56 +44,12 @@ class ZonesController(rest.RestController):
request = pecan.request
context = request.environ['context']
if 'Accept' not in request.headers:
raise exceptions.BadRequest('Missing Accept header')
best_match = request.accept.best_match(['application/json',
'text/dns'])
if best_match == 'text/dns':
return self._get_zonefile(request, context, zone_id)
elif best_match == 'application/json':
return self._get_json(request, context, zone_id)
else:
raise exceptions.UnsupportedAccept(
'Accept must be text/dns or application/json')
def _get_json(self, request, context, zone_id):
"""'Normal' zone get"""
return DesignateAdapter.render(
'API_v2',
self.central_api.get_domain(context, zone_id),
request=request)
def _get_zonefile(self, request, context, zone_id):
"""Export zonefile"""
servers = self.central_api.get_domain_servers(context, zone_id)
domain = self.central_api.get_domain(context, zone_id)
criterion = {'domain_id': zone_id}
recordsets = self.central_api.find_recordsets(context, criterion)
records = []
for recordset in recordsets:
criterion = {
'domain_id': domain['id'],
'recordset_id': recordset['id']
}
raw_records = self.central_api.find_records(context, criterion)
for record in raw_records:
records.append({
'name': recordset['name'],
'type': recordset['type'],
'ttl': recordset['ttl'],
'data': record['data'],
})
return utils.render_template('bind9-zone.jinja2',
servers=servers,
domain=domain,
records=records)
@pecan.expose(template='json:', content_type='application/json')
def get_all(self, **params):
"""List Zones"""
@ -125,16 +77,7 @@ class ZonesController(rest.RestController):
request = pecan.request
response = pecan.response
context = request.environ['context']
if request.content_type == 'text/dns':
return self._post_zonefile(request, response, context)
elif request.content_type == 'application/json':
return self._post_json(request, response, context)
else:
raise exceptions.UnsupportedContentType(
'Content-type must be text/dns or application/json')
def _post_json(self, request, response, context):
"""'Normal' zone creation"""
zone = request.body_dict
# We need to check the zone type before validating the schema since if
@ -175,43 +118,6 @@ class ZonesController(rest.RestController):
return zone
def _post_zonefile(self, request, response, context):
"""Import Zone"""
try:
dnspython_zone = dnszone.from_text(
request.body,
# Don't relativize, otherwise we end up with '@' record names.
relativize=False,
# Dont check origin, we allow missing NS records (missing SOA
# records are taken care of in _create_zone).
check_origin=False)
domain = dnsutils.from_dnspython_zone(dnspython_zone)
domain.type = 'PRIMARY'
for rrset in list(domain.recordsets):
if rrset.type in ('NS', 'SOA'):
domain.recordsets.remove(rrset)
except dnszone.UnknownOrigin:
raise exceptions.BadRequest('The $ORIGIN statement is required and'
' must be the first statement in the'
' zonefile.')
except dnsexception.SyntaxError:
raise exceptions.BadRequest('Malformed zonefile.')
zone = self.central_api.create_domain(context, domain)
if zone['status'] == 'PENDING':
response.status_int = 202
else:
response.status_int = 201
zone = DesignateAdapter.render('API_v2', zone, request=request)
response.headers['Location'] = zone['links']['self']
return zone
@pecan.expose(template='json:', content_type='application/json')
@pecan.expose(template='json:', content_type='application/json-patch+json')
@utils.validate_uuid('zone_id')

View File

@ -41,7 +41,8 @@ class Request(pecan.core.Request):
except ValueError as valueError:
raise exceptions.InvalidJson(valueError.message)
else:
raise Exception('TODO: Unsupported Content Type')
raise exceptions.UnsupportedContentType(
'Content-type must be application/json')
__init__ = pecan.core.Pecan.__base__.__init__
if not ismethod(__init__) or 'request_cls' not in getargspec(__init__).args:

View File

@ -0,0 +1,81 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.P.
#
# 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 dns import zone as dnszone
from oslo.config import cfg
from designate.tests.test_api.test_admin import AdminApiTestCase
cfg.CONF.import_opt('enabled_extensions_admin', 'designate.api.admin',
group='service:api')
class AdminApiZoneImportExportTest(AdminApiTestCase):
def setUp(self):
self.config(enabled_extensions_admin=['zones'], group='service:api')
super(AdminApiZoneImportExportTest, self).setUp()
# Zone import/export
def test_missing_origin(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='noorigin')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_missing_soa(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='nosoa')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_malformed_zonefile(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='malformed')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_import_export(self):
self.policy({'default': '@'})
# Since v2 doesn't support getting records, import and export the
# fixture, making sure they're the same according to dnspython
post_response = self.client.post('/zones/import',
self.get_zonefile_fixture(),
headers={'Content-type': 'text/dns'})
get_response = self.client.get('/zones/export/%s' %
post_response.json['id'],
headers={'Accept': 'text/dns'})
exported_zonefile = get_response.body
imported = dnszone.from_text(self.get_zonefile_fixture())
exported = dnszone.from_text(exported_zonefile)
# Compare SOA emails, since zone comparison takes care of origin
imported_soa = imported.get_rdataset(imported.origin, 'SOA')
imported_email = imported_soa[0].rname.to_text()
exported_soa = exported.get_rdataset(exported.origin, 'SOA')
exported_email = exported_soa[0].rname.to_text()
self.assertEqual(imported_email, exported_email)
# Delete SOAs since they have, at the very least, different serials,
# and dnspython considers that to be not equal.
imported.delete_rdataset(imported.origin, 'SOA')
exported.delete_rdataset(exported.origin, 'SOA')
# Delete NS records, since they won't be the same
imported.delete_rdataset(imported.origin, 'NS')
exported.delete_rdataset(exported.origin, 'NS')
imported.delete_rdataset('delegation', 'NS')
self.assertEqual(imported, exported)

View File

@ -13,7 +13,6 @@
# 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 dns import zone as dnszone
from mock import patch
from oslo.config import cfg
from oslo import messaging
@ -234,11 +233,6 @@ class ApiV2ZonesTest(ApiV2TestCase):
self._assert_exception('domain_not_found', 404, self.client.get, url,
headers={'Accept': 'application/json'})
def test_get_zone_missing_accept(self):
url = '/zones/6e2146f3-87bc-4f47-adc5-4df0a5c78218'
self._assert_exception('bad_request', 400, self.client.get, url)
def test_get_zone_bad_accept(self):
url = '/zones/6e2146f3-87bc-4f47-adc5-4df0a5c78218'
@ -480,54 +474,6 @@ class ApiV2ZonesTest(ApiV2TestCase):
self._assert_exception('invalid_object', 400, self.client.patch_json,
'/zones/%s' % zone['id'], body)
# Zone import/export
def test_missing_origin(self):
fixture = self.get_zonefile_fixture(variant='noorigin')
self._assert_exception('bad_request', 400, self.client.post, '/zones',
fixture, headers={'Content-type': 'text/dns'})
def test_missing_soa(self):
fixture = self.get_zonefile_fixture(variant='nosoa')
self._assert_exception('bad_request', 400, self.client.post, '/zones',
fixture, headers={'Content-type': 'text/dns'})
def test_malformed_zonefile(self):
fixture = self.get_zonefile_fixture(variant='malformed')
self._assert_exception('bad_request', 400, self.client.post, '/zones',
fixture, headers={'Content-type': 'text/dns'})
def test_import_export(self):
# Since v2 doesn't support getting records, import and export the
# fixture, making sure they're the same according to dnspython
post_response = self.client.post('/zones',
self.get_zonefile_fixture(),
headers={'Content-type': 'text/dns'})
get_response = self.client.get('/zones/%s' %
post_response.json['id'],
headers={'Accept': 'text/dns'})
exported_zonefile = get_response.body
imported = dnszone.from_text(self.get_zonefile_fixture())
exported = dnszone.from_text(exported_zonefile)
# Compare SOA emails, since zone comparison takes care of origin
imported_soa = imported.get_rdataset(imported.origin, 'SOA')
imported_email = imported_soa[0].rname.to_text()
exported_soa = exported.get_rdataset(exported.origin, 'SOA')
exported_email = exported_soa[0].rname.to_text()
self.assertEqual(imported_email, exported_email)
# Delete SOAs since they have, at the very least, different serials,
# and dnspython considers that to be not equal.
imported.delete_rdataset(imported.origin, 'SOA')
exported.delete_rdataset(exported.origin, 'SOA')
# Delete NS records, since they won't be the same
imported.delete_rdataset(imported.origin, 'NS')
exported.delete_rdataset(exported.origin, 'NS')
imported.delete_rdataset('delegation', 'NS')
self.assertEqual(imported, exported)
# Metadata tests
def test_metadata_exists(self):
response = self.client.get('/zones/')

View File

@ -0,0 +1,114 @@
Zones
=====
Overview
--------
The zones extension can be used to import and export zonesfiles to designate.
*Note*: Zones is an extension and needs to be enabled before it can be used.
If Designate returns a 404 error, ensure that the following line has been
added to the designate.conf file::
enabled_extensions_admin = zones
Once this line has been added, restart the designate-api service.
Export Zone
-----------
.. http:get:: /admin/zones/export/(uuid:id)
**Example request:**
.. sourcecode:: http
GET /admin/zones/export/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: text/dns
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/dns
$ORIGIN example.com.
$TTL 42
example.com. IN SOA ns.designate.com. nsadmin.example.com. (
1394213803 ; serial
3600 ; refresh
600 ; retry
86400 ; expire
3600 ; minimum
)
example.com. IN NS ns.designate.com.
example.com. IN MX 10 mail.example.com.
ns.example.com. IN A 10.0.0.1
mail.example.com. IN A 10.0.0.2
:statuscode 200: Success
:statuscode 406: Not Acceptable
Notice how the SOA and NS records are replaced with the Designate server(s).
Import Zone
-----------
.. http:post:: /admin/zones/import
To import a zonefile, set the Content-type to **text/dns** . The
**zoneextractor.py** tool in the **contrib** folder can generate zonefiles
that are suitable for Designate (without any **$INCLUDE** statements for
example).
**Example request:**
.. sourcecode:: http
POST /admin/zones/import HTTP/1.1
Host: 127.0.0.1:9001
Content-type: text/dns
$ORIGIN example.com.
example.com. 42 IN SOA ns.example.com. nsadmin.example.com. 42 42 42 42 42
example.com. 42 IN NS ns.example.com.
example.com. 42 IN MX 10 mail.example.com.
ns.example.com. 42 IN A 10.0.0.1
mail.example.com. 42 IN A 10.0.0.2
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"email": "nsadmin@example.com",
"id": "6b78734a-aef1-45cd-9708-8eb3c2d26ff1",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/6b78734a-aef1-45cd-9708-8eb3c2d26ff1"
},
"name": "example.com.",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "d7accc2f8ce343318386886953f2fc6a",
"serial": 1404757531,
"ttl": "42",
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"version": 1,
"masters": [],
"type": "PRIMARY",
"transferred_at": null
}
:statuscode 201: Created
:statuscode 415: Unsupported Media Type
:statuscode 400: Bad request

View File

@ -291,106 +291,6 @@ Delete Zone
:statuscode 202: Accepted
Import Zone
-----------
.. http:post:: /zones
To import a zonefile, set the Content-type to **text/dns** . The
**zoneextractor.py** tool in the **contrib** folder can generate zonefiles
that are suitable for Designate (without any **$INCLUDE** statements for
example).
**Example request:**
.. sourcecode:: http
POST /v2/zones HTTP/1.1
Host: 127.0.0.1:9001
Content-type: text/dns
$ORIGIN example.com.
example.com. 42 IN SOA ns.example.com. nsadmin.example.com. 42 42 42 42 42
example.com. 42 IN NS ns.example.com.
example.com. 42 IN MX 10 mail.example.com.
ns.example.com. 42 IN A 10.0.0.1
mail.example.com. 42 IN A 10.0.0.2
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"email": "nsadmin@example.com",
"id": "6b78734a-aef1-45cd-9708-8eb3c2d26ff1",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/6b78734a-aef1-45cd-9708-8eb3c2d26ff1"
},
"name": "example.com.",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "d7accc2f8ce343318386886953f2fc6a",
"serial": 1404757531,
"ttl": "42",
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"version": 1,
"masters": [],
"type": "PRIMARY",
"transferred_at": null
}
:statuscode 201: Created
:statuscode 415: Unsupported Media Type
:statuscode 400: Bad request
Export Zone
-----------
.. http:get:: /zones/(uuid:id)
To export a zone in zonefile format, set the **Accept** header to **text/dns**.
**Example request**
.. sourcecode:: http
GET /v2/zones/6b78734a-aef1-45cd-9708-8eb3c2d26ff1 HTTP/1.1
Host: 127.0.0.1:9001
Accept: text/dns
**Example response**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/dns
$ORIGIN example.com.
$TTL 42
example.com. IN SOA ns.designate.com. nsadmin.example.com. (
1394213803 ; serial
3600 ; refresh
600 ; retry
86400 ; expire
3600 ; minimum
)
example.com. IN NS ns.designate.com.
example.com. IN MX 10 mail.example.com.
ns.example.com. IN A 10.0.0.1
mail.example.com. IN A 10.0.0.2
:statuscode 200: Success
:statuscode 406: Not Acceptable
Notice how the SOA and NS records are replaced with the Designate server(s).
Abandon Zone
------------

View File

@ -101,7 +101,8 @@ debug = False
#enable_api_admin = False
# Enabled Admin API extensions
# Can be one or more of : reports, quotas, counts, tenants
# Can be one or more of : reports, quotas, counts, tenants, zones
# zone import / export is in zones extension
#enabled_extensions_admin =
# Show the pecan HTML based debug interface (v2 only)

View File

@ -19,6 +19,9 @@
"use_low_ttl": "rule:admin",
"zone_import": "rule:admin",
"zone_export": "rule:admin",
"get_quotas": "rule:admin_or_owner",
"get_quota": "rule:admin_or_owner",
"set_quota": "rule:admin",

View File

@ -64,6 +64,7 @@ designate.api.v1.extensions =
designate.api.admin.extensions =
reports = designate.api.admin.controllers.extensions.reports:ReportsController
quotas = designate.api.admin.controllers.extensions.quotas:QuotasController
zones = designate.api.admin.controllers.extensions.zones:ZonesController
designate.storage =
sqlalchemy = designate.storage.impl_sqlalchemy:SQLAlchemyStorage