diff --git a/libcloud/dns/drivers/route53.py b/libcloud/dns/drivers/route53.py index 275735f..09e2703 100644 --- a/libcloud/dns/drivers/route53.py +++ b/libcloud/dns/drivers/route53.py @@ -20,11 +20,13 @@ __all__ = [ import base64 import hmac import datetime +import uuid +from libcloud.utils.py3 import httplib from hashlib import sha1 from xml.etree import ElementTree as ET -from libcloud.utils.py3 import b +from libcloud.utils.py3 import b, urlencode from libcloud.utils.xml import findtext, findall, fixxpath from libcloud.dns.types import Provider, RecordType @@ -42,17 +44,8 @@ API_ROOT = '/%s/' % (API_VERSION) NAMESPACE = 'https://%s/doc%s' % (API_HOST, API_ROOT) -class Route53Error(LibcloudError): - def __init__(self, code, errors): - self.code = code - self.errors = errors or [] - - def __str__(self): - return 'Errors: %s' % (', '.join(self.errors)) - - def __repr__(self): - return('' % - (self.code, len(self.errors))) +class InvalidChangeBatch(LibcloudError): + pass class Route53DNSResponse(AWSBaseResponse): @@ -62,7 +55,8 @@ class Route53DNSResponse(AWSBaseResponse): def success(self): return self.status in [httplib.OK, httplib.CREATED, httplib.ACCEPTED] - def error(self): + def parse_error(self): + context = self.connection.context status = int(self.status) if status == 403: @@ -71,19 +65,29 @@ class Route53DNSResponse(AWSBaseResponse): else: raise InvalidCredsError(self.body) - elif status == 400: - context = self.connection.context - messages = [] - if context['InvalidChangeBatch']['Messages']: - for message in context['InvalidChangeBatch']['Messages']: - messages.append(message['Message']) + try: + body = ET.XML(self.body) + except Exception: + raise MalformedResponseError("Failed to parse XML", + body=self.body, driver=self.driver) - raise Route53Error('InvalidChangeBatch message(s): %s ', - messages) + errs = findall(element=body, xpath='Error', namespace=NAMESPACE) + if errs: + t, code, message = errs[0].getchildren() + if code.text == "NoSuchHostedZone": + zone_id = context.get('zone_id', None) + raise ZoneDoesNotExistError(value=message.text, driver=self, + zone_id=zone_id) + elif code.text == "InvalidChangeBatch": + raise InvalidChangeBatch(value=message.text) + return message.text + + return self.body class Route53Connection(ConnectionUserAndKey): host = API_HOST + responseCls = Route53DNSResponse def pre_connect_hook(self, params, headers): time_string = datetime.datetime.utcnow() \ @@ -123,36 +127,133 @@ class Route53DNSDriver(DNSDriver): RecordType.AAAA: 'AAAA', RecordType.CNAME: 'CNAME', RecordType.TXT: 'TXT', - RecordType.SRV: 'SRV' + RecordType.SRV: 'SRV', + RecordType.PTR: 'PTR', + RecordType.SOA: 'SOA', + RecordType.SPF: 'SPF', + RecordType.TXT: 'TXT', } def list_zones(self): - data = ET.XML(self.connection.request(API_ROOT + 'hostedzone').object) + data = self.connection.request(API_ROOT + 'hostedzone').object zones = self._to_zones(data=data) return zones def list_records(self, zone): - data = ET.XML(self.connection.request(API_ROOT + 'hostedzone/' - + zone.id + '/rrset').object) + self.connection.set_context({'zone_id': zone.id}) + uri = API_ROOT + 'hostedzone/' + zone.id + '/rrset' + data = self.connection.request(uri).object records = self._to_records(data=data, zone=zone) return records def get_zone(self, zone_id): - data = ET.XML(self.connection.request(API_ROOT + 'hostedzone/' - + zone_id).object) - zone = self._to_zone(elem=findall(element=data, xpath='HostedZone', - namespace=NAMESPACE)[0]) - return zone + self.connection.set_context({'zone_id': zone_id}) + uri = API_ROOT + 'hostedzone/' + zone.id + data = self.connection.request(uri).object + elem = findall(element=data, xpath='HostedZone', namespace=NAMESPACE) + return self._to_zone(elem) def get_record(self, zone_id, record_id): zone = self.get_zone(zone_id=zone_id) - data = ET.XML(self.connection.request(API_ROOT + 'hostedzone/' - + zone_id + '/rrset?maxitems=1&name=' + record_id) - .object) + record_type, name = record_id.split(":", 1) + self.connection.set_context({'zone_id': zone_id}) + params = urlencode({'name': name, 'type': record_type}) + uri = API_ROOT + 'hostedzone/' + zone_id + '/rrset?' + params + data = self.connection.request(uri).object + + record = self._to_records(data=data, zone=zone)[0] + + # A cute aspect of the /rrset filters is that they are more pagination + # hints than filters!! + # So will return a result even if its not what you asked for. + record_type_num = self._string_to_record_type(record_type) + if record.name != name or record.type != record_type_num: + raise RecordDoesNotExistError(value='', driver=self, + record_id=record_id) - record = self._to_records(data=data, zone=zone) return record + def create_zone(self, domain, type='master', ttl=None, extra=None): + zone = ET.Element("CreateHostedZoneRequest", {'xmlns': NAMESPACE}) + ET.SubElement(zone, "Name").text = domain + ET.SubElement(zone, "CallerReference").text = str(uuid.uuid4()) + if extra and "Comment" in extra: + hzg = ET.SubElement(zone, "HostedZoneConfig") + ET.SubElement(hzg, "Comment").text = extra['Comment'] + + uri = API_ROOT+'hostedzone' + data = ET.tostring(zone) + rsp = self.connection.request(uri, method="POST", data=data).object + + elem = findall(element=rsp, xpath='HostedZone', namespace=NAMESPACE)[0] + return self._to_zone(elem=elem) + + def delete_zone(self, zone, ex_delete_records=False): + if ex_delete_records: + self.ex_clear_zone() + uri = API_ROOT+'hostedzone/%s' % zone.id + response = self.connection.request(uri, method="DELETE").object + return True + + def ex_clear_zone(self, zone): + deletions = [] + for r in zone.list_records(): + if r.type in (RecordType.NS, RecordType.SOA): + continue + deletions.append(("DELETE", r.name, r.type, r.data, r.extra)) + if deletions: + self._post_changeset(zone, deletions) + + def create_record(self, name, zone, type, data, extra=None): + batch = [("CREATE", name, type, data, extra)] + self._post_changeset(zone, batch) + id = ":".join((self.RECORD_TYPE_MAP[type], name)) + return Record(id=id, name=name, type=type, data=data, zone=zone, + driver=self, extra=extra) + + def update_record(self, record, name, type, data, extra): + batch = [ + ("DELETE", record.name, record.type, record.data, record.extra), + ("CREATE", name, type, data, extra)] + self._post_changeset(record.zone, batch) + id = ":".join((self.RECORD_TYPE_MAP[type], name)) + return Record(id=id, name=name, type=type, data=data, zone=record.zone, + driver=self, extra=extra) + + def delete_record(self, record): + try: + r = record + batch = [("DELETE", r.name, r.type, r.data, r.extra)] + self._post_changeset(record.zone, batch) + except InvalidChangeBatch: + raise RecordDoesNotExistError(value='', driver=self, + record_id=r.id) + return True + + def _post_changeset(self, zone, changes_list): + attrs = {'xmlns': NAMESPACE} + changeset = ET.Element("ChangeResourceRecordSetsRequest", attrs) + batch = ET.SubElement(changeset, "ChangeBatch") + changes = ET.SubElement(batch, "Changes") + + for action, name, type_, data, extra in changes_list: + change = ET.SubElement(changes, "Change") + ET.SubElement(change, "Action").text = action + + rrs = ET.SubElement(change, "ResourceRecordSet") + ET.SubElement(rrs, "Name").text = name + "." + zone.domain + ET.SubElement(rrs, "Type").text = self.RECORD_TYPE_MAP[type_] + ET.SubElement(rrs, "TTL").text = extra.get("ttl", "0") + + rrecs = ET.SubElement(rrs, "ResourceRecords") + rrec = ET.SubElement(rrecs, "ResourceRecord") + ET.SubElement(rrec, "Value").text = data + + uri = API_ROOT+'hostedzone/'+zone.id+'/rrset' + data = ET.tostring(changeset) + self.connection.set_context({'zone_id': zone.id}) + rsp = self.connection.request(uri, method="POST", data=data).object + def _to_zones(self, data): zones = [] for element in data.findall(fixxpath(xpath='HostedZones/HostedZone', @@ -190,6 +291,9 @@ class Route53DNSDriver(DNSDriver): def _to_record(self, elem, zone): name = findtext(element=elem, xpath='Name', namespace=NAMESPACE) + assert name.endswith(zone.domain) + name = name[:-len(zone.domain)-1] + type = self._string_to_record_type(findtext(element=elem, xpath='Type', namespace=NAMESPACE)) ttl = findtext(element=elem, xpath='TTL', namespace=NAMESPACE) @@ -202,6 +306,8 @@ class Route53DNSDriver(DNSDriver): namespace=NAMESPACE) extra = {'ttl': ttl} - record = Record(id=name, name=name, type=type, data=data, zone=zone, + + id = ":".join((self.RECORD_TYPE_MAP[type], name)) + record = Record(id=id, name=name, type=type, data=data, zone=zone, driver=self, extra=extra) return record diff --git a/libcloud/dns/providers.py b/libcloud/dns/providers.py index 0f41168..6376269 100644 --- a/libcloud/dns/providers.py +++ b/libcloud/dns/providers.py @@ -30,6 +30,8 @@ DRIVERS = { ('libcloud.dns.drivers.rackspace', 'RackspaceUKDNSDriver'), Provider.HOSTVIRTUAL: ('libcloud.dns.drivers.hostvirtual', 'HostVirtualDNSDriver'), + Provider.ROUTE53: + ('libcloud.dns.drivers.route53', 'Route53DNSDriver'), } diff --git a/libcloud/test/dns/fixtures/route53/create_zone.xml b/libcloud/test/dns/fixtures/route53/create_zone.xml new file mode 100644 index 0000000..7a7117f --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/create_zone.xml @@ -0,0 +1,20 @@ + + + + /hostedzone/47234 + t.com + some unique reference + + some comment + + 0 + + + + ns1.example.com + ns2.example.com + ns3.example.com + ns4.example.com + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/route53/get_zone.xml b/libcloud/test/dns/fixtures/route53/get_zone.xml new file mode 100644 index 0000000..3a1ffbe --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/get_zone.xml @@ -0,0 +1,21 @@ + + + + + /hostedzone/47234 + t.com + some unique reference + + some comment + + 0 + + + + ns1.example.com + ns2.example.com + ns3.example.com + ns4.example.com + + + diff --git a/libcloud/test/dns/fixtures/route53/invalid_change_batch.xml b/libcloud/test/dns/fixtures/route53/invalid_change_batch.xml new file mode 100644 index 0000000..36d7ea1 --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/invalid_change_batch.xml @@ -0,0 +1,9 @@ + + + + Sender + InvalidChangeBatch + Invalid change + + 376c64a6-6194-11e1-847f-ddaa49e4c811 + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/route53/list_records.xml b/libcloud/test/dns/fixtures/route53/list_records.xml new file mode 100644 index 0000000..039272a --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/list_records.xml @@ -0,0 +1,40 @@ + + + + + + wibble.t.com + CNAME + 86400 + + + t.com + + + + + + www.t.com + A + 86400 + + + 208.111.35.173 + + + + + + blahblah.t.com + A + 86400 + + + 208.111.35.173 + + + + + + + diff --git a/libcloud/test/dns/fixtures/route53/list_zones.xml b/libcloud/test/dns/fixtures/route53/list_zones.xml new file mode 100644 index 0000000..e0da2cf --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/list_zones.xml @@ -0,0 +1,54 @@ + + + + + /hostedzone/47234 + t.com + unique description + + some comment + + 0 + + + + /hostedzone/48170 + newbug.net + unique description + + some comment + + 0 + + + + /hostedzone/48017 + newblah.com + unique description + + some comment + + 0 + + + + /hostedzone/47288 + fromapi.com + unique description + + some comment + + 0 + + + + /hostedzone/48008 + blahnew.com + unique description + + some comment + + 0 + + + diff --git a/libcloud/test/dns/fixtures/route53/record_does_not_exist.xml b/libcloud/test/dns/fixtures/route53/record_does_not_exist.xml new file mode 100644 index 0000000..6a767d1 --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/record_does_not_exist.xml @@ -0,0 +1,17 @@ + + + + + + definitely.not.what.you.askedfor.t.com + CNAME + 86400 + + + t.com + + + + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/route53/zone_does_not_exist.xml b/libcloud/test/dns/fixtures/route53/zone_does_not_exist.xml new file mode 100644 index 0000000..6fba739 --- /dev/null +++ b/libcloud/test/dns/fixtures/route53/zone_does_not_exist.xml @@ -0,0 +1,9 @@ + + + + Sender + NoSuchHostedZone + No hosted zone found with ID: 47234 + + 376c64a6-6194-11e1-847f-ddaa49e4c811 + \ No newline at end of file diff --git a/libcloud/test/dns/test_route53.py b/libcloud/test/dns/test_route53.py new file mode 100644 index 0000000..ca3035b --- /dev/null +++ b/libcloud/test/dns/test_route53.py @@ -0,0 +1,255 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 sys +import unittest + +from libcloud.utils.py3 import httplib + +from libcloud.dns.types import RecordType, ZoneDoesNotExistError +from libcloud.dns.types import RecordDoesNotExistError +from libcloud.dns.drivers.route53 import Route53DNSDriver +from libcloud.test import MockHttp +from libcloud.test.file_fixtures import DNSFileFixtures +from libcloud.test.secrets import DNS_PARAMS_ROUTE53 + + +class Route53Tests(unittest.TestCase): + def setUp(self): + Route53DNSDriver.connectionCls.conn_classes = ( + None, Route53MockHttp) + Route53MockHttp.type = None + self.driver = Route53DNSDriver(*DNS_PARAMS_ROUTE53) + + def test_list_record_types(self): + record_types = self.driver.list_record_types() + self.assertEqual(len(record_types), 10) + self.assertTrue(RecordType.A in record_types) + + def test_list_zones(self): + zones = self.driver.list_zones() + self.assertEqual(len(zones), 5) + + zone = zones[0] + self.assertEqual(zone.id, '47234') + self.assertEqual(zone.type, 'master') + self.assertEqual(zone.domain, 't.com') + + def test_list_records(self): + zone = self.driver.list_zones()[0] + records = self.driver.list_records(zone=zone) + self.assertEqual(len(records), 3) + + record = records[1] + self.assertEqual(record.name, 'www') + self.assertEqual(record.id, 'A:www') + self.assertEqual(record.type, RecordType.A) + self.assertEqual(record.data, '208.111.35.173') + + def test_get_zone(self): + zone = self.driver.get_zone(zone_id='47234') + self.assertEqual(zone.id, '47234') + self.assertEqual(zone.type, 'master') + self.assertEqual(zone.domain, 't.com') + + def test_get_record(self): + r = self.driver.get_record(zone_id='47234', + record_id='CNAME:wibble') + self.assertEqual(record.name, 'wibble') + self.assertEqual(record.type, RecordType.CNAME) + self.assertEqual(record.data, 't.com') + + def test_list_records_zone_does_not_exist(self): + zone = self.driver.list_zones()[0] + + Route53MockHttp.type = 'ZONE_DOES_NOT_EXIST' + + try: + self.driver.list_records(zone=zone) + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, zone.id) + else: + self.fail('Exception was not thrown') + + def test_get_zone_does_not_exist(self): + Route53MockHttp.type = 'ZONE_DOES_NOT_EXIST' + + try: + self.driver.get_zone(zone_id='47234') + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, '47234') + else: + self.fail('Exception was not thrown') + + def test_get_record_zone_does_not_exist(self): + Route53MockHttp.type = 'ZONE_DOES_NOT_EXIST' + + try: + self.driver.get_record(zone_id='4444', record_id='28536') + except ZoneDoesNotExistError: + pass + else: + self.fail('Exception was not thrown') + + def test_get_record_record_does_not_exist(self): + Route53MockHttp.type = 'RECORD_DOES_NOT_EXIST' + + rid = 'CNAME:doesnotexist.t.com' + try: + self.driver.get_record(zone_id='47234', + record_id=rid) + except RecordDoesNotExistError: + pass + else: + self.fail('Exception was not thrown') + + def test_create_zone(self): + zone = self.driver.create_zone(domain='t.com', type='master', + ttl=None, extra=None) + self.assertEqual(zone.id, '47234') + self.assertEqual(zone.domain, 't.com') + + def test_create_record(self): + zone = self.driver.list_zones()[0] + record = self.driver.create_record( + name='www', zone=zone, + type=RecordType.A, data='127.0.0.1', + extra={'ttl': 0} + ) + + self.assertEqual(record.id, 'A:www') + self.assertEqual(record.name, 'www') + self.assertEqual(record.zone, zone) + self.assertEqual(record.type, RecordType.A) + self.assertEqual(record.data, '127.0.0.1') + + def test_update_record(self): + zone = self.driver.list_zones()[0] + record = self.driver.list_records(zone=zone)[1] + + parms = { + 'record': record, + 'name': 'www', + 'type': RecordType.A, + 'data': '::1', + 'extra': {'ttle': 0}} + updated_record = self.driver.update_record(**params) + + self.assertEqual(record.data, '208.111.35.173') + + self.assertEqual(updated_record.id, 'AAAA:www') + self.assertEqual(updated_record.name, 'www') + self.assertEqual(updated_record.zone, record.zone) + self.assertEqual(updated_record.type, RecordType.AAAA) + self.assertEqual(updated_record.data, '::1') + + def test_delete_zone(self): + zone = self.driver.list_zones()[0] + status = self.driver.delete_zone(zone=zone) + self.assertTrue(status) + + def test_delete_zone_does_not_exist(self): + zone = self.driver.list_zones()[0] + + Route53MockHttp.type = 'ZONE_DOES_NOT_EXIST' + + try: + self.driver.delete_zone(zone=zone) + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, zone.id) + else: + self.fail('Exception was not thrown') + + def test_delete_record(self): + zone = self.driver.list_zones()[0] + record = self.driver.list_records(zone=zone)[0] + status = self.driver.delete_record(record=record) + self.assertTrue(status) + + def test_delete_record_does_not_exist(self): + zone = self.driver.list_zones()[0] + record = self.driver.list_records(zone=zone)[0] + Route53MockHttp.type = 'RECORD_DOES_NOT_EXIST' + try: + self.driver.delete_record(record=record) + except RecordDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.record_id, record.id) + else: + self.fail('Exception was not thrown') + + +class Route53MockHttp(MockHttp): + fixtures = DNSFileFixtures('route53') + + def _2012_02_29_hostedzone_47234(self, method, url, body, headers): + body = self.fixtures.load('get_zone.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _2012_02_29_hostedzone(self, method, url, body, headers): + #print method, url, body, headers + if method == "POST": + body = self.fixtures.load("create_zone.xml") + return (httplib.CREATED, body, {}, httplib.responses[httplib.OK]) + body = self.fixtures.load('list_zones.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _2012_02_29_hostedzone_47234_rrset(self, method, url, body, headers): + body = self.fixtures.load('list_records.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _2012_02_29_hostedzone_47234_rrset_ZONE_DOES_NOT_EXIST(self, method, + url, body, headers): + body = self.fixtures.load('zone_does_not_exist.xml') + return (httplib.NOT_FOUND, body, + {}, httplib.responses[httplib.NOT_FOUND]) + + def _2012_02_29_hostedzone_4444_ZONE_DOES_NOT_EXIST(self, method, + url, body, headers): + body = self.fixtures.load('zone_does_not_exist.xml') + return (httplib.NOT_FOUND, body, + {}, httplib.responses[httplib.NOT_FOUND]) + + def _2012_02_29_hostedzone_47234_ZONE_DOES_NOT_EXIST(self, method, + url, body, headers): + body = self.fixtures.load('zone_does_not_exist.xml') + return (httplib.NOT_FOUND, body, + {}, httplib.responses[httplib.NOT_FOUND]) + + def _2012_02_29_hostedzone_47234_rrset_ZONE_DOES_NOT_EXIST(self, method, + url, body, headers): + body = self.fixtures.load('zone_does_not_exist.xml') + return (httplib.NOT_FOUND, body, + {}, httplib.responses[httplib.NOT_FOUND]) + + def _2012_02_29_hostedzone_47234_rrset_RECORD_DOES_NOT_EXIST(self, method, + url, body, headers): + if method == "POST": + body = self.fixtures.load('invalid_change_batch.xml') + return (httplib.BAD_REQUEST, body, {}, httplib.responses[httplib.BAD_REQUEST]) + body = self.fixtures.load('record_does_not_exist.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _2012_02_29_hostedzone_47234_RECORD_DOES_NOT_EXIST(self, method, + url, body, headers): + body = self.fixtures.load('get_zone.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + +if __name__ == '__main__': + sys.exit(unittest.main()) diff --git a/libcloud/test/secrets.py-dist b/libcloud/test/secrets.py-dist index 665b049..64f739b 100644 --- a/libcloud/test/secrets.py-dist +++ b/libcloud/test/secrets.py-dist @@ -52,3 +52,4 @@ DNS_PARAMS_LINODE = ('user', 'key') DNS_PARAMS_ZERIGO = ('email', 'api token') DNS_PARAMS_RACKSPACE = ('user', 'key') DNS_PARAMS_HOSTVIRTUAL = ('key',) +DNS_PARAMS_ROUTE53 = ('access_id', 'secret')