From 5f95cd904cf75ebb58d3205708198dc219496183 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 9 May 2017 22:17:52 -0700 Subject: [PATCH 1/4] First pass through NsOneProvider --- octodns/provider/nsone.py | 189 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 2 files changed, 190 insertions(+) create mode 100644 octodns/provider/nsone.py diff --git a/octodns/provider/nsone.py b/octodns/provider/nsone.py new file mode 100644 index 0000000..4648e01 --- /dev/null +++ b/octodns/provider/nsone.py @@ -0,0 +1,189 @@ +# +# +# + +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 NsOneProvider(BaseProvider): + ''' + NsOne provider + + nsone: + class: octodns.provider.nsone.NsOneProvider + api_key: env/NS_ONE_API_KEY + ''' + SUPPORTS_GEO = False + + def __init__(self, id, api_key, *args, **kwargs): + self.log = getLogger('NsOneProvider[{}]'.format(id)) + self.log.debug('__init__: id=%s, api_key=***', id) + super(NsOneProvider, 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]) + except ResourceException: + return + + before = len(zone.records) + for record in nsone_zone.data['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(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: + 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 From 23257d8ac7110f4f44f1ad707a3dc1dff3e20b2c Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Wed, 10 May 2017 16:09:21 -0700 Subject: [PATCH 2/4] NsOneProvider -> Ns1Provider and related renames --- octodns/provider/{nsone.py => ns1.py} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename octodns/provider/{nsone.py => ns1.py} (95%) diff --git a/octodns/provider/nsone.py b/octodns/provider/ns1.py similarity index 95% rename from octodns/provider/nsone.py rename to octodns/provider/ns1.py index 4648e01..4d42780 100644 --- a/octodns/provider/nsone.py +++ b/octodns/provider/ns1.py @@ -13,20 +13,20 @@ from ..record import Record from .base import BaseProvider -class NsOneProvider(BaseProvider): +class Ns1Provider(BaseProvider): ''' - NsOne provider + Ns1 provider nsone: - class: octodns.provider.nsone.NsOneProvider - api_key: env/NS_ONE_API_KEY + class: octodns.provider.nsone.Ns1Provider + api_key: env/NS1_API_KEY ''' SUPPORTS_GEO = False def __init__(self, id, api_key, *args, **kwargs): - self.log = getLogger('NsOneProvider[{}]'.format(id)) + self.log = getLogger('Ns1Provider[{}]'.format(id)) self.log.debug('__init__: id=%s, api_key=***', id) - super(NsOneProvider, self).__init__(id, *args, **kwargs) + super(Ns1Provider, self).__init__(id, *args, **kwargs) self._client = NSONE(apiKey=api_key) def _data_for_A(self, _type, record): From 06e17d043ba30e9bf0e3ef68f0a56ba50f1fe1ee Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 22 May 2017 17:33:31 -0700 Subject: [PATCH 3/4] Corrected handling of ns1 errors, Ns1Provider.populate tests --- octodns/provider/ns1.py | 9 +- tests/test_octodns_provider_ns1.py | 177 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 tests/test_octodns_provider_ns1.py diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index 4d42780..2982c5f 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -111,11 +111,14 @@ class Ns1Provider(BaseProvider): try: nsone_zone = self._client.loadZone(zone.name[:-1]) - except ResourceException: - return + records = nsone_zone.data['records'] + except ResourceException as e: + if e.message != 'server error: zone not found': + raise + records = [] before = len(zone.records) - for record in nsone_zone.data['records']: + for record in records: _type = record['type'] data_for = getattr(self, '_data_for_{}'.format(_type)) name = zone.hostname_from_fqdn(record['domain']) diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py new file mode 100644 index 0000000..110c0c1 --- /dev/null +++ b/tests/test_octodns_provider_ns1.py @@ -0,0 +1,177 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from mock import patch +from nsone.rest.errors import AuthException, ResourceException +from unittest import TestCase + +from octodns.record import Record +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): + + @patch('nsone.NSONE.loadZone') + def test_provider(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([{ + '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 20 30 foo-2.unit.tests.', + '10 20 30 foo-2.unit.tests.'], + 'domain': '_srv._tcp.unit.tests.', + }]) + load_mock.side_effect = [nsone_zone] + zone = Zone('unit.tests.', []) + provider.populate(zone) + expected = set() + expected.add(Record.new(zone, '', { + 'ttl': 32, + 'type': 'A', + 'value': '1.2.3.4', + })) + expected.add(Record.new(zone, 'foo', { + 'ttl': 32, + 'type': 'A', + 'values': ['1.2.3.4', '1.2.3.5'], + })) + expected.add(Record.new(zone, 'cname', { + 'ttl': 33, + '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.', + }] + })) + self.assertEquals(expected, zone.records) + self.assertEquals(('unit.tests',), load_mock.call_args[0]) From bc1736bc39f2f2bb87373b0b1a0e3adaca5fad5f Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 23 May 2017 09:36:15 -0700 Subject: [PATCH 4/4] NS1, add Delete support, fix apply create, flush out tests to 100% --- octodns/provider/ns1.py | 14 +- tests/test_octodns_provider_ns1.py | 299 ++++++++++++++++++----------- 2 files changed, 201 insertions(+), 112 deletions(-) diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index 2982c5f..8d168f6 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -22,6 +22,7 @@ class Ns1Provider(BaseProvider): 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)) @@ -113,7 +114,7 @@ class Ns1Provider(BaseProvider): nsone_zone = self._client.loadZone(zone.name[:-1]) records = nsone_zone.data['records'] except ResourceException as e: - if e.message != 'server error: zone not found': + if e.message != self.ZONE_NOT_FOUND_MESSAGE: raise records = [] @@ -174,6 +175,13 @@ class Ns1Provider(BaseProvider): 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 @@ -183,7 +191,9 @@ class Ns1Provider(BaseProvider): domain_name = desired.name[:-1] try: nsone_zone = self._client.loadZone(domain_name) - except ResourceException: + 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) diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py index 110c0c1..acb0125 100644 --- a/tests/test_octodns_provider_ns1.py +++ b/tests/test_octodns_provider_ns1.py @@ -5,11 +5,11 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from mock import patch +from mock import Mock, call, patch from nsone.rest.errors import AuthException, ResourceException from unittest import TestCase -from octodns.record import Record +from octodns.record import Delete, Record, Update from octodns.provider.ns1 import Ns1Provider from octodns.zone import Zone @@ -23,9 +23,127 @@ class DummyZone(object): 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_provider(self, load_mock): + def test_populate(self, load_mock): provider = Ns1Provider('test', 'api-key') # Bad auth @@ -64,114 +182,75 @@ class TestNs1Provider(TestCase): # Existing zone w/records load_mock.reset_mock() - nsone_zone = DummyZone([{ - '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 20 30 foo-2.unit.tests.', - '10 20 30 foo-2.unit.tests.'], - 'domain': '_srv._tcp.unit.tests.', - }]) + nsone_zone = DummyZone(self.nsone_records) load_mock.side_effect = [nsone_zone] zone = Zone('unit.tests.', []) provider.populate(zone) - expected = set() - expected.add(Record.new(zone, '', { - 'ttl': 32, - 'type': 'A', - 'value': '1.2.3.4', - })) - expected.add(Record.new(zone, 'foo', { - 'ttl': 32, - 'type': 'A', - 'values': ['1.2.3.4', '1.2.3.5'], - })) - expected.add(Record.new(zone, 'cname', { - 'ttl': 33, - '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.', - }] - })) - self.assertEquals(expected, zone.records) + 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() + ])