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