From 8a13ccab466c9ab9e641959238e1de18355f1f6d Mon Sep 17 00:00:00 2001 From: trnsnt Date: Mon, 18 Sep 2017 14:59:41 +0200 Subject: [PATCH] Add OVH as octodns provider --- README.md | 1 + octodns/provider/ovh.py | 322 ++++++++++++++++++++++++++ requirements.txt | 1 + tests/test_octodns_provider_ovh.py | 359 +++++++++++++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 octodns/provider/ovh.py create mode 100644 tests/test_octodns_provider_ovh.py diff --git a/README.md b/README.md index 1f103f1..afe92ac 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ The above command pulled the existing data out of Route53 and placed the results | [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | CAA tags restricted | | [DynProvider](/octodns/provider/dyn.py) | All | Yes | | | [Ns1Provider](/octodns/provider/ns1.py) | All | No | | +| [OVH](/octodns/provider/ovh.py) | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [PowerDnsProvider](/octodns/provider/powerdns.py) | All | No | | | [Route53](/octodns/provider/route53.py) | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | Yes | | | [TinyDNSSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only | diff --git a/octodns/provider/ovh.py b/octodns/provider/ovh.py new file mode 100644 index 0000000..b890862 --- /dev/null +++ b/octodns/provider/ovh.py @@ -0,0 +1,322 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import logging +from collections import defaultdict + +import ovh + +from octodns.record import Record +from .base import BaseProvider + + +class OvhProvider(BaseProvider): + """ + OVH provider using API v6 + + ovh: + class: octodns.provider.ovh.OvhProvider + # OVH api v6 endpoint + endpoint: ovh-eu + # API application key + application_key: 1234 + # API application secret + application_secret: 1234 + # API consumer key + consumer_key: 1234 + """ + + SUPPORTS_GEO = False + + SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', + 'SRV', 'SSHFP', 'TXT')) + + def __init__(self, id, endpoint, application_key, application_secret, + consumer_key, *args, **kwargs): + self.log = logging.getLogger('OvhProvider[{}]'.format(id)) + self.log.debug('__init__: id=%s, endpoint=%s, application_key=%s, ' + 'application_secret=***, consumer_key=%s', id, endpoint, + application_key, consumer_key) + super(OvhProvider, self).__init__(id, *args, **kwargs) + self._client = ovh.Client( + endpoint=endpoint, + application_key=application_key, + application_secret=application_secret, + consumer_key=consumer_key, + ) + + def populate(self, zone, target=False, lenient=False): + self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, + target, lenient) + zone_name = zone.name[:-1] + records = self.get_records(zone_name=zone_name) + + values = defaultdict(lambda: defaultdict(list)) + for record in records: + values[record['subDomain']][record['fieldType']].append(record) + + before = len(zone.records) + for name, types in values.items(): + for _type, records in types.items(): + data_for = getattr(self, '_data_for_{}'.format(_type)) + record = Record.new(zone, name, data_for(_type, records), + source=self, lenient=lenient) + zone.add_record(record) + + self.log.info('populate: found %s records', + len(zone.records) - before) + + def _apply(self, plan): + desired = plan.desired + changes = plan.changes + zone_name = desired.name[:-1] + self.log.info('_apply: zone=%s, len(changes)=%d', desired.name, + len(changes)) + for change in changes: + class_name = change.__class__.__name__ + getattr(self, '_apply_{}'.format(class_name).lower())(zone_name, + change) + + # We need to refresh the zone to really apply the changes + self._client.post('/domain/zone/{}/refresh'.format(zone_name)) + + def _apply_create(self, zone_name, change): + new = change.new + params_for = getattr(self, '_params_for_{}'.format(new._type)) + for params in params_for(new): + self.create_record(zone_name, params) + + def _apply_update(self, zone_name, change): + self._apply_delete(zone_name, change) + self._apply_create(zone_name, change) + + def _apply_delete(self, zone_name, change): + existing = change.existing + self.delete_records(zone_name, existing._type, existing.name) + + @staticmethod + def _data_for_multiple(_type, records): + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': [record['target'] for record in records] + } + + @staticmethod + def _data_for_single(_type, records): + record = records[0] + return { + 'ttl': record['ttl'], + 'type': _type, + 'value': record['target'] + } + + @staticmethod + def _data_for_MX(_type, records): + values = [] + for record in records: + preference, exchange = record['target'].split(' ', 1) + values.append({ + 'preference': preference, + 'exchange': exchange, + }) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values, + } + + @staticmethod + def _data_for_NAPTR(_type, records): + values = [] + for record in records: + order, preference, flags, service, regexp, replacement = record[ + 'target'].split(' ', 5) + values.append({ + 'flags': flags[1:-1], + 'order': order, + 'preference': preference, + 'regexp': regexp[1:-1], + 'replacement': replacement, + 'service': service[1:-1], + }) + return { + 'type': _type, + 'ttl': records[0]['ttl'], + 'values': values + } + + @staticmethod + def _data_for_SRV(_type, records): + values = [] + for record in records: + priority, weight, port, target = record['target'].split(' ', 3) + values.append({ + 'port': port, + 'priority': priority, + 'target': '{}.'.format(target), + 'weight': weight + }) + return { + 'type': _type, + 'ttl': records[0]['ttl'], + 'values': values + } + + @staticmethod + def _data_for_SSHFP(_type, records): + values = [] + for record in records: + algorithm, fingerprint_type, fingerprint = record['target'].split( + ' ', 2) + values.append({ + 'algorithm': algorithm, + 'fingerprint': fingerprint, + 'fingerprint_type': fingerprint_type + }) + return { + 'type': _type, + 'ttl': records[0]['ttl'], + 'values': values + } + + _data_for_A = _data_for_multiple + _data_for_AAAA = _data_for_multiple + _data_for_NS = _data_for_multiple + _data_for_TXT = _data_for_multiple + _data_for_SPF = _data_for_multiple + _data_for_PTR = _data_for_single + _data_for_CNAME = _data_for_single + + @staticmethod + def _params_for_multiple(record): + for value in record.values: + yield { + 'target': value, + 'subDomain': record.name, + 'ttl': record.ttl, + 'fieldType': record._type, + } + + @staticmethod + def _params_for_single(record): + yield { + 'target': record.value, + 'subDomain': record.name, + 'ttl': record.ttl, + 'fieldType': record._type + } + + @staticmethod + def _params_for_MX(record): + for value in record.values: + yield { + 'target': '%d %s' % (value.preference, value.exchange), + 'subDomain': record.name, + 'ttl': record.ttl, + 'fieldType': record._type + } + + @staticmethod + def _params_for_NAPTR(record): + for value in record.values: + content = '{} {} "{}" "{}" "{}" {}' \ + .format(value.order, value.preference, value.flags, + value.service, value.regexp, value.replacement) + yield { + 'target': content, + 'subDomain': record.name, + 'ttl': record.ttl, + 'fieldType': record._type + } + + @staticmethod + def _params_for_SRV(record): + for value in record.values: + yield { + 'subDomain': '{} {} {} {}'.format(value.priority, + value.weight, value.port, + value.target), + 'target': record.name, + 'ttl': record.ttl, + 'fieldType': record._type + } + + @staticmethod + def _params_for_SSHFP(record): + for value in record.values: + yield { + 'subDomain': '{} {} {}'.format(value.algorithm, + value.fingerprint_type, + value.fingerprint), + 'target': record.name, + 'ttl': record.ttl, + 'fieldType': record._type + } + + _params_for_A = _params_for_multiple + _params_for_AAAA = _params_for_multiple + _params_for_NS = _params_for_multiple + _params_for_SPF = _params_for_multiple + _params_for_TXT = _params_for_multiple + + _params_for_CNAME = _params_for_single + _params_for_PTR = _params_for_single + + def get_records(self, zone_name): + """ + List all records of a DNS zone + :param zone_name: Name of zone + :return: list of id's records + """ + records = self._client.get('/domain/zone/{}/record'.format(zone_name)) + return [self.get_record(zone_name, record_id) for record_id in records] + + def get_record(self, zone_name, record_id): + """ + Get record with given id + :param zone_name: Name of the zone + :param record_id: Id of the record + :return: Value of the record + """ + return self._client.get( + '/domain/zone/{}/record/{}'.format(zone_name, record_id)) + + def delete_records(self, zone_name, record_type, subdomain): + """ + Delete record from have fieldType=type and subDomain=subdomain + :param zone_name: Name of the zone + :param record_type: fieldType + :param subdomain: subDomain + """ + records = self._client.get('/domain/zone/{}/record'.format(zone_name), + fieldType=record_type, subDomain=subdomain) + for record in records: + self.delete_record(zone_name, record) + + def delete_record(self, zone_name, record_id): + """ + Delete record with a given id + :param zone_name: Name of the zone + :param record_id: Id of the record + """ + self.log.debug('Delete record: zone: %s, id %s', zone_name, + record_id) + self._client.delete( + '/domain/zone/{}/record/{}'.format(zone_name, record_id)) + + def create_record(self, zone_name, params): + """ + Create a record + :param zone_name: Name of the zone + :param params: {'fieldType': 'A', 'ttl': 60, 'subDomain': 'www', + 'target': '1.2.3.4' + """ + self.log.debug('Create record: zone: %s, id %s', zone_name, + params) + return self._client.post('/domain/zone/{}/record'.format(zone_name), + **params) diff --git a/requirements.txt b/requirements.txt index d2be70f..a7d1d94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ jmespath==0.9.3 msrestazure==0.4.10 natsort==5.0.3 nsone==0.9.14 +ovh==0.4.7 python-dateutil==2.6.1 requests==2.13.0 s3transfer==0.1.10 diff --git a/tests/test_octodns_provider_ovh.py b/tests/test_octodns_provider_ovh.py new file mode 100644 index 0000000..2816748 --- /dev/null +++ b/tests/test_octodns_provider_ovh.py @@ -0,0 +1,359 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +from mock import patch, call +from ovh import APIError + +from octodns.provider.ovh import OvhProvider +from octodns.record import Record +from octodns.zone import Zone + + +class TestOvhProvider(TestCase): + api_record = [] + + zone = Zone('unit.tests.', []) + expected = set() + + # A, subdomain='' + api_record.append({ + 'fieldType': 'A', + 'ttl': 100, + 'target': '1.2.3.4', + 'subDomain': '', + 'id': 1 + }) + expected.add(Record.new(zone, '', { + 'ttl': 100, + 'type': 'A', + 'value': '1.2.3.4', + })) + + # A, subdomain='sub + api_record.append({ + 'fieldType': 'A', + 'ttl': 200, + 'target': '1.2.3.4', + 'subDomain': 'sub', + 'id': 2 + }) + expected.add(Record.new(zone, 'sub', { + 'ttl': 200, + 'type': 'A', + 'value': '1.2.3.4', + })) + + # CNAME + api_record.append({ + 'fieldType': 'CNAME', + 'ttl': 300, + 'target': 'unit.tests.', + 'subDomain': 'www2', + 'id': 3 + }) + expected.add(Record.new(zone, 'www2', { + 'ttl': 300, + 'type': 'CNAME', + 'value': 'unit.tests.', + })) + + # MX + api_record.append({ + 'fieldType': 'MX', + 'ttl': 400, + 'target': '10 mx1.unit.tests.', + 'subDomain': '', + 'id': 4 + }) + expected.add(Record.new(zone, '', { + 'ttl': 400, + 'type': 'MX', + 'values': [{ + 'preference': 10, + 'exchange': 'mx1.unit.tests.', + }] + })) + + # NAPTR + api_record.append({ + 'fieldType': 'NAPTR', + 'ttl': 500, + 'target': '10 100 "S" "SIP+D2U" "!^.*$!sip:info@bar.example.com!" .', + 'subDomain': 'naptr', + 'id': 5 + }) + expected.add(Record.new(zone, 'naptr', { + 'ttl': 500, + 'type': 'NAPTR', + 'values': [{ + 'flags': 'S', + 'order': 10, + 'preference': 100, + 'regexp': '!^.*$!sip:info@bar.example.com!', + 'replacement': '.', + 'service': 'SIP+D2U', + }] + })) + + # NS + api_record.append({ + 'fieldType': 'NS', + 'ttl': 600, + 'target': 'ns1.unit.tests.', + 'subDomain': '', + 'id': 6 + }) + api_record.append({ + 'fieldType': 'NS', + 'ttl': 600, + 'target': 'ns2.unit.tests.', + 'subDomain': '', + 'id': 7 + }) + expected.add(Record.new(zone, '', { + 'ttl': 600, + 'type': 'NS', + 'values': ['ns1.unit.tests.', 'ns2.unit.tests.'], + })) + + # NS with sub + api_record.append({ + 'fieldType': 'NS', + 'ttl': 700, + 'target': 'ns3.unit.tests.', + 'subDomain': 'www3', + 'id': 8 + }) + api_record.append({ + 'fieldType': 'NS', + 'ttl': 700, + 'target': 'ns4.unit.tests.', + 'subDomain': 'www3', + 'id': 9 + }) + expected.add(Record.new(zone, 'www3', { + 'ttl': 700, + 'type': 'NS', + 'values': ['ns3.unit.tests.', 'ns4.unit.tests.'], + })) + + api_record.append({ + 'fieldType': 'SRV', + 'ttl': 800, + 'target': '10 20 30 foo-1.unit.tests.', + 'subDomain': '_srv._tcp', + 'id': 10 + }) + api_record.append({ + 'fieldType': 'SRV', + 'ttl': 800, + 'target': '40 50 60 foo-2.unit.tests.', + 'subDomain': '_srv._tcp', + 'id': 11 + }) + expected.add(Record.new(zone, '_srv._tcp', { + 'ttl': 800, + 'type': 'SRV', + 'values': [{ + 'priority': 10, + 'weight': 20, + 'port': 30, + 'target': 'foo-1.unit.tests.', + }, { + 'priority': 40, + 'weight': 50, + 'port': 60, + 'target': 'foo-2.unit.tests.', + }] + })) + + # PTR + api_record.append({ + 'fieldType': 'PTR', + 'ttl': 900, + 'target': 'unit.tests.', + 'subDomain': '4', + 'id': 12 + }) + expected.add(Record.new(zone, '4', { + 'ttl': 900, + 'type': 'PTR', + 'value': 'unit.tests.' + })) + + # SPF + api_record.append({ + 'fieldType': 'SPF', + 'ttl': 1000, + 'target': 'v=spf1 include:unit.texts.rerirect ~all', + 'subDomain': '', + 'id': 13 + }) + expected.add(Record.new(zone, '', { + 'ttl': 1000, + 'type': 'SPF', + 'value': 'v=spf1 include:unit.texts.rerirect ~all' + })) + + # SSHFP + api_record.append({ + 'fieldType': 'SSHFP', + 'ttl': 1100, + 'target': '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73 ', + 'subDomain': '', + 'id': 14 + }) + expected.add(Record.new(zone, '', { + 'ttl': 1100, + 'type': 'SSHFP', + 'value': { + 'algorithm': 1, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + 'fingerprint_type': 1 + } + })) + + # AAAA + api_record.append({ + 'fieldType': 'AAAA', + 'ttl': 1200, + 'target': '1:1ec:1::1', + 'subDomain': '', + 'id': 15 + }) + expected.add(Record.new(zone, '', { + 'ttl': 200, + 'type': 'AAAA', + 'value': '1:1ec:1::1', + })) + + @patch('ovh.Client') + def test_populate(self, client_mock): + provider = OvhProvider('test', 'endpoint', 'application_key', + 'application_secret', 'consumer_key') + + with patch.object(provider._client, 'get') as get_mock: + zone = Zone('unit.tests.', []) + get_mock.side_effect = APIError('boom') + with self.assertRaises(APIError) as ctx: + provider.populate(zone) + self.assertEquals(get_mock.side_effect, ctx.exception) + + with patch.object(provider._client, 'get') as get_mock: + zone = Zone('unit.tests.', []) + get_returns = [[record['id'] for record in self.api_record]] + get_returns += self.api_record + get_mock.side_effect = get_returns + provider.populate(zone) + self.assertEquals(self.expected, zone.records) + + @patch('ovh.Client') + def test_apply(self, client_mock): + provider = OvhProvider('test', 'endpoint', 'application_key', + 'application_secret', 'consumer_key') + + desired = Zone('unit.tests.', []) + + for r in self.expected: + desired.add_record(r) + + with patch.object(provider._client, 'post') as get_mock: + plan = provider.plan(desired) + get_mock.side_effect = APIError('boom') + with self.assertRaises(APIError) as ctx: + provider.apply(plan) + self.assertEquals(get_mock.side_effect, ctx.exception) + + with patch.object(provider._client, 'get') as get_mock: + get_returns = [[1, 2], { + 'fieldType': 'A', + 'ttl': 600, + 'target': '5.6.7.8', + 'subDomain': '', + 'id': 100 + }, {'fieldType': 'A', + 'ttl': 600, + 'target': '5.6.7.8', + 'subDomain': 'fake', + 'id': 101 + }] + get_mock.side_effect = get_returns + + plan = provider.plan(desired) + + with patch.object(provider._client, 'post') as post_mock: + with patch.object(provider._client, 'delete') as delete_mock: + with patch.object(provider._client, 'get') as get_mock: + get_mock.side_effect = [[100], [101]] + provider.apply(plan) + wanted_calls = [ + call(u'/domain/zone/unit.tests/record', + fieldType=u'A', + subDomain=u'', target=u'1.2.3.4', ttl=100), + call(u'/domain/zone/unit.tests/record', + fieldType=u'SRV', + subDomain=u'10 20 30 foo-1.unit.tests.', + target='_srv._tcp', ttl=800), + call(u'/domain/zone/unit.tests/record', + fieldType=u'SRV', + subDomain=u'40 50 60 foo-2.unit.tests.', + target='_srv._tcp', ttl=800), + call(u'/domain/zone/unit.tests/record', + fieldType=u'PTR', subDomain='4', + target=u'unit.tests.', ttl=900), + call(u'/domain/zone/unit.tests/record', + fieldType=u'NS', subDomain='www3', + target=u'ns3.unit.tests.', ttl=700), + call(u'/domain/zone/unit.tests/record', + fieldType=u'NS', subDomain='www3', + target=u'ns4.unit.tests.', ttl=700), + call(u'/domain/zone/unit.tests/record', + fieldType=u'SSHFP', + subDomain=u'1 1 bf6b6825d2977c511a475bbefb88a' + u'ad54' + u'a92ac73', + target=u'', ttl=1100), + call(u'/domain/zone/unit.tests/record', + fieldType=u'AAAA', subDomain=u'', + target=u'1:1ec:1::1', ttl=200), + call(u'/domain/zone/unit.tests/record', + fieldType=u'MX', subDomain=u'', + target=u'10 mx1.unit.tests.', ttl=400), + call(u'/domain/zone/unit.tests/record', + fieldType=u'CNAME', subDomain='www2', + target=u'unit.tests.', ttl=300), + call(u'/domain/zone/unit.tests/record', + fieldType=u'SPF', subDomain=u'', + target=u'v=spf1 include:unit.texts.' + u'rerirect ~all', + ttl=1000), + call(u'/domain/zone/unit.tests/record', + fieldType=u'A', + subDomain='sub', target=u'1.2.3.4', ttl=200), + call(u'/domain/zone/unit.tests/record', + fieldType=u'NAPTR', subDomain='naptr', + target=u'10 100 "S" "SIP+D2U" "!^.*$!sip:' + u'info@bar' + u'.example.com!" .', + ttl=500), + call(u'/domain/zone/unit.tests/refresh')] + + post_mock.assert_has_calls(wanted_calls) + + # Get for delete calls + get_mock.assert_has_calls( + [call(u'/domain/zone/unit.tests/record', + fieldType=u'A', subDomain=u''), + call(u'/domain/zone/unit.tests/record', + fieldType=u'A', subDomain='fake')] + ) + # 2 delete calls, one for update + one for delete + delete_mock.assert_has_calls( + [call(u'/domain/zone/unit.tests/record/100'), + call(u'/domain/zone/unit.tests/record/101')])