mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
Merge pull request #33 from github/nsone-basic-support
First pass through NsOneProvider
This commit is contained in:
202
octodns/provider/ns1.py
Normal file
202
octodns/provider/ns1.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from logging import getLogger
|
||||
from nsone import NSONE
|
||||
from nsone.rest.errors import ResourceException
|
||||
|
||||
from ..record import Record
|
||||
from .base import BaseProvider
|
||||
|
||||
|
||||
class Ns1Provider(BaseProvider):
|
||||
'''
|
||||
Ns1 provider
|
||||
|
||||
nsone:
|
||||
class: octodns.provider.nsone.Ns1Provider
|
||||
api_key: env/NS1_API_KEY
|
||||
'''
|
||||
SUPPORTS_GEO = False
|
||||
ZONE_NOT_FOUND_MESSAGE = 'server error: zone not found'
|
||||
|
||||
def __init__(self, id, api_key, *args, **kwargs):
|
||||
self.log = getLogger('Ns1Provider[{}]'.format(id))
|
||||
self.log.debug('__init__: id=%s, api_key=***', id)
|
||||
super(Ns1Provider, self).__init__(id, *args, **kwargs)
|
||||
self._client = NSONE(apiKey=api_key)
|
||||
|
||||
def _data_for_A(self, _type, record):
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'values': record['short_answers'],
|
||||
}
|
||||
|
||||
_data_for_AAAA = _data_for_A
|
||||
_data_for_SPF = _data_for_A
|
||||
_data_for_TXT = _data_for_A
|
||||
|
||||
def _data_for_CNAME(self, _type, record):
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'value': record['short_answers'][0],
|
||||
}
|
||||
|
||||
_data_for_PTR = _data_for_CNAME
|
||||
|
||||
def _data_for_MX(self, _type, record):
|
||||
values = []
|
||||
for answer in record['short_answers']:
|
||||
priority, value = answer.split(' ', 1)
|
||||
values.append({
|
||||
'priority': priority,
|
||||
'value': value,
|
||||
})
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'values': values,
|
||||
}
|
||||
|
||||
def _data_for_NAPTR(self, _type, record):
|
||||
values = []
|
||||
for answer in record['short_answers']:
|
||||
order, preference, flags, service, regexp, replacement = \
|
||||
answer.split(' ', 5)
|
||||
values.append({
|
||||
'flags': flags,
|
||||
'order': order,
|
||||
'preference': preference,
|
||||
'regexp': regexp,
|
||||
'replacement': replacement,
|
||||
'service': service,
|
||||
})
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'values': values,
|
||||
}
|
||||
|
||||
def _data_for_NS(self, _type, record):
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'values': [a if a.endswith('.') else '{}.'.format(a)
|
||||
for a in record['short_answers']],
|
||||
}
|
||||
|
||||
def _data_for_SRV(self, _type, record):
|
||||
values = []
|
||||
for answer in record['short_answers']:
|
||||
priority, weight, port, target = answer.split(' ', 3)
|
||||
values.append({
|
||||
'priority': priority,
|
||||
'weight': weight,
|
||||
'port': port,
|
||||
'target': target,
|
||||
})
|
||||
return {
|
||||
'ttl': record['ttl'],
|
||||
'type': _type,
|
||||
'values': values,
|
||||
}
|
||||
|
||||
def populate(self, zone, target=False):
|
||||
self.log.debug('populate: name=%s', zone.name)
|
||||
|
||||
try:
|
||||
nsone_zone = self._client.loadZone(zone.name[:-1])
|
||||
records = nsone_zone.data['records']
|
||||
except ResourceException as e:
|
||||
if e.message != self.ZONE_NOT_FOUND_MESSAGE:
|
||||
raise
|
||||
records = []
|
||||
|
||||
before = len(zone.records)
|
||||
for record in records:
|
||||
_type = record['type']
|
||||
data_for = getattr(self, '_data_for_{}'.format(_type))
|
||||
name = zone.hostname_from_fqdn(record['domain'])
|
||||
record = Record.new(zone, name, data_for(_type, record))
|
||||
zone.add_record(record)
|
||||
|
||||
self.log.info('populate: found %s records',
|
||||
len(zone.records) - before)
|
||||
|
||||
def _params_for_A(self, record):
|
||||
return {'answers': record.values, 'ttl': record.ttl}
|
||||
|
||||
_params_for_AAAA = _params_for_A
|
||||
_params_for_NS = _params_for_A
|
||||
_params_for_SPF = _params_for_A
|
||||
_params_for_TXT = _params_for_A
|
||||
|
||||
def _params_for_CNAME(self, record):
|
||||
return {'answers': [record.value], 'ttl': record.ttl}
|
||||
|
||||
_params_for_PTR = _params_for_CNAME
|
||||
|
||||
def _params_for_MX(self, record):
|
||||
values = [(v.priority, v.value) for v in record.values]
|
||||
return {'answers': values, 'ttl': record.ttl}
|
||||
|
||||
def _params_for_NAPTR(self, record):
|
||||
values = [(v.order, v.preference, v.flags, v.service, v.regexp,
|
||||
v.replacement) for v in record.values]
|
||||
return {'answers': values, 'ttl': record.ttl}
|
||||
|
||||
def _params_for_SRV(self, record):
|
||||
values = [(v.priority, v.weight, v.port, v.target)
|
||||
for v in record.values]
|
||||
return {'answers': values, 'ttl': record.ttl}
|
||||
|
||||
def _get_name(self, record):
|
||||
return record.fqdn[:-1] if record.name == '' else record.name
|
||||
|
||||
def _apply_Create(self, nsone_zone, change):
|
||||
new = change.new
|
||||
name = self._get_name(new)
|
||||
_type = new._type
|
||||
params = getattr(self, '_params_for_{}'.format(_type))(new)
|
||||
getattr(nsone_zone, 'add_{}'.format(_type))(name, **params)
|
||||
|
||||
def _apply_Update(self, nsone_zone, change):
|
||||
existing = change.existing
|
||||
name = self._get_name(existing)
|
||||
_type = existing._type
|
||||
record = nsone_zone.loadRecord(name, _type)
|
||||
new = change.new
|
||||
params = getattr(self, '_params_for_{}'.format(_type))(new)
|
||||
record.update(**params)
|
||||
|
||||
def _apply_Delete(self, nsone_zone, change):
|
||||
existing = change.existing
|
||||
name = self._get_name(existing)
|
||||
_type = existing._type
|
||||
record = nsone_zone.loadRecord(name, _type)
|
||||
record.delete()
|
||||
|
||||
def _apply(self, plan):
|
||||
desired = plan.desired
|
||||
changes = plan.changes
|
||||
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
|
||||
len(changes))
|
||||
|
||||
domain_name = desired.name[:-1]
|
||||
try:
|
||||
nsone_zone = self._client.loadZone(domain_name)
|
||||
except ResourceException as e:
|
||||
if e.message != self.ZONE_NOT_FOUND_MESSAGE:
|
||||
raise
|
||||
self.log.debug('_apply: no matching zone, creating')
|
||||
nsone_zone = self._client.createZone(domain_name)
|
||||
|
||||
for change in changes:
|
||||
class_name = change.__class__.__name__
|
||||
getattr(self, '_apply_{}'.format(class_name))(nsone_zone, change)
|
||||
@@ -10,6 +10,7 @@ futures==3.0.5
|
||||
incf.countryutils==1.0
|
||||
ipaddress==1.0.18
|
||||
jmespath==0.9.0
|
||||
nsone==0.9.10
|
||||
python-dateutil==2.6.0
|
||||
requests==2.13.0
|
||||
s3transfer==0.1.10
|
||||
|
||||
256
tests/test_octodns_provider_ns1.py
Normal file
256
tests/test_octodns_provider_ns1.py
Normal file
@@ -0,0 +1,256 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from mock import Mock, call, patch
|
||||
from nsone.rest.errors import AuthException, ResourceException
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Delete, Record, Update
|
||||
from octodns.provider.ns1 import Ns1Provider
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class DummyZone(object):
|
||||
|
||||
def __init__(self, records):
|
||||
self.data = {
|
||||
'records': records
|
||||
}
|
||||
|
||||
|
||||
class TestNs1Provider(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
expected = set()
|
||||
expected.add(Record.new(zone, '', {
|
||||
'ttl': 32,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.4',
|
||||
}))
|
||||
expected.add(Record.new(zone, 'foo', {
|
||||
'ttl': 33,
|
||||
'type': 'A',
|
||||
'values': ['1.2.3.4', '1.2.3.5'],
|
||||
}))
|
||||
expected.add(Record.new(zone, 'cname', {
|
||||
'ttl': 34,
|
||||
'type': 'CNAME',
|
||||
'value': 'foo.unit.tests.',
|
||||
}))
|
||||
expected.add(Record.new(zone, '', {
|
||||
'ttl': 35,
|
||||
'type': 'MX',
|
||||
'values': [{
|
||||
'priority': 10,
|
||||
'value': 'mx1.unit.tests.',
|
||||
}, {
|
||||
'priority': 20,
|
||||
'value': 'mx2.unit.tests.',
|
||||
}]
|
||||
}))
|
||||
expected.add(Record.new(zone, 'naptr', {
|
||||
'ttl': 36,
|
||||
'type': 'NAPTR',
|
||||
'values': [{
|
||||
'flags': 'U',
|
||||
'order': 100,
|
||||
'preference': 100,
|
||||
'regexp': '!^.*$!sip:info@bar.example.com!',
|
||||
'replacement': '.',
|
||||
'service': 'SIP+D2U',
|
||||
}, {
|
||||
'flags': 'S',
|
||||
'order': 10,
|
||||
'preference': 100,
|
||||
'regexp': '!^.*$!sip:info@bar.example.com!',
|
||||
'replacement': '.',
|
||||
'service': 'SIP+D2U',
|
||||
}]
|
||||
}))
|
||||
expected.add(Record.new(zone, '', {
|
||||
'ttl': 37,
|
||||
'type': 'NS',
|
||||
'values': ['ns1.unit.tests.', 'ns2.unit.tests.'],
|
||||
}))
|
||||
expected.add(Record.new(zone, '_srv._tcp', {
|
||||
'ttl': 38,
|
||||
'type': 'SRV',
|
||||
'values': [{
|
||||
'priority': 10,
|
||||
'weight': 20,
|
||||
'port': 30,
|
||||
'target': 'foo-1.unit.tests.',
|
||||
}, {
|
||||
'priority': 12,
|
||||
'weight': 30,
|
||||
'port': 30,
|
||||
'target': 'foo-2.unit.tests.',
|
||||
}]
|
||||
}))
|
||||
expected.add(Record.new(zone, 'sub', {
|
||||
'ttl': 39,
|
||||
'type': 'NS',
|
||||
'values': ['ns3.unit.tests.', 'ns4.unit.tests.'],
|
||||
}))
|
||||
|
||||
nsone_records = [{
|
||||
'type': 'A',
|
||||
'ttl': 32,
|
||||
'short_answers': ['1.2.3.4'],
|
||||
'domain': 'unit.tests.',
|
||||
}, {
|
||||
'type': 'A',
|
||||
'ttl': 33,
|
||||
'short_answers': ['1.2.3.4', '1.2.3.5'],
|
||||
'domain': 'foo.unit.tests.',
|
||||
}, {
|
||||
'type': 'CNAME',
|
||||
'ttl': 34,
|
||||
'short_answers': ['foo.unit.tests.'],
|
||||
'domain': 'cname.unit.tests.',
|
||||
}, {
|
||||
'type': 'MX',
|
||||
'ttl': 35,
|
||||
'short_answers': ['10 mx1.unit.tests.', '20 mx2.unit.tests.'],
|
||||
'domain': 'unit.tests.',
|
||||
}, {
|
||||
'type': 'NAPTR',
|
||||
'ttl': 36,
|
||||
'short_answers': [
|
||||
'10 100 S SIP+D2U !^.*$!sip:info@bar.example.com! .',
|
||||
'100 100 U SIP+D2U !^.*$!sip:info@bar.example.com! .'
|
||||
],
|
||||
'domain': 'naptr.unit.tests.',
|
||||
}, {
|
||||
'type': 'NS',
|
||||
'ttl': 37,
|
||||
'short_answers': ['ns1.unit.tests.', 'ns2.unit.tests.'],
|
||||
'domain': 'unit.tests.',
|
||||
}, {
|
||||
'type': 'SRV',
|
||||
'ttl': 38,
|
||||
'short_answers': ['12 30 30 foo-2.unit.tests.',
|
||||
'10 20 30 foo-1.unit.tests.'],
|
||||
'domain': '_srv._tcp.unit.tests.',
|
||||
}, {
|
||||
'type': 'NS',
|
||||
'ttl': 39,
|
||||
'short_answers': ['ns3.unit.tests.', 'ns4.unit.tests.'],
|
||||
'domain': 'sub.unit.tests.',
|
||||
}]
|
||||
|
||||
@patch('nsone.NSONE.loadZone')
|
||||
def test_populate(self, load_mock):
|
||||
provider = Ns1Provider('test', 'api-key')
|
||||
|
||||
# Bad auth
|
||||
load_mock.side_effect = AuthException('unauthorized')
|
||||
zone = Zone('unit.tests.', [])
|
||||
with self.assertRaises(AuthException) as ctx:
|
||||
provider.populate(zone)
|
||||
self.assertEquals(load_mock.side_effect, ctx.exception)
|
||||
|
||||
# General error
|
||||
load_mock.reset_mock()
|
||||
load_mock.side_effect = ResourceException('boom')
|
||||
zone = Zone('unit.tests.', [])
|
||||
with self.assertRaises(ResourceException) as ctx:
|
||||
provider.populate(zone)
|
||||
self.assertEquals(load_mock.side_effect, ctx.exception)
|
||||
self.assertEquals(('unit.tests',), load_mock.call_args[0])
|
||||
|
||||
# Non-existant zone doesn't populate anything
|
||||
load_mock.reset_mock()
|
||||
load_mock.side_effect = \
|
||||
ResourceException('server error: zone not found')
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(set(), zone.records)
|
||||
self.assertEquals(('unit.tests',), load_mock.call_args[0])
|
||||
|
||||
# Existing zone w/o records
|
||||
load_mock.reset_mock()
|
||||
nsone_zone = DummyZone([])
|
||||
load_mock.side_effect = [nsone_zone]
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(set(), zone.records)
|
||||
self.assertEquals(('unit.tests',), load_mock.call_args[0])
|
||||
|
||||
# Existing zone w/records
|
||||
load_mock.reset_mock()
|
||||
nsone_zone = DummyZone(self.nsone_records)
|
||||
load_mock.side_effect = [nsone_zone]
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(self.expected, zone.records)
|
||||
self.assertEquals(('unit.tests',), load_mock.call_args[0])
|
||||
|
||||
@patch('nsone.NSONE.createZone')
|
||||
@patch('nsone.NSONE.loadZone')
|
||||
def test_sync(self, load_mock, create_mock):
|
||||
provider = Ns1Provider('test', 'api-key')
|
||||
|
||||
desired = Zone('unit.tests.', [])
|
||||
desired.records.update(self.expected)
|
||||
|
||||
plan = provider.plan(desired)
|
||||
# everything except the root NS
|
||||
expected_n = len(self.expected) - 1
|
||||
self.assertEquals(expected_n, len(plan.changes))
|
||||
|
||||
# Fails, general error
|
||||
load_mock.reset_mock()
|
||||
create_mock.reset_mock()
|
||||
load_mock.side_effect = ResourceException('boom')
|
||||
with self.assertRaises(ResourceException) as ctx:
|
||||
provider.apply(plan)
|
||||
self.assertEquals(load_mock.side_effect, ctx.exception)
|
||||
|
||||
# Fails, bad auth
|
||||
load_mock.reset_mock()
|
||||
create_mock.reset_mock()
|
||||
load_mock.side_effect = \
|
||||
ResourceException('server error: zone not found')
|
||||
create_mock.side_effect = AuthException('unauthorized')
|
||||
with self.assertRaises(AuthException) as ctx:
|
||||
provider.apply(plan)
|
||||
self.assertEquals(create_mock.side_effect, ctx.exception)
|
||||
|
||||
# non-existant zone, create
|
||||
load_mock.reset_mock()
|
||||
create_mock.reset_mock()
|
||||
load_mock.side_effect = \
|
||||
ResourceException('server error: zone not found')
|
||||
create_mock.side_effect = None
|
||||
got_n = provider.apply(plan)
|
||||
self.assertEquals(expected_n, got_n)
|
||||
|
||||
# Update & delete
|
||||
load_mock.reset_mock()
|
||||
create_mock.reset_mock()
|
||||
nsone_zone = DummyZone(self.nsone_records + [{
|
||||
'type': 'A',
|
||||
'ttl': 42,
|
||||
'short_answers': ['9.9.9.9'],
|
||||
'domain': 'delete-me.unit.tests.',
|
||||
}])
|
||||
nsone_zone.data['records'][0]['short_answers'][0] = '2.2.2.2'
|
||||
nsone_zone.loadRecord = Mock()
|
||||
load_mock.side_effect = [nsone_zone, nsone_zone]
|
||||
plan = provider.plan(desired)
|
||||
self.assertEquals(2, len(plan.changes))
|
||||
self.assertIsInstance(plan.changes[0], Update)
|
||||
self.assertIsInstance(plan.changes[1], Delete)
|
||||
|
||||
got_n = provider.apply(plan)
|
||||
self.assertEquals(2, got_n)
|
||||
nsone_zone.loadRecord.assert_has_calls([
|
||||
call('unit.tests', u'A'),
|
||||
call().update(answers=[u'1.2.3.4'], ttl=32),
|
||||
call('delete-me', u'A'),
|
||||
call().delete()
|
||||
])
|
||||
Reference in New Issue
Block a user