Repository: libcloud Updated Branches: refs/heads/trunk de401aca8 -> 7034ea8c5
add PowerDNS driver Closes #758 Project: http://git-wip-us.apache.org/repos/asf/libcloud/repo Commit: http://git-wip-us.apache.org/repos/asf/libcloud/commit/7034ea8c Tree: http://git-wip-us.apache.org/repos/asf/libcloud/tree/7034ea8c Diff: http://git-wip-us.apache.org/repos/asf/libcloud/diff/7034ea8c Branch: refs/heads/trunk Commit: 7034ea8c5f91213ff502a9c2bbcd1e7d9aceb6d9 Parents: de401ac Author: Ken Dreyer <ktdre...@ktdreyer.com> Authored: Tue Apr 12 16:23:21 2016 -0600 Committer: anthony-shaw <anthonys...@apache.org> Committed: Tue Apr 19 03:04:54 2016 +1000 ---------------------------------------------------------------------- docs/dns/_supported_methods.rst | 2 + docs/dns/_supported_providers.rst | 2 + docs/dns/drivers/powerdns.rst | 43 ++ .../examples/dns/powerdns/instantiate_driver.py | 13 + libcloud/dns/drivers/powerdns.py | 460 +++++++++++++++++++ libcloud/dns/providers.py | 2 + libcloud/dns/types.py | 10 + .../dns/fixtures/powerdns/list_records.json | 49 ++ .../test/dns/fixtures/powerdns/list_zones.json | 30 ++ libcloud/test/dns/test_powerdns.py | 190 ++++++++ 10 files changed, 801 insertions(+) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/docs/dns/_supported_methods.rst ---------------------------------------------------------------------- diff --git a/docs/dns/_supported_methods.rst b/docs/dns/_supported_methods.rst index ce1f8f1..f812164 100644 --- a/docs/dns/_supported_methods.rst +++ b/docs/dns/_supported_methods.rst @@ -19,6 +19,7 @@ Provider list zones list records create zone update zone create recor `NFSN DNS`_ no yes no no yes no no yes `NS1 DNS`_ yes yes yes no yes yes yes yes `Point DNS`_ yes yes yes yes yes yes yes yes +`PowerDNS`_ yes yes yes no yes yes yes yes `Rackspace DNS`_ yes yes yes yes yes yes yes yes `Route53 DNS`_ yes yes yes no yes yes yes yes `Softlayer DNS`_ yes yes yes no yes yes yes yes @@ -44,6 +45,7 @@ Provider list zones list records create zone update zone create recor .. _`NFSN DNS`: https://www.nearlyfreespeech.net .. _`NS1 DNS`: https://ns1.com .. _`Point DNS`: https://pointhq.com/ +.. _`PowerDNS`: https://www.powerdns.com/ .. _`Rackspace DNS`: http://www.rackspace.com/ .. _`Route53 DNS`: http://aws.amazon.com/route53/ .. _`Softlayer DNS`: https://www.softlayer.com http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/docs/dns/_supported_providers.rst ---------------------------------------------------------------------- diff --git a/docs/dns/_supported_providers.rst b/docs/dns/_supported_providers.rst index 75232b2..2ff4308 100644 --- a/docs/dns/_supported_providers.rst +++ b/docs/dns/_supported_providers.rst @@ -19,6 +19,7 @@ Provider Documentation Provider Constant `NFSN DNS`_ :doc:`Click </dns/drivers/nfsn>` NFSN single region driver :mod:`libcloud.dns.drivers.nfsn` :class:`NFSNDNSDriver` `NS1 DNS`_ NSONE single region driver :mod:`libcloud.dns.drivers.nsone` :class:`NsOneDNSDriver` `Point DNS`_ :doc:`Click </dns/drivers/pointdns>` POINTDNS single region driver :mod:`libcloud.dns.drivers.pointdns` :class:`PointDNSDriver` +`PowerDNS`_ :doc:`Click </dns/drivers/powerdns>` POWERDNS single region driver :mod:`libcloud.dns.drivers.powerdns` :class:`PowerDNSDriver` `Rackspace DNS`_ RACKSPACE us, uk :mod:`libcloud.dns.drivers.rackspace` :class:`RackspaceDNSDriver` `Route53 DNS`_ ROUTE53 single region driver :mod:`libcloud.dns.drivers.route53` :class:`Route53DNSDriver` `Softlayer DNS`_ SOFTLAYER single region driver :mod:`libcloud.dns.drivers.softlayer` :class:`SoftLayerDNSDriver` @@ -44,6 +45,7 @@ Provider Documentation Provider Constant .. _`NFSN DNS`: https://www.nearlyfreespeech.net .. _`NS1 DNS`: https://ns1.com .. _`Point DNS`: https://pointhq.com/ +.. _`PowerDNS`: https://www.powerdns.com/ .. _`Rackspace DNS`: http://www.rackspace.com/ .. _`Route53 DNS`: http://aws.amazon.com/route53/ .. _`Softlayer DNS`: https://www.softlayer.com http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/docs/dns/drivers/powerdns.rst ---------------------------------------------------------------------- diff --git a/docs/dns/drivers/powerdns.rst b/docs/dns/drivers/powerdns.rst new file mode 100644 index 0000000..0dd41a2 --- /dev/null +++ b/docs/dns/drivers/powerdns.rst @@ -0,0 +1,43 @@ +PowerDNS Driver Documentation +============================= + +`PowerDNS`_ is an open-source DNS server. + +The current libcloud PowerDNS driver uses the HTTP API from PowerDNS 3.x by +default. Please read the `PowerDNS 3 HTTP API documentation`_ to enable the +HTTP API on your PowerDNS server. Specifically, you will need to set the +following in ``pdns.conf``:: + + experimental-json-interface=yes + experimental-api-key=changeme + webserver=yes + +For PowerDNS 4.x, please read the `PowerDNS 4 HTTP API documentation`_. The +``pdns.conf`` options are slightly different (the options are no longer +prefixed with ``experimental-``):: + + json-interface=yes + api-key=changeme + webserver=yes + +Be sure to reload the pdns service after any configuration changes. + +Instantiating the driver +------------------------ + +To instantiate the driver you need to pass the API key, hostname, and webserver +HTTP port to the driver constructor as shown below. + +.. literalinclude:: /examples/dns/powerdns/instantiate_driver.py + :language: python + +API Docs +-------- + +.. autoclass:: libcloud.dns.drivers.powerdns.PowerDNSDriver + :members: + :inherited-members: + +.. _`PowerDNS`: https://doc.powerdns.com/ +.. _`PowerDNS 3 HTTP API documentation`: https://doc.powerdns.com/3/httpapi/README/ +.. _`PowerDNS 4 HTTP API documentation`: https://doc.powerdns.com/md/httpapi/README/ http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/docs/examples/dns/powerdns/instantiate_driver.py ---------------------------------------------------------------------- diff --git a/docs/examples/dns/powerdns/instantiate_driver.py b/docs/examples/dns/powerdns/instantiate_driver.py new file mode 100644 index 0000000..5baefa6 --- /dev/null +++ b/docs/examples/dns/powerdns/instantiate_driver.py @@ -0,0 +1,13 @@ +from libcloud.dns.types import Provider +from libcloud.dns.providers import get_driver + +cls = get_driver(Provider.POWERDNS) + +# powerdns3.example.com is running PowerDNS v3.x. +driver = cls(key='changeme', host='powerdns3.example.com', port=8081) + +# OR: + +# powerdns4.example.com is running PowerDNS v4.x, so it uses api_version v1. +driver = cls(key='changeme', host='powerdns4.example.com', port=8081, + api_version='v1') http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/dns/drivers/powerdns.py ---------------------------------------------------------------------- diff --git a/libcloud/dns/drivers/powerdns.py b/libcloud/dns/drivers/powerdns.py new file mode 100644 index 0000000..56e2381 --- /dev/null +++ b/libcloud/dns/drivers/powerdns.py @@ -0,0 +1,460 @@ +# 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. +""" +PowerDNS Driver +""" +import json +import sys + +from libcloud.common.base import ConnectionKey, JsonResponse +from libcloud.common.exceptions import BaseHTTPError +from libcloud.common.types import InvalidCredsError, MalformedResponseError +from libcloud.dns.base import DNSDriver, Zone, Record +from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError +from libcloud.dns.types import Provider, RecordType +from libcloud.utils.py3 import httplib + +__all__ = [ + 'PowerDNSDriver', +] + + +class PowerDNSResponse(JsonResponse): + + def success(self): + i = int(self.status) + return i >= 200 and i <= 299 + + def parse_error(self): + if self.status == httplib.UNAUTHORIZED: + raise InvalidCredsError('Invalid provider credentials') + + try: + body = self.parse_body() + except MalformedResponseError: + e = sys.exc_info()[1] + body = '%s: %s' % (e.value, e.body) + try: + errors = [body['error']] + except TypeError: + # parse_body() gave us a simple string, not a dict. + return '%s (HTTP Code: %d)' % (body, self.status) + try: + errors.append(body['errors']) + except KeyError: + # The PowerDNS API does not return the "errors" list all the time. + pass + + return '%s (HTTP Code: %d)' % (' '.join(errors), self.status) + + +class PowerDNSConnection(ConnectionKey): + responseCls = PowerDNSResponse + + def add_default_headers(self, headers): + headers['X-API-Key'] = self.key + return headers + + +class PowerDNSDriver(DNSDriver): + type = Provider.POWERDNS + name = 'PowerDNS' + website = 'https://www.powerdns.com/' + connectionCls = PowerDNSConnection + + RECORD_TYPE_MAP = { + RecordType.A: 'A', + RecordType.AAAA: 'AAAA', + RecordType.AFSDB: 'AFSDB', + RecordType.CERT: 'CERT', + RecordType.CNAME: 'CNAME', + RecordType.DNSKEY: 'DNSKEY', + RecordType.DS: 'DS', + RecordType.HINFO: 'HINFO', + RecordType.KEY: 'KEY', + RecordType.LOC: 'LOC', + RecordType.MX: 'MX', + RecordType.NAPTR: 'NAPTR', + RecordType.NS: 'NS', + RecordType.NSEC: 'NSEC', + RecordType.OPENPGPKEY: 'OPENPGPKEY', + RecordType.PTR: 'PTR', + RecordType.RP: 'RP', + RecordType.RRSIG: 'RRSIG', + RecordType.SOA: 'SOA', + RecordType.SPF: 'SPF', + RecordType.SSHFP: 'SSHFP', + RecordType.SRV: 'SRV', + RecordType.TLSA: 'TLSA', + RecordType.TXT: 'TXT', + } + + def __init__(self, key, secret=None, secure=False, host=None, port=None, + api_version='experimental', **kwargs): + """ + PowerDNS Driver defaulting to using PowerDNS 3.x API (ie + "experimental"). + + :param key: API key or username to used (required) + :type key: ``str`` + + :param secure: Whether to use HTTPS or HTTP. Note: Off by default + for PowerDNS. + :type secure: ``bool`` + + :param host: Hostname used for connections. + :type host: ``str`` + + :param port: Port used for connections. + :type port: ``int`` + + :param api_version: Specifies the API version to use. + ``experimental`` and ``v1`` are the only valid + options. Defaults to using ``experimental`` + (optional) + :type api_version: ``str`` + + :return: ``None`` + """ + # libcloud doesn't really have a concept of "servers". We'll just use + # localhost for now. + self.ex_server = 'localhost' + + if api_version == 'experimental': + # PowerDNS 3.x has no API root prefix. + self.api_root = '' + elif api_version == 'v1': + # PowerDNS 4.x has an '/api/v1' root prefix. + self.api_root = '/api/v1' + else: + raise NotImplementedError('Unsupported API version: %s' % + api_version) + + return super(PowerDNSDriver, self).__init__(key=key, secure=secure, + host=host, port=port, + **kwargs) + + def create_record(self, name, zone, type, data, extra=None): + """ + Create a new record. + + There are two PowerDNS-specific quirks here. Firstly, this method will + silently clobber any pre-existing records that might already exist. For + example, if PowerDNS already contains a "test.example.com" A record, + and you create that record using this function, then the old A record + will be replaced with your new one. + + Secondly, PowerDNS requires that you provide a ttl for all new records. + In other words, the "extra" parameter must be ``{'ttl': + <some-integer>}`` at a minimum. + + :param name: FQDN of the new record, for example "www.example.com". + :type name: ``str`` + + :param zone: Zone where the requested record is created. + :type zone: :class:`Zone` + + :param type: DNS record type (A, AAAA, ...). + :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. 'ttl'). + Note that PowerDNS *requires* a ttl value for every + record. + :type extra: ``dict`` + + :rtype: :class:`Record` + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + zone.id) + if extra is None or extra.get('ttl', None) is None: + raise ValueError('PowerDNS requires a ttl value for every record') + record = { + 'content': data, + 'disabled': False, + 'name': name, + 'ttl': extra['ttl'], + 'type': type, + } + payload = {'rrsets': [{'name': name, + 'type': type, + 'changetype': 'REPLACE', + 'records': [record] + }] + } + try: + self.connection.request(action=action, data=json.dumps(payload), + method='PATCH') + except BaseHTTPError: + e = sys.exc_info()[1] + if e.code == httplib.UNPROCESSABLE_ENTITY and \ + e.message.startswith('Could not find domain'): + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + raise e + return Record(id=None, name=name, data=data, + type=type, zone=zone, driver=self, ttl=extra['ttl']) + + def create_zone(self, domain, type=None, ttl=None, extra={}): + """ + Create a new zone. + + There are two PowerDNS-specific quirks here. Firstly, the "type" and + "ttl" parameters are ignored (no-ops). The "type" parameter is simply + not implemented, and PowerDNS does not have an ability to set a + zone-wide default TTL. (TTLs must be set per-record.) + + Secondly, PowerDNS requires that you provide a list of nameservers for + the zone upon creation. In other words, the "extra" parameter must be + ``{'nameservers': ['ns1.example.org']}`` at a minimum. + + :param name: Zone domain name (e.g. example.com) + :type name: ``str`` + + :param domain: Zone type (master / slave). (optional). Note that the + PowerDNS driver does nothing with this parameter. + :type domain: :class:`Zone` + + :param ttl: TTL for new records. (optional). Note that the PowerDNS + driver does nothing with this parameter. + :type ttl: ``int`` + + :param extra: Extra attributes (driver specific). + For example, specify + ``extra={'nameservers': ['ns1.example.org']}`` to set + a list of nameservers for this new zone. + :type extra: ``dict`` + + :rtype: :class:`Zone` + """ + action = '%s/servers/%s/zones' % (self.api_root, self.ex_server) + if extra is None or extra.get('nameservers', None) is None: + msg = 'PowerDNS requires a list of nameservers for every new zone' + raise ValueError(msg) + payload = {'name': domain, 'kind': 'Native'} + payload.update(extra) + zone_id = domain + '.' + try: + self.connection.request(action=action, data=json.dumps(payload), + method='POST') + except BaseHTTPError: + e = sys.exc_info()[1] + if e.code == httplib.UNPROCESSABLE_ENTITY and \ + e.message.startswith("Domain '%s' already exists" % domain): + raise ZoneAlreadyExistsError(zone_id=zone_id, driver=self, + value=e.message) + raise e + return Zone(id=zone_id, domain=domain, type=None, ttl=None, + driver=self, extra=extra) + + def delete_record(self, record): + """ + Use this method to delete a record. + + :param record: record to delete + :type record: `Record` + + :rtype: ``bool`` + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + record.zone.id) + payload = {'rrsets': [{'name': record.name, + 'type': record.type, + 'changetype': 'DELETE', + }] + } + try: + self.connection.request(action=action, data=json.dumps(payload), + method='PATCH') + except BaseHTTPError: + # I'm not sure if we should raise a ZoneDoesNotExistError here. The + # base DNS API only specifies that we should return a bool. So, + # let's ignore this code for now. + # e = sys.exc_info()[1] + # if e.code == httplib.UNPROCESSABLE_ENTITY and \ + # e.message.startswith('Could not find domain'): + # raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + # value=e.message) + # raise e + return False + return True + + def delete_zone(self, zone): + """ + Use this method to delete a zone. + + :param zone: zone to delete + :type zone: `Zone` + + :rtype: ``bool`` + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + zone.id) + try: + self.connection.request(action=action, method='DELETE') + except BaseHTTPError: + # I'm not sure if we should raise a ZoneDoesNotExistError here. The + # base DNS API only specifies that we should return a bool. So, + # let's ignore this code for now. + # e = sys.exc_info()[1] + # if e.code == httplib.UNPROCESSABLE_ENTITY and \ + # e.message.startswith('Could not find domain'): + # raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + # value=e.message) + # raise e + return False + return True + + def get_zone(self, zone_id): + """ + Return a Zone instance. + + (Note that PowerDNS does not support per-zone TTL defaults, so all Zone + objects will have ``ttl=None``.) + + :param zone_id: name of the required zone with the trailing period, for + example "example.com.". + :type zone_id: ``str`` + + :rtype: :class:`Zone` + :raises: ZoneDoesNotExistError: If no zone could be found. + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + zone_id) + try: + response = self.connection.request(action=action, method='GET') + except BaseHTTPError: + e = sys.exc_info()[1] + if e.code == httplib.UNPROCESSABLE_ENTITY: + raise ZoneDoesNotExistError(zone_id=zone_id, driver=self, + value=e.message) + raise e + return self._to_zone(response.object) + + def list_records(self, zone): + """ + Return a list of all records for the provided zone. + + :param zone: Zone to list records for. + :type zone: :class:`Zone` + + :return: ``list`` of :class:`Record` + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + zone.id) + try: + response = self.connection.request(action=action, method='GET') + except BaseHTTPError: + e = sys.exc_info()[1] + if e.code == httplib.UNPROCESSABLE_ENTITY and \ + e.message.startswith('Could not find domain'): + raise ZoneDoesNotExistError(zone_id=zone.id, driver=self, + value=e.message) + raise e + return self._to_records(response, zone) + + def list_zones(self): + """ + Return a list of zones. + + :return: ``list`` of :class:`Zone` + """ + action = '%s/servers/%s/zones' % (self.api_root, self.ex_server) + response = self.connection.request(action=action, method='GET') + return self._to_zones(response) + + def update_record(self, record, name, type, data, extra=None): + """ + Update an existing record. + + :param record: Record to update. + :type record: :class:`Record` + + :param name: FQDN of the new record, for example "www.example.com". + :type name: ``str`` + + :param type: DNS record type (A, AAAA, ...). + :type type: :class:`RecordType` + + :param data: Data for the record (depends on the record type). + :type data: ``str`` + + :param extra: (optional) Extra attributes (driver specific). + :type extra: ``dict`` + + :rtype: :class:`Record` + """ + action = '%s/servers/%s/zones/%s' % (self.api_root, self.ex_server, + record.zone.id) + if extra is None or extra.get('ttl', None) is None: + raise ValueError('PowerDNS requires a ttl value for every record') + updated_record = { + 'content': data, + 'disabled': False, + 'name': name, + 'ttl': extra['ttl'], + 'type': type, + } + payload = {'rrsets': [{'name': record.name, + 'type': record.type, + 'changetype': 'DELETE', + }, + {'name': name, + 'type': type, + 'changetype': 'REPLACE', + 'records': [updated_record] + }] + } + try: + self.connection.request(action=action, data=json.dumps(payload), + method='PATCH') + except BaseHTTPError: + e = sys.exc_info()[1] + if e.code == httplib.UNPROCESSABLE_ENTITY and \ + e.message.startswith('Could not find domain'): + raise ZoneDoesNotExistError(zone_id=record.zone.id, + driver=self, value=e.message) + raise e + return Record(id=None, name=name, data=data, type=type, + zone=record.zone, driver=self, ttl=extra['ttl']) + + def _to_zone(self, item): + extra = {} + for e in ['kind', 'dnssec', 'account', 'masters', 'serial', + 'notified_serial', 'last_check']: + extra[e] = item[e] + # XXX: we have to hard-code "ttl" to "None" here because PowerDNS does + # not support per-zone ttl defaults. However, I don't know what "type" + # should be; probably not None. + return Zone(id=item['id'], domain=item['name'], type=None, + ttl=None, driver=self, extra=extra) + + def _to_zones(self, items): + zones = [] + for item in items.object: + zones.append(self._to_zone(item)) + return zones + + def _to_record(self, item, zone): + return Record(id=None, name=item['name'], data=item['content'], + type=item['type'], zone=zone, driver=self, + ttl=item['ttl']) + + def _to_records(self, items, zone): + records = [] + for item in items.object['records']: + records.append(self._to_record(item, zone)) + return records http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/dns/providers.py ---------------------------------------------------------------------- diff --git a/libcloud/dns/providers.py b/libcloud/dns/providers.py index ad55385..38aa39f 100644 --- a/libcloud/dns/providers.py +++ b/libcloud/dns/providers.py @@ -73,6 +73,8 @@ DRIVERS = { ('libcloud.dns.drivers.luadns', 'LuadnsDNSDriver'), Provider.BUDDYNS: ('libcloud.dns.drivers.buddyns', 'BuddyNSDNSDriver'), + Provider.POWERDNS: + ('libcloud.dns.drivers.powerdns', 'PowerDNSDriver'), # Deprecated Provider.RACKSPACE_US: http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/dns/types.py ---------------------------------------------------------------------- diff --git a/libcloud/dns/types.py b/libcloud/dns/types.py index d1fac12..e6fc28d 100644 --- a/libcloud/dns/types.py +++ b/libcloud/dns/types.py @@ -53,6 +53,7 @@ class Provider(object): NFSN = 'nfsn' NSONE = 'nsone' POINTDNS = 'pointdns' + POWERDNS = 'powerdns' RACKSPACE = 'rackspace' ROUTE53 = 'route53' SOFTLAYER = 'softlayer' @@ -77,22 +78,31 @@ class RecordType(object): """ A = 'A' AAAA = 'AAAA' + AFSDB = 'A' ALIAS = 'ALIAS' + CERT = 'CERT' CNAME = 'CNAME' DNAME = 'DNAME' + DNSKEY = 'DNSKEY' + DS = 'DS' GEO = 'GEO' HINFO = 'HINFO' + KEY = 'KEY' LOC = 'LOC' MX = 'MX' NAPTR = 'NAPTR' NS = 'NS' + NSEC = 'NSEC' + OPENPGPKEY = 'OPENPGPKEY' PTR = 'PTR' REDIRECT = 'REDIRECT' RP = 'RP' + RRSIG = 'RRSIG' SOA = 'SOA' SPF = 'SPF' SRV = 'SRV' SSHFP = 'SSHFP' + TLSA = 'TLSA' TXT = 'TXT' URL = 'URL' WKS = 'WKS' http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/test/dns/fixtures/powerdns/list_records.json ---------------------------------------------------------------------- diff --git a/libcloud/test/dns/fixtures/powerdns/list_records.json b/libcloud/test/dns/fixtures/powerdns/list_records.json new file mode 100644 index 0000000..99ea2e0 --- /dev/null +++ b/libcloud/test/dns/fixtures/powerdns/list_records.json @@ -0,0 +1,49 @@ +{ + "id":"example.com.", + "url":"/servers/localhost/zones/example.com.", + "name":"example.com", + "kind":"Native", + "dnssec":false, + "account":"", + "masters":[ + + ], + "serial":2016041501, + "notified_serial":0, + "last_check":0, + "soa_edit_api":"", + "soa_edit":"", + "records":[ + { + "name":"example.com", + "type":"NS", + "ttl":3600, + "disabled":false, + "content":"ns1.example.com" + }, + { + "name":"example.com", + "type":"SOA", + "ttl":3600, + "disabled":false, + "content":"a.misconfigured.powerdns.server hostmaster.example.com 2016041501 10800 3600 604800 3600" + }, + { + "name":"www.example.com", + "type":"A", + "ttl":86400, + "disabled":false, + "content":"192.0.5.1" + }, + { + "name":"example.com", + "type":"A", + "ttl":300, + "disabled":false, + "content":"192.0.5.1" + } + ], + "comments":[ + + ] +} http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/test/dns/fixtures/powerdns/list_zones.json ---------------------------------------------------------------------- diff --git a/libcloud/test/dns/fixtures/powerdns/list_zones.json b/libcloud/test/dns/fixtures/powerdns/list_zones.json new file mode 100644 index 0000000..9db222b --- /dev/null +++ b/libcloud/test/dns/fixtures/powerdns/list_zones.json @@ -0,0 +1,30 @@ +[ + { + "id":"example.com.", + "url":"/servers/localhost/zones/example.com.", + "name":"example.com", + "kind":"Native", + "dnssec":false, + "account":"", + "masters":[ + + ], + "serial":1, + "notified_serial":0, + "last_check":0 + }, + { + "id":"example.net.", + "url":"/servers/localhost/zones/example.net.", + "name":"example.net", + "kind":"Native", + "dnssec":false, + "account":"", + "masters":[ + + ], + "serial":2016041501, + "notified_serial":0, + "last_check":0 + } +] http://git-wip-us.apache.org/repos/asf/libcloud/blob/7034ea8c/libcloud/test/dns/test_powerdns.py ---------------------------------------------------------------------- diff --git a/libcloud/test/dns/test_powerdns.py b/libcloud/test/dns/test_powerdns.py new file mode 100644 index 0000000..744676f --- /dev/null +++ b/libcloud/test/dns/test_powerdns.py @@ -0,0 +1,190 @@ +# 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 + +import sys +import unittest +import json + +from libcloud.utils.py3 import httplib + +from libcloud.dns.base import Record, Zone +from libcloud.dns.drivers.powerdns import PowerDNSDriver +from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError +from libcloud.dns.types import RecordType + +from libcloud.test import LibcloudTestCase, MockHttp +from libcloud.test.file_fixtures import DNSFileFixtures + + +class PowerDNSTestCase(LibcloudTestCase): + + def setUp(self): + PowerDNSDriver.connectionCls.conn_classes = (PowerDNSMockHttp, + PowerDNSMockHttp) + PowerDNSMockHttp.type = None + self.driver = PowerDNSDriver('testsecret') + + self.test_zone = Zone(id='example.com.', domain='example.com', + driver=self.driver, type='master', ttl=None, + extra={}) + self.test_record = Record(id=None, name='', data='192.0.2.1', + type=RecordType.A, zone=self.test_zone, + driver=self.driver, extra={}) + + def test_create_record(self): + record = self.test_zone.create_record(name='newrecord.example.com', + type=RecordType.A, + data='192.0.5.4', + extra={'ttl': 86400}) + self.assertEqual(record.id, None) + self.assertEqual(record.name, 'newrecord.example.com') + self.assertEqual(record.data, '192.0.5.4') + self.assertEqual(record.type, RecordType.A) + self.assertEqual(record.ttl, 86400) + + def test_create_zone(self): + extra = {'nameservers': ['ns1.example.org', 'ns2.example.org']} + zone = self.driver.create_zone('example.org', extra=extra) + self.assertEqual(zone.id, 'example.org.') + self.assertEqual(zone.domain, 'example.org') + self.assertEqual(zone.type, None) + self.assertEqual(zone.ttl, None) + + def test_delete_record(self): + self.assertTrue(self.test_record.delete()) + + def test_delete_zone(self): + self.assertTrue(self.test_zone.delete()) + + def test_get_record(self): + with self.assertRaises(NotImplementedError): + self.driver.get_record('example.com.', '12345') + + def test_get_zone(self): + zone = self.driver.get_zone('example.com.') + self.assertEqual(zone.id, 'example.com.') + self.assertEqual(zone.domain, 'example.com') + self.assertEqual(zone.type, None) + self.assertEqual(zone.ttl, None) + + def test_list_record_types(self): + result = self.driver.list_record_types() + self.assertEqual(len(result), 23) + + def test_list_records(self): + records = self.driver.list_records(self.test_zone) + self.assertEqual(len(records), 4) + + def test_list_zones(self): + zones = self.driver.list_zones() + self.assertEqual(zones[0].id, 'example.com.') + self.assertEqual(zones[0].domain, 'example.com') + self.assertEqual(zones[0].type, None) + self.assertEqual(zones[0].ttl, None) + self.assertEqual(zones[1].id, 'example.net.') + self.assertEqual(zones[1].domain, 'example.net') + self.assertEqual(zones[1].type, None) + self.assertEqual(zones[1].ttl, None) + + def test_update_record(self): + record = self.driver.update_record(self.test_record, + name='newrecord.example.com', + type=RecordType.A, + data='127.0.0.1', + extra={'ttl': 300}) + self.assertEqual(record.id, None) + self.assertEqual(record.name, 'newrecord.example.com') + self.assertEqual(record.data, '127.0.0.1') + self.assertEqual(record.type, RecordType.A) + self.assertEqual(record.ttl, 300) + + def test_update_zone(self): + with self.assertRaises(NotImplementedError): + self.driver.update_zone(self.test_zone, 'example.net') + + # Test some error conditions + + def test_create_existing_zone(self): + PowerDNSMockHttp.type = 'EXISTS' + extra = {'nameservers': ['ns1.example.com', 'ns2.example.com']} + with self.assertRaises(ZoneAlreadyExistsError): + self.driver.create_zone('example.com', extra=extra) + + def test_get_missing_zone(self): + PowerDNSMockHttp.type = 'MISSING' + with self.assertRaises(ZoneDoesNotExistError): + self.driver.get_zone('example.com.') + + def test_delete_missing_record(self): + PowerDNSMockHttp.type = 'MISSING' + self.assertFalse(self.test_record.delete()) + + def test_delete_missing_zone(self): + PowerDNSMockHttp.type = 'MISSING' + self.assertFalse(self.test_zone.delete()) + + +class PowerDNSMockHttp(MockHttp): + fixtures = DNSFileFixtures('powerdns') + base_headers = {'content-type': 'application/json'} + + def _servers_localhost_zones(self, method, url, body, headers): + if method == 'GET': + # list_zones() + body = self.fixtures.load('list_zones.json') + elif method == 'POST': + # create_zone() + # Don't bother with a fixture for this operation, because we do + # nothing with the parsed body anyway. + body = '' + else: + raise NotImplementedError('Unexpected method: %s' % method) + return (httplib.OK, body, self.base_headers, + httplib.responses[httplib.OK]) + + def _servers_localhost_zones_example_com_(self, method, *args, **kwargs): + if method == 'GET': + # list_records() + body = self.fixtures.load('list_records.json') + elif method == 'PATCH': + # create/update/delete_record() + # Don't bother with a fixture for these operations, because we do + # nothing with the parsed body anyway. + body = '' + elif method == 'DELETE': + # delete_zone() + return (httplib.NO_CONTENT, '', self.base_headers, + httplib.responses[httplib.NO_CONTENT]) + else: + raise NotImplementedError('Unexpected method: %s' % method) + return (httplib.OK, body, self.base_headers, + httplib.responses[httplib.OK]) + + def _servers_localhost_zones_EXISTS(self, method, url, body, headers): + # create_zone() is a POST. Raise on all other operations to be safe. + if method != 'POST': + raise NotImplementedError('Unexpected method: %s' % method) + payload = json.loads(body) + domain = payload['name'] + body = json.dumps({'error': "Domain '%s' already exists" % domain}) + return (httplib.UNPROCESSABLE_ENTITY, body, self.base_headers, + 'Unprocessable Entity') + + def _servers_localhost_zones_example_com__MISSING(self, *args, **kwargs): + return (httplib.UNPROCESSABLE_ENTITY, 'Could not find domain', + self.base_headers, 'Unprocessable Entity') + + +if __name__ == '__main__': + sys.exit(unittest.main())