Domain Import/Export

Change-Id: Ic9e43eadc6aa2110ca89be071a3d6579f343be01
Implements: blueprint domain-import-export
This commit is contained in:
Artom Lifshitz 2013-09-27 17:05:33 -04:00
parent ddfcd36f9e
commit 343e087e65
9 changed files with 301 additions and 7 deletions

View File

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

View File

@ -1,3 +1,4 @@
$ORIGIN {{ domain.name }}
$TTL {{ domain.ttl }}
{{ domain.name }} IN SOA {{ servers[0].name }} {{ domain.email | replace("@", ".") }}. (

View File

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

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

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

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

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

View File

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

View File

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