diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py new file mode 100644 index 0000000..8d168f6 --- /dev/null +++ b/octodns/provider/ns1.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 53f6a29..efd7577 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py new file mode 100644 index 0000000..acb0125 --- /dev/null +++ b/tests/test_octodns_provider_ns1.py @@ -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() + ])