From 869ef311d90458c2e18c4ce3726d12d81a231207 Mon Sep 17 00:00:00 2001 From: oltjano Date: Tue, 23 Jun 2015 18:44:39 +0200 Subject: [PATCH] [LIBCLOUD-747] Add Zonomi DNS provider implementation --- docs/dns/drivers/zonomi.rst | 25 ++ .../examples/dns/zonomi/instantiate_driver.py | 5 + libcloud/common/zonomi.py | 150 +++++++ libcloud/dns/drivers/zonomi.py | 349 ++++++++++++++++ libcloud/dns/providers.py | 2 + libcloud/dns/types.py | 1 + .../fixtures/zonomi/converted_to_master.xml | 6 + .../fixtures/zonomi/converted_to_slave.xml | 6 + .../dns/fixtures/zonomi/couldnt_convert.xml | 2 + .../dns/fixtures/zonomi/create_record.xml | 15 + .../zonomi/create_record_already_exists.xml | 15 + .../test/dns/fixtures/zonomi/create_zone.xml | 6 + .../zonomi/create_zone_already_exists.xml | 2 + .../dns/fixtures/zonomi/delete_record.xml | 15 + .../zonomi/delete_record_does_not_exist.xml | 11 + .../test/dns/fixtures/zonomi/delete_zone.xml | 14 + .../zonomi/delete_zone_does_not_exist.xml | 2 + .../dns/fixtures/zonomi/empty_zones_list.xml | 11 + .../test/dns/fixtures/zonomi/list_records.xml | 21 + .../test/dns/fixtures/zonomi/list_zones.xml | 18 + libcloud/test/dns/test_zonomi.py | 382 ++++++++++++++++++ libcloud/test/secrets.py-dist | 1 + 22 files changed, 1059 insertions(+) create mode 100644 docs/dns/drivers/zonomi.rst create mode 100644 docs/examples/dns/zonomi/instantiate_driver.py create mode 100644 libcloud/common/zonomi.py create mode 100644 libcloud/dns/drivers/zonomi.py create mode 100644 libcloud/test/dns/fixtures/zonomi/converted_to_master.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/converted_to_slave.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/couldnt_convert.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/create_record.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/create_record_already_exists.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/create_zone.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/create_zone_already_exists.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/delete_record.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/delete_record_does_not_exist.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/delete_zone.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/delete_zone_does_not_exist.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/empty_zones_list.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/list_records.xml create mode 100644 libcloud/test/dns/fixtures/zonomi/list_zones.xml create mode 100644 libcloud/test/dns/test_zonomi.py diff --git a/docs/dns/drivers/zonomi.rst b/docs/dns/drivers/zonomi.rst new file mode 100644 index 0000000000..44f6554fdd --- /dev/null +++ b/docs/dns/drivers/zonomi.rst @@ -0,0 +1,25 @@ +Zonomi DNS Driver Documentation +=========================== + +`Zonomi`_ name servers are spread around the globe (London, Dallas, New York +and Auckland). It offers Fully redundant, fault-tolerant, reliable name +servers, Instant updates, Low TTLs, Wildcard domain names, Any DNS record type, +Dynamic DNS, DNS API, Round robin DNS, No need to transfer your domain name +registrar, Integrates with Pingability's DNS fail over, Vanity name servers. + +Read more at: http://zonomi.com/ + +Instantiating the driver +-------------------------- + +.. literalinclude:: /examples/dns/zonomi/instantiate_driver.py + :language: python + +API Docs +-------- + +.. autoclass:: libcloud.dns.drivers.zonomi.ZonomiDNSDriver + :members: + :inherited-members: + +.. _`Zonomi`: http://zonomi.com/ diff --git a/docs/examples/dns/zonomi/instantiate_driver.py b/docs/examples/dns/zonomi/instantiate_driver.py new file mode 100644 index 0000000000..6c356047c2 --- /dev/null +++ b/docs/examples/dns/zonomi/instantiate_driver.py @@ -0,0 +1,5 @@ +from libcloud.dns.types import Provider +from libcloud.dns.providers import get_driver + +cls = get_driver(Provider.ZONOMI) +driver = cls('apikey') diff --git a/libcloud/common/zonomi.py b/libcloud/common/zonomi.py new file mode 100644 index 0000000000..6619b103b3 --- /dev/null +++ b/libcloud/common/zonomi.py @@ -0,0 +1,150 @@ +# 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. + +from libcloud.common.base import XmlResponse +from libcloud.common.base import ConnectionKey +from libcloud.utils.py3 import b, PY3 + + +__all__ = [ + 'ZonomiException', + 'ZonomiResponse', + 'ZonomiConnection' +] + +# Endpoint for Zonomi API. +API_HOST = 'zonomi.com' + +SPECIAL_ERRORS = [ + 'Not found.', + 'ERROR: This zone is already in your zone list.', + 'Record not deleted.' +] + + +class ZonomiException(Exception): + def __init__(self, code, message): + self.code = code + self.message = message + self.args = (code, message) + + def __str__(self): + return "%s %s" % (self.code, self.message) + + def __repr__(self): + return "ZonomiException %s %s" % (self.code, self.message) + + +class ZonomiResponse(XmlResponse): + errors = None + objects = None + + def __init__(self, response, connection): + self.connection = connection + self.headers = dict(response.getheaders()) + self.error = response.reason + self.status = response.status + + # This attribute is used when using LoggingConnection + original_data = getattr(response, '_original_data', None) + if original_data: + self.body = response._original_data + else: + self.body = self._decompress_response(body=response.read(), + headers=self.headers) + if PY3: + self.body = b(self.body).decode('utf-8') + self.objects, self.errors = self.parse_body() + if self.errors: + raise self._make_excp(self.errors[0]) + + def parse_body(self): + error_dict = {} + actions = None + result_counts = None + action_childrens = None + data = [] + errors = [] + xml_body = super(ZonomiResponse, self).parse_body() + # Error handling + if xml_body.text is not None and xml_body.tag == 'error': + error_dict['ERRORCODE'] = self.status + if xml_body.text.startswith('ERROR: No zone found for'): + error_dict['ERRORCODE'] = '404' + error_dict['ERRORMESSAGE'] = 'Not found.' + else: + error_dict['ERRORMESSAGE'] = xml_body.text + errors.append(error_dict) + + # Data handling + childrens = xml_body.getchildren() + if len(childrens) == 3: + result_counts = childrens[1] + actions = childrens[2] + + if actions is not None: + actions_childrens = actions.getchildren() + action = actions_childrens[0] + action_childrens = action.getchildren() + + if action_childrens is not None: + for child in action_childrens: + if child.tag == 'zone' or child.tag == 'record': + data.append(child.attrib) + + if result_counts is not None and \ + result_counts.attrib.get('deleted') == '1': + data.append('DELETED') + + if result_counts is not None and \ + result_counts.attrib.get('deleted') == '0' and \ + action.get('action') == 'DELETE': + error_dict['ERRORCODE'] = self.status + error_dict['ERRORMESSAGE'] = 'Record not deleted.' + errors.append(error_dict) + + return (data, errors) + + # def success(self): + # return (len(self.errors) == 0) + + def _make_excp(self, error): + """ + :param error: contains error code and error message + :type error: dict + """ + return ZonomiException(error['ERRORCODE'], error['ERRORMESSAGE']) + + +class ZonomiConnection(ConnectionKey): + host = API_HOST + responseCls = ZonomiResponse + + def add_default_params(self, params): + """ + Adds default parameters to perform a request, + such as api_key. + """ + params['api_key'] = self.key + + return params + + def add_default_headers(self, headers): + """ + Adds default headers needed to perform a successful + request such as Content-Type, User-Agent. + """ + headers['Content-Type'] = 'text/xml;charset=UTF-8' + + return headers diff --git a/libcloud/dns/drivers/zonomi.py b/libcloud/dns/drivers/zonomi.py new file mode 100644 index 0000000000..8fdf87bb77 --- /dev/null +++ b/libcloud/dns/drivers/zonomi.py @@ -0,0 +1,349 @@ +# 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. +""" +Zonomi DNS Driver +""" +import sys + +from libcloud.common.zonomi import ZonomiConnection, ZonomiResponse +from libcloud.common.zonomi import ZonomiException +from libcloud.dns.base import DNSDriver, Zone, Record +from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError +from libcloud.dns.types import RecordAlreadyExistsError +from libcloud.dns.types import RecordDoesNotExistError +from libcloud.dns.types import Provider, RecordType + + +__all__ = [ + 'ZonomiDNSDriver', +] + + +class ZonomiDNSResponse(ZonomiResponse): + pass + + +class ZonomiDNSConnection(ZonomiConnection): + responseCls = ZonomiDNSResponse + + +class ZonomiDNSDriver(DNSDriver): + type = Provider.ZONOMI + name = 'Zonomi DNS' + website = 'https://zonomi.com' + connectionCls = ZonomiDNSConnection + + RECORD_TYPE_MAP = { + RecordType.A: 'A', + RecordType.MX: 'MX', + RecordType.TXT: 'TXT' + } + + def list_zones(self): + """ + Return a list of zones. + + :return: ``list`` of :class:`Zone` + """ + action = '/app/dns/dyndns.jsp?' + params = {'action': 'QUERYZONES', 'api_key': self.key} + + response = self.connection.request(action=action, params=params) + zones = self._to_zones(response.objects) + + return zones + + def list_records(self, zone): + """ + Return a list of records for the provided zone. + + :param zone: Zone to list records for. + :type zone: :class:`Zone` + + :return: ``list`` of :class:`Record` + """ + action = '/app/dns/dyndns.jsp?' + params = {'action': 'QUERY', 'name': '**.' + zone.id} + try: + response = self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if e.code == '404': + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + raise e + + records = self._to_records(response.objects, zone) + + return records + + def get_zone(self, zone_id): + """ + Return a Zone instance. + + :param zone_id: ID of the required zone + :type zone_id: ``str`` + + :rtype: :class:`Zone` + """ + zone = None + zones = self.list_zones() + for z in zones: + if z.id == zone_id: + zone = z + + if zone is None: + raise ZoneDoesNotExistError(zone_id=zone_id, driver=self, value='') + + return zone + + def get_record(self, zone_id, record_id): + """ + Return a Record instance. + + :param zone_id: ID of the required zone + :type zone_id: ``str`` + + :param record_id: ID of the required record + :type record_id: ``str`` + + :rtype: :class:`Record` + """ + record = None + zone = self.get_zone(zone_id=zone_id) + records = self.list_records(zone=zone) + + for r in records: + if r.id == record_id: + record = r + + if record is None: + raise RecordDoesNotExistError(record_id=record_id, driver=self, + value='') + + return record + + def create_zone(self, domain, type='master', ttl=None, extra=None): + """ + Create a new zone. + + :param zone_id: Zone domain name (e.g. example.com) + :type zone_id: ``str`` + + :rtype: :class:`Zone` + """ + action = '/app/dns/addzone.jsp?' + params = {'name': domain} + try: + self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if e.message == 'ERROR: This zone is already in your zone list.': + raise ZoneAlreadyExistsError(zone_id=domain, driver=self, + value=e.message) + raise e + + zone = Zone(id=domain, domain=domain, type='master', ttl=ttl, + driver=self, extra=extra) + return zone + + def create_record(self, name, zone, type, data, extra=None): + """ + Create a new record. + + :param name: Record name without the domain name (e.g. www). + Note: If you want to create a record for a base domain + name, you should specify empty string ('') for this + argument. + :type name: ``str`` + + :param zone: Zone where the requested record is created. + :type zone: :class:`Zone` + + :param type: DNS record type (A, MX, TXT). + :type type: :class:`RecordType` + + :param data: Data for the record (depends on the record type). + :type data: ``str`` + + :param extra: Extra attributes (driver specific, e.g. 'prio' or 'ttl'). + (optional) + :type extra: ``dict`` + + :rtype: :class:`Record` + """ + action = '/app/dns/dyndns.jsp?' + if name: + record_name = name + '.' + zone.domain + else: + record_name = zone.domain + params = {'action': 'SET', 'name': record_name, 'value': data, + 'type': type} + + if type == 'MX' and extra is not None: + params['prio'] = extra.get('prio') + try: + response = self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if ('ERROR: No zone found for %s' % record_name) in e.message: + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + raise e + + # we determine if an A or MX record already exists + # by looking at the response.If the key 'skipped' is present in the + # response, it means record already exists. If this is True, + # then raise RecordAlreadyExistsError + if len(response.objects) != 0 and \ + response.objects[0].get('skipped') == 'unchanged': + raise RecordAlreadyExistsError(record_id=name, driver=self, + value='') + + if 'DELETED' in response.objects: + for el in response.objects[:2]: + if el.get('content') == data: + response.objects = [el] + records = self._to_records(response.objects, zone=zone) + return records[0] + + def delete_zone(self, zone): + """ + Delete a zone. + + Note: This will delete all the records belonging to this zone. + + :param zone: Zone to delete. + :type zone: :class:`Zone` + + :rtype: ``bool`` + """ + action = '/app/dns/dyndns.jsp?' + params = {'action': 'DELETEZONE', 'name': zone.id} + try: + response = self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if e.code == '404': + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + raise e + + return 'DELETED' in response.objects + + def delete_record(self, record): + """ + Use this method to delete a record. + + :param record: record to delete + :type record: `Record` + + :rtype: Bool + """ + action = '/app/dns/dyndns.jsp?' + params = {'action': 'DELETE', 'name': record.name, 'type': record.type} + try: + response = self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if e.message == 'Record not deleted.': + raise RecordDoesNotExistError(record_id=record.id, driver=self, + value=e.message) + raise e + + return 'DELETED' in response.objects + + def ex_convert_to_secondary(self, zone, master): + """ + Convert existent zone to slave. + + :param zone: Zone to convert. + :type zone: :class:`Zone` + + :param master: the specified master name server IP address. + :type master: ``str`` + + :rtype: Bool + """ + action = '/app/dns/converttosecondary.jsp?' + params = {'name': zone.domain, 'master': master} + try: + self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if 'ERROR: Could not find' in e.message: + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + return True + + def ex_convert_to_master(self, zone): + """ + Convert existent zone to master. + + :param zone: Zone to convert. + :type zone: :class:`Zone` + + :rtype: Bool + """ + action = '/app/dns/converttomaster.jsp?' + params = {'name': zone.domain} + try: + self.connection.request(action=action, params=params) + except ZonomiException: + e = sys.exc_info()[1] + if 'ERROR: Could not find' in e.message: + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + return True + + def _to_zone(self, item): + if item['type'] == 'NATIVE': + type = 'master' + elif item['type'] == 'SLAVE': + type = 'slave' + zone = Zone(id=item['name'], domain=item['name'], type=type, + driver=self, extra={}, ttl=None) + + return zone + + def _to_zones(self, items): + zones = [] + for item in items: + zones.append(self._to_zone(item)) + + return zones + + def _to_record(self, item, zone): + if len(item.get('ttl')) > 0: + ttl = item.get('ttl').split(' ')[0] + extra = {'ttl': ttl, + 'prio': item.get('prio')} + if len(item['name']) > len(zone.domain): + full_domain = item['name'] + index = full_domain.index('.' + zone.domain) + record_name = full_domain[:index] + else: + record_name = zone.domain + record = Record(id=record_name, name=record_name, + data=item['content'], type=item['type'], zone=zone, + driver=self, extra=extra) + + return record + + def _to_records(self, items, zone): + records = [] + for item in items: + records.append(self._to_record(item, zone)) + + return records diff --git a/libcloud/dns/providers.py b/libcloud/dns/providers.py index b54fe4df2c..a91caae841 100644 --- a/libcloud/dns/providers.py +++ b/libcloud/dns/providers.py @@ -43,6 +43,8 @@ ('libcloud.dns.drivers.dnsimple', 'DNSimpleDNSDriver'), Provider.POINTDNS: ('libcloud.dns.drivers.pointdns', 'PointDNSDriver'), + Provider.ZONOMI: + ('libcloud.dns.drivers.zonomi', 'ZonomiDNSDriver'), # Deprecated Provider.RACKSPACE_US: ('libcloud.dns.drivers.rackspace', 'RackspaceUSDNSDriver'), diff --git a/libcloud/dns/types.py b/libcloud/dns/types.py index 01fa06b9f4..7cfb4021e1 100644 --- a/libcloud/dns/types.py +++ b/libcloud/dns/types.py @@ -42,6 +42,7 @@ class Provider(object): WORLDWIDEDNS = 'worldwidedns' DNSIMPLE = 'dnsimple' POINTDNS = 'pointdns' + ZONOMI = 'zonomi' # Deprecated RACKSPACE_US = 'rackspace_us' diff --git a/libcloud/test/dns/fixtures/zonomi/converted_to_master.xml b/libcloud/test/dns/fixtures/zonomi/converted_to_master.xml new file mode 100644 index 0000000000..f69d7f229d --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/converted_to_master.xml @@ -0,0 +1,6 @@ + + + OK: + \n + This service is now the master for this zone. It will no longer listen for changes from 1.2.3.4. + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/converted_to_slave.xml b/libcloud/test/dns/fixtures/zonomi/converted_to_slave.xml new file mode 100644 index 0000000000..191404adda --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/converted_to_slave.xml @@ -0,0 +1,6 @@ + + + OK: + \n + zone.com successfully converted to a slave zone. Our name servers will pick up DNS changes from the master server you specified. + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/couldnt_convert.xml b/libcloud/test/dns/fixtures/zonomi/couldnt_convert.xml new file mode 100644 index 0000000000..9cfc23c351 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/couldnt_convert.xml @@ -0,0 +1,2 @@ + +ERROR: Could not find a 'nonexistentzone.com' zone. diff --git a/libcloud/test/dns/fixtures/zonomi/create_record.xml b/libcloud/test/dns/fixtures/zonomi/create_record.xml new file mode 100644 index 0000000000..15c39991c1 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/create_record.xml @@ -0,0 +1,15 @@ + + + OK: + \n + + \n + + \n\n + + \n + + \t + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/create_record_already_exists.xml b/libcloud/test/dns/fixtures/zonomi/create_record_already_exists.xml new file mode 100644 index 0000000000..bc48349f17 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/create_record_already_exists.xml @@ -0,0 +1,15 @@ + + + OK: + \n + + \n + + \n\n + + \n + + \t + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/create_zone.xml b/libcloud/test/dns/fixtures/zonomi/create_zone.xml new file mode 100644 index 0000000000..6564a6b735 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/create_zone.xml @@ -0,0 +1,6 @@ + + + OK: + \n + myzone.com added successfully + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/create_zone_already_exists.xml b/libcloud/test/dns/fixtures/zonomi/create_zone_already_exists.xml new file mode 100644 index 0000000000..8816d2fd2a --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/create_zone_already_exists.xml @@ -0,0 +1,2 @@ + +ERROR: This zone is already in your zone list. diff --git a/libcloud/test/dns/fixtures/zonomi/delete_record.xml b/libcloud/test/dns/fixtures/zonomi/delete_record.xml new file mode 100644 index 0000000000..775df21423 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/delete_record.xml @@ -0,0 +1,15 @@ + + + OK: + \n + + \n + + \n\n + + \n + + \t + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/delete_record_does_not_exist.xml b/libcloud/test/dns/fixtures/zonomi/delete_record_does_not_exist.xml new file mode 100644 index 0000000000..aa91ab30fb --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/delete_record_does_not_exist.xml @@ -0,0 +1,11 @@ + + + OK: + \n + + \n + + \n\n + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/delete_zone.xml b/libcloud/test/dns/fixtures/zonomi/delete_zone.xml new file mode 100644 index 0000000000..a05ad098dc --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/delete_zone.xml @@ -0,0 +1,14 @@ + + + OK: + \n + + \n + + \n\n + + \n + + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/delete_zone_does_not_exist.xml b/libcloud/test/dns/fixtures/zonomi/delete_zone_does_not_exist.xml new file mode 100644 index 0000000000..f17aa49db7 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/delete_zone_does_not_exist.xml @@ -0,0 +1,2 @@ + +ERROR: No zone found for zone diff --git a/libcloud/test/dns/fixtures/zonomi/empty_zones_list.xml b/libcloud/test/dns/fixtures/zonomi/empty_zones_list.xml new file mode 100644 index 0000000000..ebb64f2bb8 --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/empty_zones_list.xml @@ -0,0 +1,11 @@ + + + OK: + \n + + \n + + \n\n + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/list_records.xml b/libcloud/test/dns/fixtures/zonomi/list_records.xml new file mode 100644 index 0000000000..5a322ac19a --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/list_records.xml @@ -0,0 +1,21 @@ + + + OK: + \n + + \n + + \n\n + + \n + + \t\n + + \t\n + + \t\n + + \t + + + \ No newline at end of file diff --git a/libcloud/test/dns/fixtures/zonomi/list_zones.xml b/libcloud/test/dns/fixtures/zonomi/list_zones.xml new file mode 100644 index 0000000000..fcc78a2efc --- /dev/null +++ b/libcloud/test/dns/fixtures/zonomi/list_zones.xml @@ -0,0 +1,18 @@ + + + OK: + \n + + \n + + \n\n + + \n + + \n + + \n + + + + \ No newline at end of file diff --git a/libcloud/test/dns/test_zonomi.py b/libcloud/test/dns/test_zonomi.py new file mode 100644 index 0000000000..186e2f5f13 --- /dev/null +++ b/libcloud/test/dns/test_zonomi.py @@ -0,0 +1,382 @@ +# 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 mock import MagicMock + + +from libcloud.test import MockHttp +from libcloud.utils.py3 import httplib +from libcloud.dns.drivers.zonomi import ZonomiDNSDriver +from libcloud.test.secrets import DNS_PARAMS_ZONOMI +from libcloud.test.file_fixtures import DNSFileFixtures +from libcloud.dns.types import RecordType +from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError +from libcloud.dns.types import RecordDoesNotExistError +from libcloud.dns.types import RecordAlreadyExistsError +from libcloud.dns.base import Zone, Record + + +class ZonomiTests(unittest.TestCase): + + def setUp(self): + ZonomiDNSDriver.connectionCls.conn_classes = (None, ZonomiMockHttp) + ZonomiMockHttp.type = None + self.driver = ZonomiDNSDriver(*DNS_PARAMS_ZONOMI) + self.test_zone = Zone(id='zone.com', domain='zone.com', + driver=self.driver, type='master', ttl=None, + extra={}) + self.test_record = Record(id='record.zone.com', name='record.zone.com', + data='127.0.0.1', type='A', + zone=self.test_zone, driver=self, + extra={}) + + def test_list_record_types(self): + record_types = self.driver.list_record_types() + self.assertEqual(len(record_types), 3) + self.assertTrue(RecordType.A in record_types) + self.assertTrue(RecordType.MX in record_types) + self.assertTrue(RecordType.TXT in record_types) + + def test_list_zones_empty(self): + ZonomiMockHttp.type = 'EMPTY_ZONES_LIST' + zones = self.driver.list_zones() + + self.assertEqual(zones, []) + + def test_list_zones_success(self): + zones = self.driver.list_zones() + + self.assertEqual(len(zones), 3) + + zone = zones[0] + self.assertEqual(zone.id, 'thegamertest.com') + self.assertEqual(zone.domain, 'thegamertest.com') + self.assertEqual(zone.type, 'master') + self.assertEqual(zone.ttl, None) + self.assertEqual(zone.driver, self.driver) + + second_zone = zones[1] + self.assertEqual(second_zone.id, 'lonelygamer.com') + self.assertEqual(second_zone.domain, 'lonelygamer.com') + self.assertEqual(second_zone.type, 'master') + self.assertEqual(second_zone.ttl, None) + self.assertEqual(second_zone.driver, self.driver) + + third_zone = zones[2] + self.assertEqual(third_zone.id, 'gamertest.com') + self.assertEqual(third_zone.domain, 'gamertest.com') + self.assertEqual(third_zone.type, 'master') + self.assertEqual(third_zone.ttl, None) + self.assertEqual(third_zone.driver, self.driver) + + def test_get_zone_GET_ZONE_DOES_NOT_EXIST(self): + ZonomiMockHttp.type = 'GET_ZONE_DOES_NOT_EXIST' + try: + self.driver.get_zone('testzone.com') + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, 'testzone.com') + else: + self.fail('Exception was not thrown.') + + def test_get_zone_GET_ZONE_SUCCESS(self): + ZonomiMockHttp.type = 'GET_ZONE_SUCCESS' + zone = self.driver.get_zone(zone_id='gamertest.com') + + self.assertEqual(zone.id, 'gamertest.com') + self.assertEqual(zone.domain, 'gamertest.com') + self.assertEqual(zone.type, 'master') + self.assertEqual(zone.ttl, None) + self.assertEqual(zone.driver, self.driver) + + def test_delete_zone_DELETE_ZONE_DOES_NOT_EXIST(self): + ZonomiMockHttp.type = 'DELETE_ZONE_DOES_NOT_EXIST' + try: + self.driver.delete_zone(zone=self.test_zone) + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, self.test_zone.id) + else: + self.fail('Exception was not thrown.') + + def test_delete_zone_delete_zone_success(self): + ZonomiMockHttp.type = 'DELETE_ZONE_SUCCESS' + status = self.driver.delete_zone(zone=self.test_zone) + + self.assertEqual(status, True) + + def test_create_zone_already_exists(self): + ZonomiMockHttp.type = 'CREATE_ZONE_ALREADY_EXISTS' + try: + self.driver.create_zone(domain='gamertest.com') + except ZoneAlreadyExistsError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, 'gamertest.com') + else: + self.fail('Exception was not thrown.') + + def test_create_zone_create_zone_success(self): + ZonomiMockHttp.type = 'CREATE_ZONE_SUCCESS' + + zone = self.driver.create_zone(domain='myzone.com') + + self.assertEqual(zone.id, 'myzone.com') + self.assertEqual(zone.domain, 'myzone.com') + self.assertEqual(zone.type, 'master') + self.assertEqual(zone.ttl, None) + + def test_list_records_empty_list(self): + ZonomiMockHttp.type = 'LIST_RECORDS_EMPTY_LIST' + pass + + def test_list_records_success(self): + ZonomiMockHttp.type = 'LIST_RECORDS_SUCCESS' + records = self.driver.list_records(zone=self.test_zone) + + self.assertEqual(len(records), 4) + + record = records[0] + self.assertEqual(record.id, 'zone.com') + self.assertEqual(record.type, 'SOA') + self.assertEqual(record.data, + 'ns1.zonomi.com. soacontact.zonomi.com. 13') + self.assertEqual(record.name, 'zone.com') + self.assertEqual(record.zone, self.test_zone) + + second_record = records[1] + self.assertEqual(second_record.id, 'zone.com') + self.assertEqual(second_record.name, 'zone.com') + self.assertEqual(second_record.type, 'NS') + self.assertEqual(second_record.data, 'ns1.zonomi.com') + self.assertEqual(second_record.zone, self.test_zone) + + third_record = records[2] + self.assertEqual(third_record.id, 'oltjano') + self.assertEqual(third_record.name, 'oltjano') + self.assertEqual(third_record.type, 'A') + self.assertEqual(third_record.data, '127.0.0.1') + self.assertEqual(third_record.zone, self.test_zone) + + fourth_record = records[3] + self.assertEqual(fourth_record.id, 'zone.com') + self.assertEqual(fourth_record.name, 'zone.com') + self.assertEqual(fourth_record.type, 'NS') + self.assertEqual(fourth_record.data, 'ns5.zonomi.com') + self.assertEqual(fourth_record.zone, self.test_zone) + + def test_get_record_does_not_exist(self): + ZonomiMockHttp.type = 'GET_RECORD_DOES_NOT_EXIST' + zone = Zone(id='zone.com', domain='zone.com', type='master', + ttl=None, driver=self.driver) + self.driver.get_zone = MagicMock(return_value=zone) + record_id = 'nonexistent' + try: + self.driver.get_record(record_id=record_id, + zone_id='zone.com') + except RecordDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.record_id, record_id) + else: + self.fail('Exception was not thrown.') + + def test_get_record_success(self): + ZonomiMockHttp.type = 'GET_RECORD_SUCCESS' + zone = Zone(id='zone.com', domain='zone.com', type='master', + ttl=None, driver=self.driver) + self.driver.get_zone = MagicMock(return_value=zone) + record = self.driver.get_record(record_id='oltjano', + zone_id='zone.com') + + self.assertEqual(record.id, 'oltjano') + self.assertEqual(record.name, 'oltjano') + self.assertEqual(record.type, 'A') + self.assertEqual(record.data, '127.0.0.1') + + def test_delete_record_does_not_exist(self): + ZonomiMockHttp.type = 'DELETE_RECORD_DOES_NOT_EXIST' + record = self.test_record + 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.') + + def test_delete_record_success(self): + ZonomiMockHttp.type = 'DELETE_RECORD_SUCCESS' + record = self.test_record + status = self.driver.delete_record(record=record) + + self.assertEqual(status, True) + + def test_create_record_already_exists(self): + zone = self.test_zone + ZonomiMockHttp.type = 'CREATE_RECORD_ALREADY_EXISTS' + try: + self.driver.create_record(name='createrecord', type='A', + data='127.0.0.1', zone=zone, extra={}) + except RecordAlreadyExistsError: + e = sys.exc_info()[1] + self.assertEqual(e.record_id, 'createrecord') + else: + self.fail('Exception was not thrown.') + + def test_create_record_success(self): + ZonomiMockHttp.type = 'CREATE_RECORD_SUCCESS' + zone = self.test_zone + record = self.driver.create_record(name='createrecord', + zone=zone, type='A', + data='127.0.0.1', extra={}) + + self.assertEqual(record.id, 'createrecord') + self.assertEqual(record.name, 'createrecord') + self.assertEqual(record.type, 'A') + self.assertEqual(record.data, '127.0.0.1') + self.assertEqual(record.zone, zone) + + def test_convert_to_slave(self): + zone = self.test_zone + result = self.driver.ex_convert_to_secondary(zone, '1.2.3.4') + self.assertTrue(result) + + def test_convert_to_slave_couldnt_convert(self): + zone = self.test_zone + ZonomiMockHttp.type = 'COULDNT_CONVERT' + try: + self.driver.ex_convert_to_secondary(zone, '1.2.3.4') + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, 'zone.com') + else: + self.fail('Exception was not thrown.') + + def test_convert_to_master(self): + zone = self.test_zone + result = self.driver.ex_convert_to_master(zone) + self.assertTrue(result) + + def test_convert_to_master_couldnt_convert(self): + zone = self.test_zone + ZonomiMockHttp.type = 'COULDNT_CONVERT' + try: + self.driver.ex_convert_to_master(zone) + except ZoneDoesNotExistError: + e = sys.exc_info()[1] + self.assertEqual(e.zone_id, 'zone.com') + else: + self.fail('Exception was not thrown.') + + +class ZonomiMockHttp(MockHttp): + fixtures = DNSFileFixtures('zonomi') + + def _app_dns_dyndns_jsp_EMPTY_ZONES_LIST(self, method, url, body, headers): + body = self.fixtures.load('empty_zones_list.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp(self, method, url, body, headers): + body = self.fixtures.load('list_zones.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_GET_ZONE_DOES_NOT_EXIST(self, method, url, body, + headers): + body = self.fixtures.load('list_zones.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_GET_ZONE_SUCCESS(self, method, url, body, headers): + body = self.fixtures.load('list_zones.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_DELETE_ZONE_DOES_NOT_EXIST(self, method, url, body, + headers): + body = self.fixtures.load('delete_zone_does_not_exist.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_DELETE_ZONE_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('delete_zone.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_addzone_jsp_CREATE_ZONE_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('create_zone.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_addzone_jsp_CREATE_ZONE_ALREADY_EXISTS(self, method, url, + body, headers): + body = self.fixtures.load('create_zone_already_exists.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_LIST_RECORDS_EMPTY_LIST(self, method, url, body, + headers): + body = self.fixtures.load('list_records_empty_list.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_LIST_RECORDS_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('list_records.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_DELETE_RECORD_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('delete_record.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_DELETE_RECORD_DOES_NOT_EXIST(self, method, url, + body, headers): + body = self.fixtures.load('delete_record_does_not_exist.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_CREATE_RECORD_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('create_record.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_CREATE_RECORD_ALREADY_EXISTS(self, method, url, + body, headers): + body = self.fixtures.load('create_record_already_exists.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_GET_RECORD_SUCCESS(self, method, url, body, + headers): + body = self.fixtures.load('list_records.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_dyndns_jsp_GET_RECORD_DOES_NOT_EXIST(self, method, url, body, + headers): + body = self.fixtures.load('list_records.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_converttosecondary_jsp(self, method, url, body, headers): + body = self.fixtures.load('converted_to_slave.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_converttosecondary_jsp_COULDNT_CONVERT(self, method, url, + body, headers): + body = self.fixtures.load('couldnt_convert.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_converttomaster_jsp(self, method, url, body, headers): + body = self.fixtures.load('converted_to_master.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _app_dns_converttomaster_jsp_COULDNT_CONVERT(self, method, url, + body, headers): + body = self.fixtures.load('couldnt_convert.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 50f2970072..dbb9789c7e 100644 --- a/libcloud/test/secrets.py-dist +++ b/libcloud/test/secrets.py-dist @@ -75,3 +75,4 @@ DNS_KEYWORD_PARAMS_GOOGLE = {'project': 'project_name'} DNS_PARAMS_WORLDWIDEDNS = ('user', 'key') DNS_PARAMS_DNSIMPLE = ('user', 'key') DNS_PARAMS_POINTDNS = ('user', 'key') +DNS_PARAMS_ZONOMI = ('key')