From d24efb63a58422b21f661fdcf198a03acee8a215 Mon Sep 17 00:00:00 2001 From: Wido den Hollander Date: Thu, 2 Jul 2015 21:57:01 +0200 Subject: [PATCH] LIBCLOUD-735: Implementation of AuroraDNS driver AuroraDNS is the new DNS product of PCextreme under the cloud name 'Aurora'. --- docs/dns/drivers/auroradns.rst | 63 +++++ .../dns/auroradns/enable_disable_record.py | 13 + .../dns/auroradns/instantiate_driver.py | 6 + libcloud/dns/drivers/auroradns.py | 259 ++++++++++++++++++ libcloud/dns/providers.py | 4 +- libcloud/dns/types.py | 1 + 6 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 docs/dns/drivers/auroradns.rst create mode 100644 docs/examples/dns/auroradns/enable_disable_record.py create mode 100644 docs/examples/dns/auroradns/instantiate_driver.py create mode 100644 libcloud/dns/drivers/auroradns.py diff --git a/docs/dns/drivers/auroradns.rst b/docs/dns/drivers/auroradns.rst new file mode 100644 index 0000000000..56b1da83b8 --- /dev/null +++ b/docs/dns/drivers/auroradns.rst @@ -0,0 +1,63 @@ +AuroraDNS DNS driver documentation +================================== + +`PCextreme B.V.`_ is a Dutch cloud provider. It provides a public cloud offering +under the name AuroraCompute. All cloud services are under the family name +Aurora. + +`AuroraDNS`_ is a highly available DNS service which also provides health +checking. + +Records can be attached to a health check. When this health check becomes +unhealthy this record will no longer be served. + +This provides the possibility to create loadbalancing over multiple servers +without the requirement of a central loadbalancer in the network. It is also +provider agnostic, health checks can point to any IP/Host on the internet. + +IPv6 +---- +AuroraDNS fully supports IPv6: + +* DNS queries over IPv6 +* AAAA records +* Health checks over IPv6 + +Instantiating a driver +---------------------- + +When you instantiate a driver, you need to pass a your ``key`` and ``secret`` +to the driver constructor. These can be obtained in the control panel of +AuroraDNS. + +For example: + +.. literalinclude:: /examples/dns/auroradns/instantiate_driver.py + :language: python + +Disabling and enabling records +------------------------------ + +Records in can be disabled and enabled. By default all new records are enabled, +but this property can be set during creation and can be updated. + +For example: + +.. literalinclude:: /examples/dns/auroradns/instantiate_driver.py + :language: python + +In this example we create a record, but disable it. This means it will not be +served. + +Afterwards we enable the record and this make the DNS server serve this specific +record. + +API Docs +-------- + +.. autoclass:: libcloud.dns.drivers.auroradns.AuroraDNSDriver + :members: + :inherited-members: + +.. _`PCextreme B.V.`: https://www.pcextreme.nl/ +.. _`AuroraDNS`: https://www.pcextreme.nl/en/aurora/dns diff --git a/docs/examples/dns/auroradns/enable_disable_record.py b/docs/examples/dns/auroradns/enable_disable_record.py new file mode 100644 index 0000000000..a94c66b712 --- /dev/null +++ b/docs/examples/dns/auroradns/enable_disable_record.py @@ -0,0 +1,13 @@ +from libcloud.dns.types import Provider +from libcloud.dns.providers import get_driver + +cls = get_driver(Provider.AURORADNS) + +driver = cls('myapikey', 'mysecret') + +zone = driver.get_zone("auroradns.eu") + +record = zone.create_record(name="www", type="AAAA", data="2a00:f10:452::1", + ex_disabled=True) + +record.update(ex_disabled=False) diff --git a/docs/examples/dns/auroradns/instantiate_driver.py b/docs/examples/dns/auroradns/instantiate_driver.py new file mode 100644 index 0000000000..16906171b1 --- /dev/null +++ b/docs/examples/dns/auroradns/instantiate_driver.py @@ -0,0 +1,6 @@ +from libcloud.dns.types import Provider +from libcloud.dns.providers import get_driver + +cls = get_driver(Provider.AURORADNS) + +driver = cls('myapikey', 'mysecret') diff --git a/libcloud/dns/drivers/auroradns.py b/libcloud/dns/drivers/auroradns.py new file mode 100644 index 0000000000..c85e3d3843 --- /dev/null +++ b/libcloud/dns/drivers/auroradns.py @@ -0,0 +1,259 @@ +# 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 base64 +import json +import hmac +import datetime + +from hashlib import sha256 + +from libcloud.utils.py3 import httplib +from libcloud.utils.py3 import b + +from libcloud.common.base import ConnectionUserAndKey, JsonResponse + +from libcloud.common.types import InvalidCredsError, ProviderError + +from libcloud.dns.base import DNSDriver, Zone, Record +from libcloud.dns.types import RecordType, ZoneDoesNotExistError +from libcloud.dns.types import ZoneAlreadyExistsError, RecordDoesNotExistError + + +API_HOST = 'api.auroradns.eu' + +# Default TTL required by libcloud, but doesn't do anything in AuroraDNS +DEFAULT_ZONE_TTL = 3600 +DEFAULT_ZONE_TYPE = 'master' + +VALID_RECORD_PARAMS_EXTRA = ['ttl' 'prio', 'health_check_id', 'disabled'] + + +class AuroraDNSResponse(JsonResponse): + def success(self): + return self.status in [httplib.OK, httplib.CREATED, httplib.ACCEPTED] + + def parse_error(self): + status = int(self.status) + + if status == httplib.UNAUTHORIZED: + raise InvalidCredsError(value='Authentication failed', driver=self) + + elif status == httplib.FORBIDDEN: + raise ProviderError(value='Authorization failed', http_code=status, + driver=self) + + elif status == httplib.NOT_FOUND: + context = self.connection.context + if context['resource'] == 'zone': + raise ZoneDoesNotExistError(value='', driver=self, + zone_id=context['id']) + elif context['resource'] == 'record': + raise RecordDoesNotExistError(value='', driver=self, + record_id=context['id']) + + elif status == httplib.CONFLICT: + context = self.connection.context + if context['resource'] == 'zone': + raise ZoneAlreadyExistsError(value='', driver=self, + zone_id=context['id']) + + +class AuroraDNSConnection(ConnectionUserAndKey): + host = API_HOST + responseCls = AuroraDNSResponse + + def calculate_auth_signature(self, secret_key, method, url, timestamp): + b64_hmac = base64.b64encode( + hmac.new(b(secret_key), + b(method) + b(url) + b(timestamp), + digestmod=sha256).digest() + ) + + return b64_hmac.decode('utf-8') + + def gen_auth_header(self, api_key, secret_key, method, url, timestamp): + signature = self.calculate_auth_signature(secret_key, method, url, + timestamp) + + auth_b64 = base64.b64encode(b('%s:%s' % (api_key, signature))) + return 'AuroraDNSv1 %s' % (auth_b64.decode('utf-8')) + + def request(self, action, params=None, data='', headers=None, + method='GET'): + if not headers: + headers = {} + if not params: + params = {} + + if method in ("POST", "PUT"): + headers = {'Content-Type': 'application/json; charset=UTF-8'} + + t = datetime.datetime.utcnow() + timestamp = t.strftime('%Y%m%dT%H%M%SZ') + + headers['X-AuroraDNS-Date'] = timestamp + headers['Authorization'] = self.gen_auth_header(self.user_id, self.key, + method, action, + timestamp) + + return super(AuroraDNSConnection, self).request(action=action, + params=params, + data=data, + method=method, + headers=headers) + + +class AuroraDNSDriver(DNSDriver): + name = 'AuroraDNS' + website = 'https://www.pcextreme.nl/en/aurora/dns' + connectionCls = AuroraDNSConnection + + RECORD_TYPE_MAP = { + RecordType.A: 'A', + RecordType.AAAA: 'AAAA', + RecordType.CNAME: 'CNAME', + RecordType.MX: 'MX', + RecordType.NS: 'NS', + RecordType.SOA: 'SOA', + RecordType.SPF: 'SPF', + RecordType.SRV: 'SRV', + RecordType.TXT: 'TXT', + } + + def list_zones(self): + zones = [] + + res = self.connection.request('/zones') + for zone in res.parse_body(): + zones.append(self.__res_to_zone(zone)) + + return zones + + def list_records(self, zone): + records = [] + res = self.connection.request('/zones/%s/records' % zone.id) + + for record in res.parse_body(): + records.append(self.__res_to_record(zone, record)) + + return records + + def get_zone(self, zone_id): + self.connection.set_context({'resource': 'zone', 'id': zone_id}) + res = self.connection.request('/zones/%s' % zone_id) + zone = res.parse_body() + return self.__res_to_zone(zone) + + def get_record(self, zone_id, record_id): + self.connection.set_context({'resource': 'record', 'id': record_id}) + res = self.connection.request('/zones/%s/records/%s' % (zone_id, + record_id)) + record = res.parse_body() + + zone = self.get_zone(zone_id) + + return self.__res_to_record(zone, record) + + def create_zone(self, domain, type='master', ttl=None, extra=None): + self.connection.set_context({'resource': 'zone', 'id': domain}) + res = self.connection.request('/zones', method='POST', + data=json.dumps({'name': domain})) + zone = res.parse_body() + return self.__res_to_zone(zone) + + def create_record(self, name, zone, type, data, extra=None): + if name is None: + name = "" + + rdata = { + 'name': name, + 'type': type, + 'content': data + } + + rdata = self.__merge_extra_data(rdata, extra) + + self.connection.set_context({'resource': 'zone', 'id': zone.id}) + res = self.connection.request('/zones/%s/records' % zone.id, + method='POST', + data=json.dumps(rdata)) + + record = res.parse_body() + return self.__res_to_record(zone, record) + + def delete_zone(self, zone): + self.connection.request('/zones/%s' % zone.id, method='DELETE') + + def delete_record(self, record): + self.connection.set_context({'resource': 'record', 'id': record.id}) + self.connection.request('/zones/%s/records/%s' % (record.zone.id, + record.id), + method='DELETE') + + def update_record(self, record, name, type, data, extra): + rdata = {} + + if name is not None: + rdata['name'] = name + + if type is not None: + rdata['type'] = type + + if data is not None: + rdata['content'] = data + + rdata = self.__merge_extra_data(rdata, extra) + + self.connection.set_context({'resource': 'record', 'id': record.id}) + self.connection.request('/zones/%s/records/%s' % (record.zone.id, + record.id), + method='PUT', + data=json.dumps(rdata)) + + return self.get_record(record.zone.id, record.id) + + def __res_to_record(self, zone, record): + if len(record['name']) == 0: + name = None + else: + name = record['name'] + + extra = {} + extra['created'] = record['created'] + extra['modified'] = record['modified'] + extra['disabled'] = record['disabled'] + extra['ttl'] = record['ttl'] + extra['prio'] = record['prio'] + + return Record(id=record['id'], name=name, type=record['type'], + data=record['content'], zone=zone, driver=self, + extra=extra) + + def __res_to_zone(self, zone): + return Zone(id=zone['id'], domain=zone['name'], type=DEFAULT_ZONE_TYPE, + ttl=DEFAULT_ZONE_TTL, driver=self, + extra={'created': zone['created'], + 'servers': zone['servers'], + 'account_id': zone['account_id'], + 'cluster_id': zone['cluster_id']}) + + def __merge_extra_data(self, rdata, extra): + if extra is not None: + for param in VALID_RECORD_PARAMS_EXTRA: + if param in extra: + rdata[param] = extra[param] + + return rdata diff --git a/libcloud/dns/providers.py b/libcloud/dns/providers.py index a6c92a800c..aef0aa7bd7 100644 --- a/libcloud/dns/providers.py +++ b/libcloud/dns/providers.py @@ -41,7 +41,9 @@ Provider.RACKSPACE_US: ('libcloud.dns.drivers.rackspace', 'RackspaceUSDNSDriver'), Provider.RACKSPACE_UK: - ('libcloud.dns.drivers.rackspace', 'RackspaceUKDNSDriver') + ('libcloud.dns.drivers.rackspace', 'RackspaceUKDNSDriver'), + Provider.AURORADNS: + ('libcloud.dns.drivers.auroradns', 'AuroraDNSDriver') } diff --git a/libcloud/dns/types.py b/libcloud/dns/types.py index cd1ae857d0..ad2b34a613 100644 --- a/libcloud/dns/types.py +++ b/libcloud/dns/types.py @@ -38,6 +38,7 @@ class Provider(object): GOOGLE = 'google' SOFTLAYER = 'softlayer' DIGITAL_OCEAN = 'digitalocean' + AURORADNS = 'auroradns' # Deprecated RACKSPACE_US = 'rackspace_us'