Browse Source

Merge pull request #108 from trnsnt/ovh-support

Add OVH as octodns provider
pull/112/head
Ross McFarland 8 years ago
committed by GitHub
parent
commit
1659784438
4 changed files with 683 additions and 0 deletions
  1. +1
    -0
      README.md
  2. +322
    -0
      octodns/provider/ovh.py
  3. +1
    -0
      requirements.txt
  4. +359
    -0
      tests/test_octodns_provider_ovh.py

+ 1
- 0
README.md View File

@ -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 |


+ 322
- 0
octodns/provider/ovh.py View File

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

+ 1
- 0
requirements.txt View File

@ -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


+ 359
- 0
tests/test_octodns_provider_ovh.py View File

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

Loading…
Cancel
Save