Domain Import/Export
Change-Id: Ic9e43eadc6aa2110ca89be071a3d6579f343be01 Implements: blueprint domain-import-export
This commit is contained in:
parent
ddfcd36f9e
commit
343e087e65
@ -14,6 +14,8 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import pecan
|
||||
import dns
|
||||
from designate import exceptions
|
||||
from designate import utils
|
||||
from designate import schema
|
||||
from designate.api.v2.controllers import rest
|
||||
@ -22,7 +24,6 @@ from designate.api.v2.views import zones as zones_view
|
||||
from designate.central import rpcapi as central_rpcapi
|
||||
from designate.openstack.common import log as logging
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
central_api = central_rpcapi.CentralAPI()
|
||||
|
||||
@ -34,18 +35,42 @@ class ZonesController(rest.RestController):
|
||||
|
||||
recordsets = recordsets.RecordSetsController()
|
||||
|
||||
@pecan.expose(template=None, content_type='text/dns')
|
||||
@pecan.expose(template='json:', content_type='application/json')
|
||||
def get_one(self, zone_id):
|
||||
""" Get Zone """
|
||||
request = pecan.request
|
||||
context = request.environ['context']
|
||||
|
||||
# TODO(kiall): Validate we have a sane UUID for zone_id
|
||||
|
||||
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(['text/dns',
|
||||
'application/json'])
|
||||
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 """
|
||||
zone = central_api.get_domain(context, zone_id)
|
||||
|
||||
return self._view.detail(context, request, zone)
|
||||
|
||||
def _get_zonefile(self, request, context, zone_id):
|
||||
""" Export zonefile """
|
||||
servers = central_api.get_domain_servers(context, zone_id)
|
||||
domain = central_api.get_domain(context, zone_id)
|
||||
records = central_api.find_records(context, zone_id)
|
||||
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 """
|
||||
@ -71,6 +96,16 @@ 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 """
|
||||
body = request.body_dict
|
||||
|
||||
# Validate the request conforms to the schema
|
||||
@ -96,6 +131,28 @@ class ZonesController(rest.RestController):
|
||||
# Prepare and return the response body
|
||||
return self._view.detail(context, request, zone)
|
||||
|
||||
def _post_zonefile(self, request, response, context):
|
||||
""" Import Zone """
|
||||
dnszone = self._parse_zonefile(request)
|
||||
# TODO(artom) This should probably be handled with transactions
|
||||
zone = self._create_zone(context, dnszone)
|
||||
|
||||
try:
|
||||
self._create_records(context, zone['id'], dnszone)
|
||||
|
||||
except exceptions.Base as e:
|
||||
central_api.delete_domain(context, zone['id'])
|
||||
raise e
|
||||
|
||||
if zone['status'] == 'PENDING':
|
||||
response.status_int = 202
|
||||
else:
|
||||
response.status_int = 201
|
||||
|
||||
response.headers['Location'] = self._view._get_resource_href(request,
|
||||
zone)
|
||||
return self._view.detail(context, request, zone)
|
||||
|
||||
@pecan.expose(template='json:', content_type='application/json')
|
||||
@pecan.expose(template='json:', content_type='application/json-patch+json')
|
||||
def patch_one(self, zone_id):
|
||||
@ -162,3 +219,81 @@ class ZonesController(rest.RestController):
|
||||
|
||||
# NOTE: This is a hack and a half.. But Pecan needs it.
|
||||
return ''
|
||||
|
||||
#TODO(artom) Methods below may be useful elsewhere, consider putting them
|
||||
# somewhere reusable.
|
||||
|
||||
def _create_zone(self, context, dnszone):
|
||||
""" Creates the initial zone """
|
||||
# dnspython never builds a zone with more than one SOA, even if we give
|
||||
# it a zonefile that contains more than one
|
||||
soa = dnszone.get_rdataset(dnszone.origin, 'SOA')
|
||||
if soa is None:
|
||||
raise exceptions.BadRequest('An SOA record is required')
|
||||
email = soa[0].rname.to_text().rstrip('.')
|
||||
email = email.replace('.', '@', 1)
|
||||
values = {
|
||||
'name': dnszone.origin.to_text(),
|
||||
'email': email,
|
||||
'ttl': str(soa.ttl)
|
||||
}
|
||||
return central_api.create_domain(context, values)
|
||||
|
||||
def _record2json(self, record_type, rdata):
|
||||
if record_type == 'MX':
|
||||
return {
|
||||
'type': record_type,
|
||||
'data': rdata.exchange.to_text(),
|
||||
'priority': str(rdata.preference)
|
||||
}
|
||||
elif record_type == 'SRV':
|
||||
return {
|
||||
'type': record_type,
|
||||
'data': '%s %s %s' % (str(rdata.weight), str(rdata.port),
|
||||
rdata.target.to_text()),
|
||||
'priority': str(rdata.priority)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': record_type,
|
||||
'data': rdata.to_text()
|
||||
}
|
||||
|
||||
def _create_records(self, context, zone_id, dnszone):
|
||||
""" Creates the records """
|
||||
for record_name in dnszone.nodes.keys():
|
||||
for rdataset in dnszone.nodes[record_name]:
|
||||
record_type = dns.rdatatype.to_text(rdataset.rdtype)
|
||||
for rdata in rdataset:
|
||||
if record_type == 'SOA':
|
||||
# Don't create SOA records
|
||||
pass
|
||||
elif record_type == 'NS' and record_name == dnszone.origin:
|
||||
# Don't create NS records for the domain, they've been
|
||||
# taken care of as servers
|
||||
pass
|
||||
else:
|
||||
# Everything else, including delegation NS, gets
|
||||
# created
|
||||
values = self._record2json(record_type, rdata)
|
||||
values['name'] = record_name.to_text()
|
||||
central_api.create_record(context, zone_id, values)
|
||||
|
||||
def _parse_zonefile(self, request):
|
||||
""" Parses a POSTed zonefile into a dnspython zone object """
|
||||
try:
|
||||
dnszone = dns.zone.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)
|
||||
except dns.zone.UnknownOrigin:
|
||||
raise exceptions.BadRequest('The $ORIGIN statement is required and'
|
||||
' must be the first statement in the'
|
||||
' zonefile.')
|
||||
except dns.exception.SyntaxError:
|
||||
raise exceptions.BadRequest('Malformed zonefile.')
|
||||
return dnszone
|
||||
|
@ -1,3 +1,4 @@
|
||||
$ORIGIN {{ domain.name }}
|
||||
$TTL {{ domain.ttl }}
|
||||
|
||||
{{ domain.name }} IN SOA {{ servers[0].name }} {{ domain.email | replace("@", ".") }}. (
|
||||
|
@ -17,6 +17,7 @@ import copy
|
||||
import unittest2
|
||||
import mox
|
||||
import nose
|
||||
import os
|
||||
from oslo.config import cfg
|
||||
from designate.openstack.common import log as logging
|
||||
from designate.openstack.common.notifier import test_notifier
|
||||
@ -212,6 +213,15 @@ class TestCase(unittest2.TestCase):
|
||||
|
||||
return _values
|
||||
|
||||
def get_zonefile_fixture(self, variant=None):
|
||||
if variant is None:
|
||||
path = 'example.com.zone'
|
||||
else:
|
||||
path = '%s_example.com.zone' % variant
|
||||
path = os.path.join(os.path.dirname(__file__), path)
|
||||
with open(path) as zonefile:
|
||||
return zonefile.read()
|
||||
|
||||
def create_quota(self, **kwargs):
|
||||
context = kwargs.pop('context', self.get_admin_context())
|
||||
fixture = kwargs.pop('fixture', 0)
|
||||
|
21
designate/tests/example.com.zone
Normal file
21
designate/tests/example.com.zone
Normal file
@ -0,0 +1,21 @@
|
||||
$ORIGIN example.com.
|
||||
example.com. 600 IN SOA ns1.example.com. nsadmin.example.com. (
|
||||
2013091101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
2419200 ; expire
|
||||
10800 ; minimum
|
||||
)
|
||||
ipv4.example.com. 600 IN A 192.0.0.1
|
||||
ipv6.example.com. 600 IN AAAA fd00::1
|
||||
cname.example.com. 600 IN CNAME example.com.
|
||||
example.com. 600 IN MX 5 192.0.0.2
|
||||
example.com. 600 IN MX 10 192.0.0.3
|
||||
_http._tcp.example.com. 600 IN SRV 10 0 80 192.0.0.4
|
||||
_http._tcp.example.com. 600 IN SRV 10 5 80 192.0.0.5
|
||||
example.com. 600 IN TXT "abc" "def"
|
||||
example.com. 600 IN SPF "v=spf1 mx a"
|
||||
example.com. 600 IN NS ns1.example.com.
|
||||
example.com. 600 IN NS ns2.example.com.
|
||||
delegation.example.com. 600 IN NS ns1.example.com.
|
||||
1.0.0.192.in-addr.arpa. 600 IN PTR ipv4.example.com.
|
28
designate/tests/malformed_example.com.zone
Normal file
28
designate/tests/malformed_example.com.zone
Normal file
@ -0,0 +1,28 @@
|
||||
$ORIGIN example.com.
|
||||
example.com. 600 IN SOA ns1.example.com. nsadmin.example.com. (
|
||||
2013091101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
2419200 ; expire
|
||||
10800 ; minimum
|
||||
)
|
||||
ipv4.example.com. 600 IN A 192.0.0.1
|
||||
ipv6.example.com. 600 IN AAAA fd00::1
|
||||
|
||||
_))
|
||||
> *\ _~
|
||||
`;'\\__-' \_
|
||||
| ) _ \ \
|
||||
/ / `` w w
|
||||
w w
|
||||
|
||||
cname.example.com. 600 IN CNAME example.com.
|
||||
example.com. 600 IN MX 5 192.0.0.2
|
||||
example.com. 600 IN MX 10 192.0.0.3
|
||||
_http._tcp.example.com. 600 IN SRV 10 0 80 192.0.0.4
|
||||
_http._tcp.example.com. 600 IN SRV 10 5 80 192.0.0.5
|
||||
example.com. 600 IN TXT "abc" "def"
|
||||
example.com. 600 IN SPF "v=spf1 mx a"
|
||||
example.com. 600 IN NS ns1.example.com.
|
||||
example.com. 600 IN NS ns2.example.com.
|
||||
1.0.0.192.in-addr.arpa. 600 IN PTR ipv4.example.com.
|
19
designate/tests/noorigin_example.com.zone
Normal file
19
designate/tests/noorigin_example.com.zone
Normal file
@ -0,0 +1,19 @@
|
||||
example.com. 600 IN SOA ns1.example.com. nsadmin.example.com. (
|
||||
2013091101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
2419200 ; expire
|
||||
10800 ; minimum
|
||||
)
|
||||
ipv4.example.com. 600 IN A 192.0.0.1
|
||||
ipv6.example.com. 600 IN AAAA fd00::1
|
||||
cname.example.com. 600 IN CNAME example.com.
|
||||
example.com. 600 IN MX 5 192.0.0.2
|
||||
example.com. 600 IN MX 10 192.0.0.3
|
||||
_http._tcp.example.com. 600 IN SRV 10 0 80 192.0.0.4
|
||||
_http._tcp.example.com. 600 IN SRV 10 5 80 192.0.0.5
|
||||
example.com. 600 IN TXT "abc" "def"
|
||||
example.com. 600 IN SPF "v=spf1 mx a"
|
||||
example.com. 600 IN NS ns1.example.com.
|
||||
example.com. 600 IN NS ns2.example.com.
|
||||
1.0.0.192.in-addr.arpa. 600 IN PTR ipv4.example.com.
|
13
designate/tests/nosoa_example.com.zone
Normal file
13
designate/tests/nosoa_example.com.zone
Normal file
@ -0,0 +1,13 @@
|
||||
$ORIGIN example.com.
|
||||
ipv4.example.com. 600 IN A 192.0.0.1
|
||||
ipv6.example.com. 600 IN AAAA fd00::1
|
||||
cname.example.com. 600 IN CNAME example.com.
|
||||
example.com. 600 IN MX 5 192.0.0.2
|
||||
example.com. 600 IN MX 10 192.0.0.3
|
||||
_http._tcp.example.com. 600 IN SRV 10 0 80 192.0.0.4
|
||||
_http._tcp.example.com. 600 IN SRV 10 5 80 192.0.0.5
|
||||
example.com. 600 IN TXT "abc" "def"
|
||||
example.com. 600 IN SPF "v=spf1 mx a"
|
||||
example.com. 600 IN NS ns1.example.com.
|
||||
example.com. 600 IN NS ns2.example.com.
|
||||
1.0.0.192.in-addr.arpa. 600 IN PTR ipv4.example.com.
|
@ -13,6 +13,7 @@
|
||||
# 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 designate import exceptions
|
||||
from designate.central import service as central_service
|
||||
@ -29,6 +30,20 @@ class ApiV2ZonesTest(ApiV2TestCase):
|
||||
# Create a server
|
||||
self.create_server()
|
||||
|
||||
def test_missing_accept(self):
|
||||
self.client.get('/zones/123', status=400)
|
||||
|
||||
def test_bad_accept(self):
|
||||
self.client.get('/zones/123', headers={'Accept': 'test/goat'},
|
||||
status=406)
|
||||
|
||||
def test_missing_content_type(self):
|
||||
self.client.post('/zones', status=415)
|
||||
|
||||
def test_bad_content_type(self):
|
||||
self.client.post('/zones', headers={'Content-type': 'test/goat'},
|
||||
status=415)
|
||||
|
||||
def test_create_zone(self):
|
||||
# Create a zone
|
||||
fixture = self.get_domain_fixture(0)
|
||||
@ -128,7 +143,8 @@ class ApiV2ZonesTest(ApiV2TestCase):
|
||||
# Create a zone
|
||||
zone = self.create_domain()
|
||||
|
||||
response = self.client.get('/zones/%s' % zone['id'])
|
||||
response = self.client.get('/zones/%s' % zone['id'],
|
||||
headers=[('Accept', 'application/json')])
|
||||
|
||||
# Check the headers are what we expect
|
||||
self.assertEqual(200, response.status_int)
|
||||
@ -151,12 +167,14 @@ class ApiV2ZonesTest(ApiV2TestCase):
|
||||
side_effect=rpc_common.Timeout())
|
||||
def test_get_zone_timeout(self, _):
|
||||
self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980',
|
||||
headers={'Accept': 'application/json'},
|
||||
status=504)
|
||||
|
||||
@patch.object(central_service.Service, 'get_domain',
|
||||
side_effect=exceptions.DomainNotFound())
|
||||
def test_get_zone_missing(self, _):
|
||||
self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980',
|
||||
headers={'Accept': 'application/json'},
|
||||
status=404)
|
||||
|
||||
def test_get_zone_invalid_id(self):
|
||||
@ -164,13 +182,18 @@ class ApiV2ZonesTest(ApiV2TestCase):
|
||||
|
||||
# The letter "G" is not valid in a UUID
|
||||
self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff9GG',
|
||||
headers={'Accept': 'application/json'},
|
||||
status=404)
|
||||
|
||||
# Badly formed UUID
|
||||
self.client.get('/zones/2fdadfb1cf964259ac6bbb7b6d2ff9GG', status=404)
|
||||
self.client.get('/zones/2fdadfb1cf964259ac6bbb7b6d2ff9GG',
|
||||
headers={'Accept': 'application/json'},
|
||||
status=404)
|
||||
|
||||
# Integer
|
||||
self.client.get('/zones/12345', status=404)
|
||||
self.client.get('/zones/12345',
|
||||
headers={'Accept': 'application/json'},
|
||||
status=404)
|
||||
|
||||
def test_update_zone(self):
|
||||
# Create a zone
|
||||
@ -296,3 +319,46 @@ class ApiV2ZonesTest(ApiV2TestCase):
|
||||
|
||||
# Integer
|
||||
self.client.delete('/zones/12345', status=404)
|
||||
|
||||
# Zone import/export
|
||||
def test_missing_origin(self):
|
||||
self.client.post('/zones',
|
||||
self.get_zonefile_fixture(variant='noorigin'),
|
||||
headers={'Content-type': 'text/dns'}, status=400)
|
||||
|
||||
def test_missing_soa(self):
|
||||
self.client.post('/zones',
|
||||
self.get_zonefile_fixture(variant='nosoa'),
|
||||
headers={'Content-type': 'text/dns'}, status=400)
|
||||
|
||||
def test_malformed_zonefile(self):
|
||||
self.client.post('/zones',
|
||||
self.get_zonefile_fixture(variant='malformed'),
|
||||
headers={'Content-type': 'text/dns'}, status=400)
|
||||
|
||||
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['zone']['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 non-delegation NS, since they won't be the same
|
||||
imported.delete_rdataset(imported.origin, 'NS')
|
||||
exported.delete_rdataset(exported.origin, 'NS')
|
||||
self.assertEqual(imported, exported)
|
||||
|
@ -17,3 +17,4 @@ SQLAlchemy>=0.7.8,<=0.7.99
|
||||
sqlalchemy-migrate>=0.7.2
|
||||
stevedore>=0.10
|
||||
WebOb>=1.2.3,<1.3
|
||||
dnspython>=1.9.4
|
||||
|
Loading…
Reference in New Issue
Block a user