diff --git a/designate/api/v2/controllers/zones.py b/designate/api/v2/controllers/zones.py index 4461e4393..4a460cc24 100644 --- a/designate/api/v2/controllers/zones.py +++ b/designate/api/v2/controllers/zones.py @@ -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 diff --git a/designate/resources/templates/bind9-zone.jinja2 b/designate/resources/templates/bind9-zone.jinja2 index 383139b18..25966509c 100644 --- a/designate/resources/templates/bind9-zone.jinja2 +++ b/designate/resources/templates/bind9-zone.jinja2 @@ -1,3 +1,4 @@ +$ORIGIN {{ domain.name }} $TTL {{ domain.ttl }} {{ domain.name }} IN SOA {{ servers[0].name }} {{ domain.email | replace("@", ".") }}. ( diff --git a/designate/tests/__init__.py b/designate/tests/__init__.py index ee4dd0ab5..f55082fcd 100644 --- a/designate/tests/__init__.py +++ b/designate/tests/__init__.py @@ -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) diff --git a/designate/tests/example.com.zone b/designate/tests/example.com.zone new file mode 100644 index 000000000..7ad788937 --- /dev/null +++ b/designate/tests/example.com.zone @@ -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. diff --git a/designate/tests/malformed_example.com.zone b/designate/tests/malformed_example.com.zone new file mode 100644 index 000000000..40ff131e9 --- /dev/null +++ b/designate/tests/malformed_example.com.zone @@ -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. diff --git a/designate/tests/noorigin_example.com.zone b/designate/tests/noorigin_example.com.zone new file mode 100644 index 000000000..9d171ebb5 --- /dev/null +++ b/designate/tests/noorigin_example.com.zone @@ -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. diff --git a/designate/tests/nosoa_example.com.zone b/designate/tests/nosoa_example.com.zone new file mode 100644 index 000000000..e04e7b750 --- /dev/null +++ b/designate/tests/nosoa_example.com.zone @@ -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. diff --git a/designate/tests/test_api/test_v2/test_zones.py b/designate/tests/test_api/test_v2/test_zones.py index 241e32fd7..9bfde4b93 100644 --- a/designate/tests/test_api/test_v2/test_zones.py +++ b/designate/tests/test_api/test_v2/test_zones.py @@ -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) diff --git a/requirements.txt b/requirements.txt index ad7f2d6a8..375564545 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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