# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from dyn.tm.errors import DynectGetError from dyn.tm.services.dsf import DSFResponsePool from json import loads from mock import MagicMock, call, patch from unittest import TestCase from octodns.record import Create, Delete, Record, Update from octodns.provider.base import Plan from octodns.provider.dyn import DynProvider, _CachingDynZone, DSFMonitor, \ _dynamic_value_sort_key from octodns.zone import Zone from helpers import SimpleProvider class _DummyPool(object): def __init__(self, response_pool_id): self.response_pool_id = response_pool_id self.deleted = False def delete(self): self.deleted = True class TestDynProvider(TestCase): expected = Zone('unit.tests.', []) for name, data in ( ('', { 'type': 'A', 'ttl': 300, 'values': ['1.2.3.4'] }), ('cname', { 'type': 'CNAME', 'ttl': 301, 'value': 'unit.tests.' }), ('', { 'type': 'MX', 'ttl': 302, 'values': [{ 'preference': 10, 'exchange': 'smtp-1.unit.tests.' }, { 'preference': 20, 'exchange': 'smtp-2.unit.tests.' }] }), ('naptr', { 'type': 'NAPTR', 'ttl': 303, 'values': [{ 'order': 100, 'preference': 101, 'flags': 'U', 'service': 'SIP+D2U', 'regexp': '!^.*$!sip:info@foo.example.com!', 'replacement': '.', }, { 'order': 200, 'preference': 201, 'flags': 'U', 'service': 'SIP+D2U', 'regexp': '!^.*$!sip:info@bar.example.com!', 'replacement': '.', }] }), ('sub', { 'type': 'NS', 'ttl': 3600, 'values': ['ns3.p10.dynect.net.', 'ns3.p10.dynect.net.'], }), ('ptr', { 'type': 'PTR', 'ttl': 304, 'value': 'xx.unit.tests.' }), ('spf', { 'type': 'SPF', 'ttl': 305, 'values': ['v=spf1 ip4:192.168.0.1/16-all', 'v=spf1 -all'], }), ('', { 'type': 'SSHFP', 'ttl': 306, 'value': { 'algorithm': 1, 'fingerprint_type': 1, 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', } }), ('_srv._tcp', { 'type': 'SRV', 'ttl': 307, 'values': [{ 'priority': 11, 'weight': 12, 'port': 10, 'target': 'foo-1.unit.tests.' }, { 'priority': 21, 'weight': 22, 'port': 20, 'target': 'foo-2.unit.tests.' }]}), ('', { 'type': 'CAA', 'ttl': 308, 'values': [{ 'flags': 0, 'tag': 'issue', 'value': 'ca.unit.tests' }]})): expected.add_record(Record.new(expected, name, data)) @classmethod def setUpClass(self): # Get the DynectSession creation out of the way so that tests can # ignore it with patch('dyn.core.SessionEngine.execute', return_value={'status': 'success'}): provider = DynProvider('test', 'cust', 'user', 'pass') provider._check_dyn_sess() def setUp(self): # Flush our zone to ensure we start fresh _CachingDynZone.flush_zone(self.expected.name[:-1]) @patch('dyn.core.SessionEngine.execute') def test_populate_non_existent(self, execute_mock): provider = DynProvider('test', 'cust', 'user', 'pass') # Test Zone create execute_mock.side_effect = [ DynectGetError('foo'), ] got = Zone('unit.tests.', []) provider.populate(got) execute_mock.assert_has_calls([ call('/Zone/unit.tests/', 'GET', {}), ]) self.assertEquals(set(), got.records) @patch('dyn.core.SessionEngine.execute') def test_populate(self, execute_mock): provider = DynProvider('test', 'cust', 'user', 'pass') # Test Zone create execute_mock.side_effect = [ # get Zone {'data': {}}, # get_all_records {'data': { 'a_records': [{ 'fqdn': 'unit.tests', 'rdata': {'address': '1.2.3.4'}, 'record_id': 1, 'record_type': 'A', 'ttl': 300, 'zone': 'unit.tests', }], 'cname_records': [{ 'fqdn': 'cname.unit.tests', 'rdata': {'cname': 'unit.tests.'}, 'record_id': 2, 'record_type': 'CNAME', 'ttl': 301, 'zone': 'unit.tests', }], 'ns_records': [{ 'fqdn': 'unit.tests', 'rdata': {'nsdname': 'ns1.p10.dynect.net.'}, 'record_id': 254597562, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }, { 'fqdn': 'unit.tests', 'rdata': {'nsdname': 'ns2.p10.dynect.net.'}, 'record_id': 254597563, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }, { 'fqdn': 'unit.tests', 'rdata': {'nsdname': 'ns3.p10.dynect.net.'}, 'record_id': 254597564, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }, { 'fqdn': 'unit.tests', 'rdata': {'nsdname': 'ns4.p10.dynect.net.'}, 'record_id': 254597565, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }, { 'fqdn': 'sub.unit.tests', 'rdata': {'nsdname': 'ns3.p10.dynect.net.'}, 'record_id': 254597564, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }, { 'fqdn': 'sub.unit.tests', 'rdata': {'nsdname': 'ns3.p10.dynect.net.'}, 'record_id': 254597564, 'record_type': 'NS', 'service_class': '', 'ttl': 3600, 'zone': 'unit.tests' }], 'mx_records': [{ 'fqdn': 'unit.tests', 'rdata': {'exchange': 'smtp-1.unit.tests.', 'preference': 10}, 'record_id': 3, 'record_type': 'MX', 'ttl': 302, 'zone': 'unit.tests', }, { 'fqdn': 'unit.tests', 'rdata': {'exchange': 'smtp-2.unit.tests.', 'preference': 20}, 'record_id': 4, 'record_type': 'MX', 'ttl': 302, 'zone': 'unit.tests', }], 'naptr_records': [{ 'fqdn': 'naptr.unit.tests', 'rdata': {'flags': 'U', 'order': 100, 'preference': 101, 'regexp': '!^.*$!sip:info@foo.example.com!', 'replacement': '.', 'services': 'SIP+D2U'}, 'record_id': 5, 'record_type': 'MX', 'ttl': 303, 'zone': 'unit.tests', }, { 'fqdn': 'naptr.unit.tests', 'rdata': {'flags': 'U', 'order': 200, 'preference': 201, 'regexp': '!^.*$!sip:info@bar.example.com!', 'replacement': '.', 'services': 'SIP+D2U'}, 'record_id': 6, 'record_type': 'MX', 'ttl': 303, 'zone': 'unit.tests', }], 'ptr_records': [{ 'fqdn': 'ptr.unit.tests', 'rdata': {'ptrdname': 'xx.unit.tests.'}, 'record_id': 7, 'record_type': 'PTR', 'ttl': 304, 'zone': 'unit.tests', }], 'soa_records': [{ 'fqdn': 'unit.tests', 'rdata': {'txtdata': 'ns1.p16.dynect.net. ' 'hostmaster.unit.tests. 4 3600 600 604800 1800'}, 'record_id': 99, 'record_type': 'SOA', 'ttl': 299, 'zone': 'unit.tests', }], 'spf_records': [{ 'fqdn': 'spf.unit.tests', 'rdata': {'txtdata': 'v=spf1 ip4:192.168.0.1/16-all'}, 'record_id': 8, 'record_type': 'SPF', 'ttl': 305, 'zone': 'unit.tests', }, { 'fqdn': 'spf.unit.tests', 'rdata': {'txtdata': 'v=spf1 -all'}, 'record_id': 8, 'record_type': 'SPF', 'ttl': 305, 'zone': 'unit.tests', }], 'sshfp_records': [{ 'fqdn': 'unit.tests', 'rdata': {'algorithm': 1, 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', 'fptype': 1}, 'record_id': 9, 'record_type': 'SSHFP', 'ttl': 306, 'zone': 'unit.tests', }], 'srv_records': [{ 'fqdn': '_srv._tcp.unit.tests', 'rdata': {'port': 10, 'priority': 11, 'target': 'foo-1.unit.tests.', 'weight': 12}, 'record_id': 10, 'record_type': 'SRV', 'ttl': 307, 'zone': 'unit.tests', }, { 'fqdn': '_srv._tcp.unit.tests', 'rdata': {'port': 20, 'priority': 21, 'target': 'foo-2.unit.tests.', 'weight': 22}, 'record_id': 11, 'record_type': 'SRV', 'ttl': 307, 'zone': 'unit.tests', }], 'caa_records': [{ 'fqdn': 'unit.tests', 'rdata': {'flags': 0, 'tag': 'issue', 'value': 'ca.unit.tests'}, 'record_id': 12, 'record_type': 'cAA', 'ttl': 308, 'zone': 'unit.tests', }], }} ] got = Zone('unit.tests.', []) provider.populate(got) execute_mock.assert_has_calls([ call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) changes = self.expected.changes(got, SimpleProvider()) self.assertEquals([], changes) @patch('dyn.core.SessionEngine.execute') def test_sync(self, execute_mock): provider = DynProvider('test', 'cust', 'user', 'pass') # Test Zone create execute_mock.side_effect = [ # No such zone, during populate DynectGetError('foo'), # No such zone, during sync DynectGetError('foo'), # get empty Zone {'data': {}}, # get zone we can modify & delete with {'data': { # A top-level to delete 'a_records': [{ 'fqdn': 'unit.tests', 'rdata': {'address': '1.2.3.4'}, 'record_id': 1, 'record_type': 'A', 'ttl': 30, 'zone': 'unit.tests', }, { 'fqdn': 'a.unit.tests', 'rdata': {'address': '2.3.4.5'}, 'record_id': 2, 'record_type': 'A', 'ttl': 30, 'zone': 'unit.tests', }], # A node to delete 'cname_records': [{ 'fqdn': 'cname.unit.tests', 'rdata': {'cname': 'unit.tests.'}, 'record_id': 3, 'record_type': 'CNAME', 'ttl': 30, 'zone': 'unit.tests', }], # A record to leave alone 'ptr_records': [{ 'fqdn': 'ptr.unit.tests', 'rdata': {'ptrdname': 'xx.unit.tests.'}, 'record_id': 4, 'record_type': 'PTR', 'ttl': 30, 'zone': 'unit.tests', }], # A record to modify 'srv_records': [{ 'fqdn': '_srv._tcp.unit.tests', 'rdata': {'port': 10, 'priority': 11, 'target': 'foo-1.unit.tests.', 'weight': 12}, 'record_id': 5, 'record_type': 'SRV', 'ttl': 30, 'zone': 'unit.tests', }, { 'fqdn': '_srv._tcp.unit.tests', 'rdata': {'port': 20, 'priority': 21, 'target': 'foo-2.unit.tests.', 'weight': 22}, 'record_id': 6, 'record_type': 'SRV', 'ttl': 30, 'zone': 'unit.tests', }], }} ] # No existing records, create all with patch('dyn.tm.zones.Zone.add_record') as add_mock: with patch('dyn.tm.zones.Zone._update') as update_mock: plan = provider.plan(self.expected) update_mock.assert_not_called() provider.apply(plan) update_mock.assert_called() self.assertFalse(plan.exists) add_mock.assert_called() # Once for each dyn record (8 Records, 2 of which have dual values) self.assertEquals(15, len(add_mock.call_args_list)) execute_mock.assert_has_calls([call('/Zone/unit.tests/', 'GET', {}), call('/Zone/unit.tests/', 'GET', {})]) self.assertEquals(10, len(plan.changes)) execute_mock.reset_mock() # Delete one and modify another new = Zone('unit.tests.', []) for name, data in ( ('a', { 'type': 'A', 'ttl': 30, 'value': '2.3.4.5' }), ('ptr', { 'type': 'PTR', 'ttl': 30, 'value': 'xx.unit.tests.' }), ('_srv._tcp', { 'type': 'SRV', 'ttl': 30, 'values': [{ 'priority': 31, 'weight': 12, 'port': 10, 'target': 'foo-1.unit.tests.' }, { 'priority': 21, 'weight': 22, 'port': 20, 'target': 'foo-2.unit.tests.' }]})): new.add_record(Record.new(new, name, data)) with patch('dyn.tm.zones.Zone.add_record') as add_mock: with patch('dyn.tm.records.DNSRecord.delete') as delete_mock: with patch('dyn.tm.zones.Zone._update') as update_mock: plan = provider.plan(new) provider.apply(plan) update_mock.assert_called() self.assertTrue(plan.exists) # we expect 4 deletes, 2 from actual deletes and 2 from # updates which delete and recreate self.assertEquals(4, len(delete_mock.call_args_list)) # the 2 (re)creates self.assertEquals(2, len(add_mock.call_args_list)) execute_mock.assert_has_calls([ call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) self.assertEquals(3, len(plan.changes)) class TestDynProviderGeo(TestCase): with open('./tests/fixtures/dyn-traffic-director-get.json') as fh: traffic_director_response = loads(fh.read()) @property def traffic_directors_response(self): return { 'data': [{ 'active': 'Y', 'label': 'unit.tests.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'some.other.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'other format', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '4ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }] } # Doing this as a property so that we get a fresh copy each time, dyn's # client lib messes with the return value and prevents it from working on # subsequent uses otherwise @property def records_response(self): return { 'data': { 'a_records': [{ 'fqdn': 'unit.tests', 'rdata': {'address': '1.2.3.4'}, 'record_id': 1, 'record_type': 'A', 'ttl': 301, 'zone': 'unit.tests', }], } } monitor_id = '42a' monitors_response = { 'data': [{ 'active': 'Y', 'agent_scheme': 'geo', 'dsf_monitor_id': monitor_id, 'endpoints': [], 'label': 'unit.tests.:A', 'notifier': [], 'expected': '', 'header': 'User-Agent: Dyn Monitor', 'host': 'unit.tests', 'path': '/_dns', 'port': '443', 'timeout': '10', 'probe_interval': '60', 'protocol': 'HTTPS', 'response_count': '2', 'retries': '2', 'services': ['12311'] }, { 'active': 'Y', 'agent_scheme': 'geo', 'dsf_monitor_id': 'b52', 'endpoints': [], 'label': 'old-label.unit.tests.', 'notifier': [], 'expected': '', 'header': 'User-Agent: Dyn Monitor', 'host': 'old-label.unit.tests', 'path': '/_dns', 'port': '443', 'timeout': '10', 'probe_interval': '60', 'protocol': 'HTTPS', 'response_count': '2', 'retries': '2', 'services': ['12312'] }], 'job_id': 3376281406, 'msgs': [{ 'ERR_CD': None, 'INFO': 'DSFMonitor_get: Here are your monitors', 'LVL': 'INFO', 'SOURCE': 'BLL' }], 'status': 'success' } expected_geo = Zone('unit.tests.', []) geo_record = Record.new(expected_geo, '', { 'geo': { 'AF': ['2.2.3.4', '2.2.3.5'], 'AS-JP': ['3.2.3.4', '3.2.3.5'], 'NA-US': ['4.2.3.4', '4.2.3.5'], 'NA-US-CA': ['5.2.3.4', '5.2.3.5'] }, 'ttl': 300, 'type': 'A', 'values': ['1.2.3.4', '1.2.3.5'], }) expected_geo.add_record(geo_record) expected_regular = Zone('unit.tests.', []) regular_record = Record.new(expected_regular, '', { 'ttl': 301, 'type': 'A', 'value': '1.2.3.4', }) expected_regular.add_record(regular_record) def setUp(self): # Flush our zone to ensure we start fresh _CachingDynZone.flush_zone('unit.tests') @patch('dyn.core.SessionEngine.execute') def test_traffic_directors(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', True) # short-circuit session checking provider._dyn_sess = True provider.log.warn = MagicMock() # no tds mock.side_effect = [{'data': []}] self.assertEquals({}, provider.traffic_directors) # a supported td and an ignored one response = { 'data': [{ 'active': 'Y', 'label': 'unit.tests.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'geo.unit.tests.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'something else', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '4ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }], 'job_id': 3376164583, 'status': 'success' } mock.side_effect = [response] # first make sure that we get the empty version from cache self.assertEquals({}, provider.traffic_directors) # reach in and bust the cache provider._traffic_directors = None tds = provider.traffic_directors self.assertEquals(set(['unit.tests.', 'geo.unit.tests.']), set(tds.keys())) self.assertEquals(['A'], list(tds['unit.tests.'].keys())) self.assertEquals(['A'], list(tds['geo.unit.tests.'].keys())) provider.log.warn.assert_called_with("Unsupported TrafficDirector " "'%s'", 'something else') @patch('dyn.core.SessionEngine.execute') def test_traffic_director_monitor(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', True) # short-circuit session checking provider._dyn_sess = True existing = Zone('unit.tests.', []) # no monitors, will try and create geo_monitor_id = '42x' mock.side_effect = [self.monitors_response, { 'data': { 'active': 'Y', 'dsf_monitor_id': geo_monitor_id, 'endpoints': [], 'label': 'geo.unit.tests.:A', 'notifier': '', 'expected': '', 'header': 'User-Agent: Dyn Monitor', 'host': 'geo.unit.tests.', 'path': '/_dns', 'port': '443', 'timeout': '10', 'probe_interval': '60', 'protocol': 'HTTPS', 'response_count': '2', 'retries': '2' }, 'job_id': 3376259461, 'msgs': [{'ERR_CD': None, 'INFO': 'add: Here is the new monitor', 'LVL': 'INFO', 'SOURCE': 'BLL'}], 'status': 'success' }] # ask for a monitor that doesn't exist record = Record.new(existing, 'geo', { 'ttl': 60, 'type': 'A', 'value': '1.2.3.4', 'octodns': { 'healthcheck': { 'host': 'foo.bar', 'path': '/_ready' } } }) monitor = provider._traffic_director_monitor(record) self.assertEquals(geo_monitor_id, monitor.dsf_monitor_id) # should see a request for the list and a create mock.assert_has_calls([ call('/DSFMonitor/', 'GET', {'detail': 'Y'}), call('/DSFMonitor/', 'POST', { 'retries': 2, 'protocol': 'HTTPS', 'response_count': 2, 'label': 'geo.unit.tests.:A', 'probe_interval': 60, 'active': 'Y', 'options': { 'path': '/_ready', 'host': 'foo.bar', 'header': 'User-Agent: Dyn Monitor', 'port': 443, 'timeout': 10 } }) ]) # created monitor is now cached self.assertTrue('geo.unit.tests.:A' in provider._traffic_director_monitors) # pre-existing one is there too self.assertTrue('unit.tests.:A' in provider._traffic_director_monitors) # now ask for a monitor that does exist record = Record.new(existing, '', { 'ttl': 60, 'type': 'A', 'value': '1.2.3.4' }) mock.reset_mock() monitor = provider._traffic_director_monitor(record) self.assertEquals(self.monitor_id, monitor.dsf_monitor_id) # should have resulted in no calls b/c exists & we've cached the list mock.assert_not_called() # and finally for a monitor that exists, but with a differing config record = Record.new(existing, '', { 'octodns': { 'healthcheck': { 'host': 'bleep.bloop', 'path': '/_nope', 'protocol': 'HTTP', 'port': 8080, } }, 'ttl': 60, 'type': 'A', 'value': '1.2.3.4' }) mock.reset_mock() mock.side_effect = [{ 'data': { 'active': 'Y', 'dsf_monitor_id': self.monitor_id, 'endpoints': [], 'label': 'unit.tests.:A', 'notifier': '', 'expected': '', 'header': 'User-Agent: Dyn Monitor', 'host': 'bleep.bloop', 'path': '/_nope', 'port': '8080', 'timeout': '10', 'probe_interval': '60', 'protocol': 'HTTP', 'response_count': '2', 'retries': '2' }, 'job_id': 3376259461, 'msgs': [{'ERR_CD': None, 'INFO': 'add: Here is the new monitor', 'LVL': 'INFO', 'SOURCE': 'BLL'}], 'status': 'success' }] monitor = provider._traffic_director_monitor(record) self.assertEquals(self.monitor_id, monitor.dsf_monitor_id) # should have resulted an update mock.assert_has_calls([ call('/DSFMonitor/42a/', 'PUT', { 'protocol': 'HTTP', 'options': { 'path': '/_nope', 'host': 'bleep.bloop', 'header': 'User-Agent: Dyn Monitor', 'port': 8080, 'timeout': 10 } }) ]) # cached monitor should have been updated self.assertTrue('unit.tests.:A' in provider._traffic_director_monitors) monitor = provider._traffic_director_monitors['unit.tests.:A'] self.assertEquals('bleep.bloop', monitor.host) self.assertEquals('/_nope', monitor.path) self.assertEquals('HTTP', monitor.protocol) self.assertEquals('8080', monitor.port) # test upgrading an old label record = Record.new(existing, 'old-label', { 'ttl': 60, 'type': 'A', 'value': '1.2.3.4' }) mock.reset_mock() mock.side_effect = [{ 'data': { 'active': 'Y', 'dsf_monitor_id': self.monitor_id, 'endpoints': [], 'label': 'old-label.unit.tests.:A', 'notifier': '', 'expected': '', 'header': 'User-Agent: Dyn Monitor', 'host': 'old-label.unit.tests', 'path': '/_dns', 'port': '443', 'timeout': '10', 'probe_interval': '60', 'protocol': 'HTTPS', 'response_count': '2', 'retries': '2' }, 'job_id': 3376259461, 'msgs': [{'ERR_CD': None, 'INFO': 'add: Here is the new monitor', 'LVL': 'INFO', 'SOURCE': 'BLL'}], 'status': 'success' }] monitor = provider._traffic_director_monitor(record) self.assertEquals(self.monitor_id, monitor.dsf_monitor_id) # should have resulted an update mock.assert_has_calls([ call('/DSFMonitor/b52/', 'PUT', { 'label': 'old-label.unit.tests.:A' }) ]) # cached monitor should have been updated self.assertTrue('old-label.unit.tests.:A' in provider._traffic_director_monitors) @patch('dyn.core.SessionEngine.execute') def test_extra_changes(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', True) # short-circuit session checking provider._dyn_sess = True mock.side_effect = [self.monitors_response] # non-geo desired = Zone('unit.tests.', []) record = Record.new(desired, '', { 'ttl': 60, 'type': 'A', 'value': '1.2.3.4', }) desired.add_record(record) extra = provider._extra_changes(desired=desired, changes=[Create(record)]) self.assertEquals(0, len(extra)) # in changes, noop desired = Zone('unit.tests.', []) record = Record.new(desired, '', { 'geo': { 'NA': ['1.2.3.4'], }, 'ttl': 60, 'type': 'A', 'value': '1.2.3.4', }) desired.add_record(record) extra = provider._extra_changes(desired=desired, changes=[Create(record)]) self.assertEquals(0, len(extra)) # no diff, no extra extra = provider._extra_changes(desired=desired, changes=[]) self.assertEquals(0, len(extra)) # monitors should have been fetched now mock.assert_called_once() # diff in healthcheck, gets extra desired = Zone('unit.tests.', []) record = Record.new(desired, '', { 'geo': { 'NA': ['1.2.3.4'], }, 'octodns': { 'healthcheck': { 'host': 'foo.bar', 'path': '/_ready' } }, 'ttl': 60, 'type': 'A', 'value': '1.2.3.4', }) desired.add_record(record) extra = provider._extra_changes(desired=desired, changes=[]) self.assertEquals(1, len(extra)) extra = extra[0] self.assertIsInstance(extra, Update) self.assertEquals(record, extra.record) # missing health check desired = Zone('unit.tests.', []) record = Record.new(desired, 'geo', { 'geo': { 'NA': ['1.2.3.4'], }, 'ttl': 60, 'type': 'A', 'value': '1.2.3.4', }) desired.add_record(record) extra = provider._extra_changes(desired=desired, changes=[]) self.assertEquals(1, len(extra)) extra = extra[0] self.assertIsInstance(extra, Update) self.assertEquals(record, extra.record) @patch('dyn.core.SessionEngine.execute') def test_populate_traffic_directors_empty(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # empty all around mock.side_effect = [ # get traffic directors {'data': []}, # get zone {'data': {}}, # get records {'data': {}}, ] got = Zone('unit.tests.', []) provider.populate(got) self.assertEquals(0, len(got.records)) mock.assert_has_calls([ call('/DSF/', 'GET', {'detail': 'Y'}), call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}), ]) @patch('dyn.core.SessionEngine.execute') def test_populate_traffic_directors_td(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) got = Zone('unit.tests.', []) zone_name = got.name[:-1] # only traffic director mock.side_effect = [ # get traffic directors self.traffic_directors_response, # get the first td's nodes {'data': [{'fqdn': zone_name, 'zone': zone_name}]}, # get traffic director, b/c ^ matches self.traffic_director_response, # get the next td's nodes, not a match {'data': [{'fqdn': 'other', 'zone': 'other'}]}, # get zone {'data': {}}, # get records {'data': {}}, ] provider.populate(got) self.assertEquals(1, len(got.records)) self.assertFalse(self.expected_geo.changes(got, provider)) mock.assert_has_calls([ call('/DSF/', 'GET', {'detail': 'Y'}), call('/DSFNode/2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/DSF/2ERWXQNsb_IKG2YZgYqkPvk0PBM/', 'GET', {'pending_changes': 'Y'}), call('/DSFNode/3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) @patch('dyn.core.SessionEngine.execute') def test_populate_traffic_directors_regular(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # only regular mock.side_effect = [ # get traffic directors {'data': []}, # get zone {'data': {}}, # get records self.records_response ] got = Zone('unit.tests.', []) provider.populate(got) self.assertEquals(1, len(got.records)) self.assertFalse(self.expected_regular.changes(got, provider)) mock.assert_has_calls([ call('/DSF/', 'GET', {'detail': 'Y'}), call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}), ]) @patch('dyn.core.SessionEngine.execute') def test_populate_traffic_directors_both(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # both traffic director and regular, regular is ignored mock.side_effect = [ # get traffic directors self.traffic_directors_response, # grab its nodes, matches {'data': [{'fqdn': 'unit.tests', 'zone': 'unit.tests'}]}, # get traffic director b/c match self.traffic_director_response, # grab next td's nodes, not a match {'data': [{'fqdn': 'other', 'zone': 'other'}]}, # get zone {'data': {}}, # get records self.records_response ] got = Zone('unit.tests.', []) provider.populate(got) self.assertEquals(1, len(got.records)) self.assertFalse(self.expected_geo.changes(got, provider)) mock.assert_has_calls([ call('/DSF/', 'GET', {'detail': 'Y'}), call('/DSFNode/2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/DSF/2ERWXQNsb_IKG2YZgYqkPvk0PBM/', 'GET', {'pending_changes': 'Y'}), call('/DSFNode/3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) @patch('dyn.core.SessionEngine.execute') def test_populate_traffic_director_busted(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) busted_traffic_director_response = { "status": "success", "data": { "notifiers": [], "rulesets": [], "ttl": "300", "active": "Y", "service_id": "oIRZ4lM-W64NUelJGuzuVziZ4MI", "nodes": [{ "fqdn": "unit.tests", "zone": "unit.tests" }], "pending_change": "", "label": "unit.tests.:A" }, "job_id": 3376642606, "msgs": [{ "INFO": "detail: Here is your service", "LVL": "INFO", "ERR_CD": None, "SOURCE": "BLL" }] } # busted traffic director mock.side_effect = [ # get traffic directors self.traffic_directors_response, {'data': [{'fqdn': 'unit.tests', 'zone': 'unit.tests'}]}, # get traffic director busted_traffic_director_response, {'data': [{'fqdn': 'other', 'zone': 'other'}]}, # get zone {'data': {}}, # get records {'data': {}}, ] got = Zone('unit.tests.', []) provider.populate(got) self.assertEquals(1, len(got.records)) # we expect a change here for the record, the values aren't important, # so just compare set contents (which does name and type) self.assertEquals(self.expected_geo.records, got.records) mock.assert_has_calls([ call('/DSF/', 'GET', {'detail': 'Y'}), call('/DSFNode/2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/DSF/2ERWXQNsb_IKG2YZgYqkPvk0PBM/', 'GET', {'pending_changes': 'Y'}), call('/DSFNode/3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'GET', {}), call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) @patch('dyn.core.SessionEngine.execute') def test_apply_traffic_director(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # stubbing these out to avoid a lot of messy mocking, they'll be tested # individually, we'll check for expected calls provider._mod_geo_Create = MagicMock() provider._mod_geo_Update = MagicMock() provider._mod_geo_Delete = MagicMock() provider._mod_Create = MagicMock() provider._mod_Update = MagicMock() provider._mod_Delete = MagicMock() # busted traffic director mock.side_effect = [ # get zone {'data': {}}, # accept publish {'data': {}}, ] desired = Zone('unit.tests.', []) geo = self.geo_record regular = self.regular_record changes = [ Create(geo), Create(regular), Update(geo, geo), Update(regular, regular), Delete(geo), Delete(regular), ] plan = Plan(None, desired, changes, True) provider._apply(plan) mock.assert_has_calls([ call('/Zone/unit.tests/', 'GET', {}), call('/Zone/unit.tests/', 'PUT', {'publish': True}) ]) # should have seen 1 call to each provider._mod_geo_Create.assert_called_once() provider._mod_geo_Update.assert_called_once() provider._mod_geo_Delete.assert_called_once() provider._mod_Create.assert_called_once() provider._mod_Update.assert_called_once() provider._mod_Delete.assert_called_once() @patch('dyn.core.SessionEngine.execute') def test_mod_geo_create(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # will be tested separately provider._mod_geo_rulesets = MagicMock() mock.side_effect = [ # create traffic director self.traffic_director_response, # get traffic directors self.traffic_directors_response ] provider._mod_geo_Create(None, Create(self.geo_record)) # td now lives in cache self.assertTrue('A' in provider.traffic_directors['unit.tests.']) # should have seen 1 gen call provider._mod_geo_rulesets.assert_called_once() def test_mod_geo_update_geo_geo(self): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # update of an existing td # pre-populate the cache with our mock td provider._traffic_directors = { 'unit.tests.': { 'A': 42, } } # mock _mod_geo_rulesets provider._mod_geo_rulesets = MagicMock() geo = self.geo_record change = Update(geo, geo) provider._mod_geo_Update(None, change) # still in cache self.assertTrue('A' in provider.traffic_directors['unit.tests.']) # should have seen 1 gen call provider._mod_geo_rulesets.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_geo_update_geo_regular(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a td to a regular record provider._mod_Create = MagicMock() provider._mod_geo_Delete = MagicMock() change = Update(self.geo_record, self.regular_record) provider._mod_geo_Update(42, change) # should have seen a call to create the new regular record provider._mod_Create.assert_called_once_with(42, change) # should have seen a call to delete the old td record provider._mod_geo_Delete.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_geo_update_regular_geo(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a regular record to a td provider._mod_geo_Create = MagicMock() provider._mod_Delete = MagicMock() change = Update(self.regular_record, self.geo_record) provider._mod_geo_Update(42, change) # should have seen a call to create the new geo record provider._mod_geo_Create.assert_called_once_with(42, change) # should have seen a call to delete the old regular record provider._mod_Delete.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_geo_delete(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) td_mock = MagicMock() provider._traffic_directors = { 'unit.tests.': { 'A': td_mock, } } provider._mod_geo_Delete(None, Delete(self.geo_record)) # delete called td_mock.delete.assert_called_once() # removed from cache self.assertFalse('A' in provider.traffic_directors['unit.tests.']) @patch('dyn.tm.services.DSFResponsePool.create') def test_find_or_create_geo_pool(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) td = 42 # no candidates cache miss, so create values = ['1.2.3.4', '1.2.3.5'] pool = provider._find_or_create_geo_pool(td, [], 'default', 'A', values) self.assertIsInstance(pool, DSFResponsePool) self.assertEquals(1, len(pool.rs_chains)) records = pool.rs_chains[0].record_sets[0].records self.assertEquals(values, [r.address for r in records]) mock.assert_called_once_with(td) # cache hit, use the one we just created mock.reset_mock() pools = [pool] cached = provider._find_or_create_geo_pool(td, pools, 'default', 'A', values) self.assertEquals(pool, cached) mock.assert_not_called() # cache miss, non-matching label mock.reset_mock() miss = provider._find_or_create_geo_pool(td, pools, 'NA-US-CA', 'A', values) self.assertNotEquals(pool, miss) self.assertEquals('NA-US-CA', miss.label) mock.assert_called_once_with(td) # cache miss, matching label, mis-matching values mock.reset_mock() values = ['2.2.3.4.', '2.2.3.5'] miss = provider._find_or_create_geo_pool(td, pools, 'default', 'A', values) self.assertNotEquals(pool, miss) mock.assert_called_once_with(td) @patch('dyn.tm.services.DSFRuleset.add_response_pool') @patch('dyn.tm.services.DSFRuleset.create') # just lets us ignore the pool.create calls @patch('dyn.tm.services.DSFResponsePool.create') def test_mod_geo_rulesets_create(self, _, ruleset_create_mock, add_response_pool_mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) td_mock = MagicMock() td_mock._rulesets = [] provider._traffic_director_monitor = MagicMock() provider._find_or_create_geo_pool = MagicMock() td_mock.all_response_pools = [] provider._find_or_create_geo_pool.side_effect = [ _DummyPool('default'), _DummyPool(1), _DummyPool(2), _DummyPool(3), _DummyPool(4), ] change = Create(self.geo_record) provider._mod_geo_rulesets(td_mock, change) ruleset_create_mock.assert_has_calls(( call(td_mock, index=0), call(td_mock, index=0), call(td_mock, index=0), call(td_mock, index=0), call(td_mock, index=0), )) add_response_pool_mock.assert_has_calls(( # default call('default'), # first geo and it's fallback call(1), call('default', index=999), # 2nd geo and it's fallback call(2), call('default', index=999), # 3nd geo and it's fallback call(3), call('default', index=999), # 4th geo and it's 2 levels of fallback call(4), call(3, index=999), call('default', index=999), )) # have to patch the place it's imported into, not where it lives @patch('octodns.provider.dyn.get_response_pool') @patch('dyn.tm.services.DSFRuleset.add_response_pool') @patch('dyn.tm.services.DSFRuleset.create') # just lets us ignore the pool.create calls @patch('dyn.tm.services.DSFResponsePool.create') def test_mod_geo_rulesets_existing(self, _, ruleset_create_mock, add_response_pool_mock, get_response_pool_mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) ruleset_mock = MagicMock() ruleset_mock.response_pools = [_DummyPool(3)] td_mock = MagicMock() td_mock._rulesets = [ ruleset_mock, ] provider._traffic_director_monitor = MagicMock() provider._find_or_create_geo_pool = MagicMock() unused_pool = _DummyPool('unused') td_mock.all_response_pools = \ ruleset_mock.response_pools + [unused_pool] get_response_pool_mock.return_value = unused_pool provider._find_or_create_geo_pool.side_effect = [ _DummyPool('default'), _DummyPool(1), _DummyPool(2), ruleset_mock.response_pools[0], _DummyPool(4), ] change = Create(self.geo_record) provider._mod_geo_rulesets(td_mock, change) ruleset_create_mock.assert_has_calls(( call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), )) add_response_pool_mock.assert_has_calls(( # default call('default'), # first geo and it's fallback call(1), call('default', index=999), # 2nd geo and it's fallback call(2), call('default', index=999), # 3nd geo, from existing, and it's fallback call(3), call('default', index=999), # 4th geo and it's 2 levels of fallback call(4), call(3, index=999), call('default', index=999), )) # unused poll should have been deleted self.assertTrue(unused_pool.deleted) # old ruleset ruleset should be deleted, it's pool will have been # reused ruleset_mock.delete.assert_called_once() class TestDynProviderAlias(TestCase): expected = Zone('unit.tests.', []) for name, data in ( ('', { 'type': 'ALIAS', 'ttl': 300, 'value': 'www.unit.tests.' }), ('www', { 'type': 'A', 'ttl': 300, 'values': ['1.2.3.4'] })): expected.add_record(Record.new(expected, name, data)) def setUp(self): # Flush our zone to ensure we start fresh _CachingDynZone.flush_zone(self.expected.name[:-1]) @patch('dyn.core.SessionEngine.execute') def test_populate(self, execute_mock): provider = DynProvider('test', 'cust', 'user', 'pass') # Test Zone create execute_mock.side_effect = [ # get Zone {'data': {}}, # get_all_records {'data': { 'a_records': [{ 'fqdn': 'www.unit.tests', 'rdata': {'address': '1.2.3.4'}, 'record_id': 1, 'record_type': 'A', 'ttl': 300, 'zone': 'unit.tests', }], 'alias_records': [{ 'fqdn': 'unit.tests', 'rdata': {'alias': 'www.unit.tests.'}, 'record_id': 2, 'record_type': 'ALIAS', 'ttl': 300, 'zone': 'unit.tests', }], }} ] got = Zone('unit.tests.', []) provider.populate(got) execute_mock.assert_has_calls([ call('/Zone/unit.tests/', 'GET', {}), call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'}) ]) changes = self.expected.changes(got, SimpleProvider()) self.assertEquals([], changes) @patch('dyn.core.SessionEngine.execute') def test_sync(self, execute_mock): provider = DynProvider('test', 'cust', 'user', 'pass') # Test Zone create execute_mock.side_effect = [ # No such zone, during populate DynectGetError('foo'), # No such zone, during sync DynectGetError('foo'), # get empty Zone {'data': {}}, # get zone we can modify & delete with {'data': { # A top-level to delete 'a_records': [{ 'fqdn': 'www.unit.tests', 'rdata': {'address': '1.2.3.4'}, 'record_id': 1, 'record_type': 'A', 'ttl': 300, 'zone': 'unit.tests', }], # A node to delete 'alias_records': [{ 'fqdn': 'unit.tests', 'rdata': {'alias': 'www.unit.tests.'}, 'record_id': 2, 'record_type': 'ALIAS', 'ttl': 300, 'zone': 'unit.tests', }], }} ] # No existing records, create all with patch('dyn.tm.zones.Zone.add_record') as add_mock: with patch('dyn.tm.zones.Zone._update') as update_mock: plan = provider.plan(self.expected) update_mock.assert_not_called() provider.apply(plan) update_mock.assert_called() add_mock.assert_called() # Once for each dyn record self.assertEquals(2, len(add_mock.call_args_list)) execute_mock.assert_has_calls([call('/Zone/unit.tests/', 'GET', {}), call('/Zone/unit.tests/', 'GET', {})]) self.assertEquals(2, len(plan.changes)) # Need a class that doesn't do all the "real" stuff, but gets our monkey # patching class DummyDSFMonitor(DSFMonitor): def __init__(self, host=None, path=None, protocol=None, port=None, options_host=None, options_path=None, options_protocol=None, options_port=None): # not calling super on purpose self._host = host self._path = path self._protocol = protocol self._port = port if options_host: self._options = { 'host': options_host, 'path': options_path, 'protocol': options_protocol, 'port': options_port, } else: self._options = None class TestDSFMonitorMonkeyPatching(TestCase): def test_host(self): monitor = DummyDSFMonitor(host='host.com', path='/path', protocol='HTTP', port=8080) self.assertEquals('host.com', monitor.host) self.assertEquals('/path', monitor.path) self.assertEquals('HTTP', monitor.protocol) self.assertEquals(8080, monitor.port) monitor = DummyDSFMonitor(options_host='host.com', options_path='/path', options_protocol='HTTP', options_port=8080) self.assertEquals('host.com', monitor.host) self.assertEquals('/path', monitor.path) monitor.host = 'other.com' self.assertEquals('other.com', monitor.host) monitor.path = '/other-path' self.assertEquals('/other-path', monitor.path) monitor.protocol = 'HTTPS' self.assertEquals('HTTPS', monitor.protocol) monitor.port = 8081 self.assertEquals(8081, monitor.port) monitor = DummyDSFMonitor() monitor.host = 'other.com' self.assertEquals('other.com', monitor.host) monitor = DummyDSFMonitor() monitor.path = '/other-path' self.assertEquals('/other-path', monitor.path) monitor.protocol = 'HTTP' self.assertEquals('HTTP', monitor.protocol) monitor.port = 8080 self.assertEquals(8080, monitor.port) # Just to exercise the _options init monitor = DummyDSFMonitor() monitor.protocol = 'HTTP' self.assertEquals('HTTP', monitor.protocol) monitor = DummyDSFMonitor() monitor.port = 8080 self.assertEquals(8080, monitor.port) class DummyRecord(object): def __init__(self, address, weight, ttl): self.address = address self.weight = weight self.ttl = ttl class DummyRecordSets(object): def __init__(self, records): self.records = records class DummyRsChains(object): def __init__(self, records): self.record_sets = [DummyRecordSets(records)] class DummyResponsePool(object): def __init__(self, label, records=[]): self.label = label if records: self.rs_chains = [DummyRsChains(records)] else: self.rs_chains = [] def refresh(self): pass class DummyRuleset(object): def __init__(self, label, response_pools=[], criteria_type='always', criteria={}): self.label = label self.response_pools = response_pools self.criteria_type = criteria_type self.criteria = criteria class DummyTrafficDirector(object): def __init__(self, zone_name, rulesets=[], response_pools=[], ttl=42): self.label = 'dummy:abcdef1234567890' self.rulesets = rulesets self.all_response_pools = response_pools self.ttl = ttl self.nodes = [{'zone': zone_name[:-1]}] class TestDynProviderDynamic(TestCase): def test_value_for_address(self): provider = DynProvider('test', 'cust', 'user', 'pass') class DummyRecord(object): def __init__(self, address, weight): self.address = address self.weight = weight record = DummyRecord('1.2.3.4', 32) self.assertEquals({ 'value': record.address, 'weight': record.weight, }, provider._value_for_A('A', record)) record = DummyRecord('2601:644:500:e210:62f8:1dff:feb8:947a', 32) self.assertEquals({ 'value': record.address, 'weight': record.weight, }, provider._value_for_AAAA('AAAA', record)) def test_value_for_CNAME(self): provider = DynProvider('test', 'cust', 'user', 'pass') class DummyRecord(object): def __init__(self, cname, weight): self.cname = cname self.weight = weight record = DummyRecord('foo.unit.tests.', 32) self.assertEquals({ 'value': record.cname, 'weight': record.weight, }, provider._value_for_CNAME('CNAME', record)) def test_populate_dynamic_pools(self): provider = DynProvider('test', 'cust', 'user', 'pass') # Empty data, empty returns default, pools = provider._populate_dynamic_pools('A', [], []) self.assertEquals({}, default) self.assertEquals({}, pools) records_a = [DummyRecord('1.2.3.4', 32, 60)] default_a = DummyResponsePool('default', records_a) # Just a default A response_pools = [default_a] default, pools = provider._populate_dynamic_pools('A', [], response_pools) self.assertEquals({ 'ttl': 60, 'type': 'A', 'values': ['1.2.3.4'], }, default) self.assertEquals({}, pools) multi_a = [ DummyRecord('1.2.3.5', 42, 90), DummyRecord('1.2.3.6', 43, 90), DummyRecord('1.2.3.7', 44, 90), ] example_a = DummyResponsePool('example', multi_a) # Just a named pool response_pools = [example_a] default, pools = provider._populate_dynamic_pools('A', [], response_pools) self.assertEquals({}, default) self.assertEquals({ 'example': { 'values': [{ 'value': '1.2.3.5', 'weight': 42, }, { 'value': '1.2.3.6', 'weight': 43, }, { 'value': '1.2.3.7', 'weight': 44, }], }, }, pools) # Named pool that shows up twice response_pools = [example_a, example_a] default, pools = provider._populate_dynamic_pools('A', [], response_pools) self.assertEquals({}, default) self.assertEquals({ 'example': { 'values': [{ 'value': '1.2.3.5', 'weight': 42, }, { 'value': '1.2.3.6', 'weight': 43, }, { 'value': '1.2.3.7', 'weight': 44, }], }, }, pools) # Default & named response_pools = [example_a, default_a, example_a] default, pools = provider._populate_dynamic_pools('A', [], response_pools) self.assertEquals({ 'ttl': 60, 'type': 'A', 'values': ['1.2.3.4'], }, default) self.assertEquals({ 'example': { 'values': [{ 'value': '1.2.3.5', 'weight': 42, }, { 'value': '1.2.3.6', 'weight': 43, }, { 'value': '1.2.3.7', 'weight': 44, }], }, }, pools) # empty rs_chains doesn't cause an example, just ignores empty_a = DummyResponsePool('empty') response_pools = [empty_a] default, pools = provider._populate_dynamic_pools('A', [], response_pools) self.assertEquals({}, default) self.assertEquals({}, pools) def test_populate_dynamic_rules(self): provider = DynProvider('test', 'cust', 'user', 'pass') # Empty rulesets = [] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([], rules) # default: is ignored rulesets = [DummyRuleset('default:')] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([], rules) # No ResponsePools in RuleSet, ignored rulesets = [DummyRuleset('0:abcdefg')] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([], rules) # ResponsePool, no fallback rulesets = [DummyRuleset('0:abcdefg', [ DummyResponsePool('some-pool') ])] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([{ 'pool': 'some-pool', }], rules) # ResponsePool, with dfault fallback (ignored) rulesets = [DummyRuleset('0:abcdefg', [ DummyResponsePool('some-pool'), DummyResponsePool('default'), ])] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([{ 'pool': 'some-pool', }], rules) # ResponsePool, with fallback rulesets = [DummyRuleset('0:abcdefg', [ DummyResponsePool('some-pool'), DummyResponsePool('some-fallback'), ])] pools = { 'some-pool': {}, } rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([{ 'pool': 'some-pool', }], rules) # fallback has been installed self.assertEquals({ 'some-pool': { 'fallback': 'some-fallback', } }, pools) # Unsupported criteria_type (ignored) rulesets = [DummyRuleset('0:abcdefg', [ DummyResponsePool('some-pool') ], 'unsupported')] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([], rules) # Geo Continent/Region response_pools = [DummyResponsePool('some-pool')] criteria = { 'geoip': { 'country': ['US'], 'province': ['or'], 'region': [14], }, } ruleset = DummyRuleset('0:abcdefg', response_pools, 'geoip', criteria) rulesets = [ruleset] pools = {} rules = provider._populate_dynamic_rules(rulesets, pools) self.assertEquals([{ 'geos': ['AF', 'NA-US', 'NA-US-OR'], 'pool': 'some-pool', }], rules) def test_populate_dynamic_traffic_director(self): provider = DynProvider('test', 'cust', 'user', 'pass') fqdn = 'dynamic.unit.tests.' multi_a = [ DummyRecord('1.2.3.5', 1, 90), DummyRecord('1.2.3.6', 1, 90), DummyRecord('1.2.3.7', 1, 90), ] default_response_pool = DummyResponsePool('default', multi_a) pool1_response_pool = DummyResponsePool('pool1', multi_a) rulesets = [ DummyRuleset('default', [default_response_pool]), DummyRuleset('0:abcdef', [pool1_response_pool], 'geoip', { 'geoip': { 'country': ['US'], 'province': ['or'], 'region': [14], }, }), ] zone = Zone('unit.tests.', []) td = DummyTrafficDirector(zone.name, rulesets, [default_response_pool, pool1_response_pool]) record = provider._populate_dynamic_traffic_director(zone, fqdn, 'A', td, rulesets, True) self.assertTrue(record) self.assertEquals('A', record._type) self.assertEquals(90, record.ttl) self.assertEquals([ '1.2.3.5', '1.2.3.6', '1.2.3.7', ], record.values) self.assertTrue('pool1' in record.dynamic.pools) self.assertEquals({ 'fallback': None, 'values': [{ 'value': '1.2.3.5', 'weight': 1, }, { 'value': '1.2.3.6', 'weight': 1, }, { 'value': '1.2.3.7', 'weight': 1, }] }, record.dynamic.pools['pool1'].data) self.assertEquals(2, len(record.dynamic.rules)) self.assertEquals({ 'pool': 'default', }, record.dynamic.rules[0].data) self.assertEquals({ 'pool': 'pool1', 'geos': ['AF', 'NA-US', 'NA-US-OR'], }, record.dynamic.rules[1].data) # Hack into the provider and create a fake list of traffic directors provider._traffic_directors = { 'dynamic.unit.tests.': { 'A': td, } } zone = Zone('unit.tests.', []) records = provider._populate_traffic_directors(zone, lenient=True) self.assertEquals(1, len(records)) def test_dynamic_records_for_A(self): provider = DynProvider('test', 'cust', 'user', 'pass') # Empty records = provider._dynamic_records_for_A([], {}) self.assertEquals([], records) # Basic values = [{ 'value': '1.2.3.4', }, { 'value': '1.2.3.5', 'weight': 42, }] records = provider._dynamic_records_for_A(values, {}) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('1.2.3.4', record.address) self.assertEquals(1, record.weight) record = records[1] self.assertEquals('1.2.3.5', record.address) self.assertEquals(42, record.weight) # With extras records = provider._dynamic_records_for_A(values, { 'automation': 'manual', 'eligible': True, }) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('1.2.3.4', record.address) self.assertEquals(1, record.weight) self.assertEquals('manual', record._automation) self.assertTrue(record.eligible) def test_dynamic_records_for_AAAA(self): provider = DynProvider('test', 'cust', 'user', 'pass') # Empty records = provider._dynamic_records_for_AAAA([], {}) self.assertEquals([], records) # Basic values = [{ 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', }, { 'value': '2601:644:500:e210:62f8:1dff:feb8:947b', 'weight': 42, }] records = provider._dynamic_records_for_AAAA(values, {}) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('2601:644:500:e210:62f8:1dff:feb8:947a', record.address) self.assertEquals(1, record.weight) record = records[1] self.assertEquals('2601:644:500:e210:62f8:1dff:feb8:947b', record.address) self.assertEquals(42, record.weight) # With extras records = provider._dynamic_records_for_AAAA(values, { 'automation': 'manual', 'eligible': True, }) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('2601:644:500:e210:62f8:1dff:feb8:947a', record.address) self.assertEquals(1, record.weight) self.assertEquals('manual', record._automation) self.assertTrue(record.eligible) def test_dynamic_records_for_CNAME(self): provider = DynProvider('test', 'cust', 'user', 'pass') # Empty records = provider._dynamic_records_for_CNAME([], {}) self.assertEquals([], records) # Basic values = [{ 'value': 'target-1.unit.tests.', }, { 'value': 'target-2.unit.tests.', 'weight': 42, }] records = provider._dynamic_records_for_CNAME(values, {}) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('target-1.unit.tests.', record.cname) self.assertEquals(1, record.weight) record = records[1] self.assertEquals('target-2.unit.tests.', record.cname) self.assertEquals(42, record.weight) # With extras records = provider._dynamic_records_for_CNAME(values, { 'automation': 'manual', 'eligible': True, }) self.assertEquals(2, len(records)) record = records[0] self.assertEquals('target-1.unit.tests.', record.cname) self.assertEquals(1, record.weight) self.assertEquals('manual', record._automation) self.assertTrue(record.eligible) def test_dynamic_value_sort_key(self): values = [{ 'value': '1.2.3.1', }, { 'value': '1.2.3.27', }, { 'value': '1.2.3.127', }, { 'value': '1.2.3.2', }] self.assertEquals([{ 'value': '1.2.3.1', }, { 'value': '1.2.3.127', }, { 'value': '1.2.3.2', }, { 'value': '1.2.3.27', }], sorted(values, key=_dynamic_value_sort_key)) @patch('dyn.tm.services.DSFResponsePool.create') def test_find_or_create_dynamic_pools(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass') td = 42 label = 'foo' values = [{ 'value': '1.2.3.1', }, { 'value': '1.2.3.127', }, { 'value': '1.2.3.2', }, { 'value': '1.2.3.27', }] # A Pool with no existing pools, will create pools = [] pool = provider._find_or_create_dynamic_pool(td, pools, label, 'A', values) self.assertIsInstance(pool, DSFResponsePool) self.assertEquals(1, len(pool.rs_chains)) self.assertEquals(1, len(pool.rs_chains[0].record_sets)) records = pool.rs_chains[0].record_sets[0].records self.assertEquals(4, len(records)) self.assertEquals([v['value'] for v in values], [r.address for r in records]) self.assertEquals([1 for r in records], [r.weight for r in records]) mock.assert_called_once_with(td) # Ask for the pool we created above and include it in the canidate list mock.reset_mock() pools = [pool] cached = provider._find_or_create_dynamic_pool(td, pools, label, 'A', values) self.assertEquals(pool, cached) mock.assert_not_called() # Invalid candidate pool, still finds the valid one that's there too mock.reset_mock() invalid = DSFResponsePool(label, rs_chains=[]) pools = [invalid, pool] cached = provider._find_or_create_dynamic_pool(td, pools, label, 'A', values) self.assertEquals(pool, cached) mock.assert_not_called() # Ask for a pool with a different label, should create a new one mock.reset_mock() pools = [pool] other = provider._find_or_create_dynamic_pool(td, pools, 'other', 'A', values) self.assertEquals('other', other.label) mock.assert_called_once_with(td) # Ask for a pool that matches label-wise, but has different values values = [{ 'value': '1.2.3.44', }] mock.reset_mock() pools = [pool] new = provider._find_or_create_dynamic_pool(td, pools, label, 'A', values) self.assertEquals(label, new.label) self.assertEquals(1, len(new.rs_chains)) self.assertEquals(1, len(new.rs_chains[0].record_sets)) records = new.rs_chains[0].record_sets[0].records self.assertEquals(1, len(records)) self.assertEquals([v['value'] for v in values], [r.address for r in records]) self.assertEquals([1 for r in records], [r.weight for r in records]) mock.assert_called_once_with(td) zone = Zone('unit.tests.', []) dynamic_a_record = Record.new(zone, '', { 'dynamic': { 'pools': { 'one': { 'values': [{ 'value': '3.3.3.3', }], }, 'two': { # Testing out of order value sorting here 'values': [{ 'value': '5.5.5.5', }, { 'value': '4.4.4.4', }], }, 'three': { 'fallback': 'two', 'values': [{ 'weight': 10, 'value': '4.4.4.4', }, { 'weight': 12, 'value': '5.5.5.5', }], }, }, 'rules': [{ 'geos': ['AF', 'EU', 'AS-JP'], 'pool': 'three', }, { 'geos': ['NA-US-CA'], 'pool': 'two', }, { 'pool': 'one', }], }, 'type': 'A', 'ttl': 60, 'values': [ '1.1.1.1', '2.2.2.2', ], }) geo_a_record = Record.new(zone, '', { 'geo': { 'AF': ['2.2.3.4', '2.2.3.5'], 'AS-JP': ['3.2.3.4', '3.2.3.5'], 'NA-US': ['4.2.3.4', '4.2.3.5'], 'NA-US-CA': ['5.2.3.4', '5.2.3.5'] }, 'ttl': 300, 'type': 'A', 'values': ['1.2.3.4', '1.2.3.5'], }) regular_a_record = Record.new(zone, '', { 'ttl': 301, 'type': 'A', 'value': '1.2.3.4', }) dynamic_cname_record = Record.new(zone, 'www', { 'dynamic': { 'pools': { 'one': { 'values': [{ 'value': 'target-0.unit.tests.', }], }, 'two': { # Testing out of order value sorting here 'values': [{ 'value': 'target-1.unit.tests.', }, { 'value': 'target-2.unit.tests.', }], }, 'three': { 'values': [{ 'weight': 10, 'value': 'target-3.unit.tests.', }, { 'weight': 12, 'value': 'target-4.unit.tests.', }], }, }, 'rules': [{ 'geos': ['AF', 'EU', 'AS-JP'], 'pool': 'three', }, { 'geos': ['NA-US-CA'], 'pool': 'two', }, { 'pool': 'one', }], }, 'type': 'CNAME', 'ttl': 60, 'value': 'target.unit.tests.', }) dynamic_fallback_loop = Record.new(zone, '', { 'dynamic': { 'pools': { 'one': { 'values': [{ 'value': '3.3.3.3', }], }, 'two': { # Testing out of order value sorting here 'fallback': 'three', 'values': [{ 'value': '5.5.5.5', }, { 'value': '4.4.4.4', }], }, 'three': { 'fallback': 'two', 'values': [{ 'weight': 10, 'value': '4.4.4.4', }, { 'weight': 12, 'value': '5.5.5.5', }], }, }, 'rules': [{ 'geos': ['AF', 'EU', 'AS-JP'], 'pool': 'three', }, { 'geos': ['NA-US-CA'], 'pool': 'two', }, { 'pool': 'one', }], }, 'type': 'A', 'ttl': 60, 'values': [ '1.1.1.1', '2.2.2.2', ], }, lenient=True) @patch('dyn.tm.services.DSFRuleset.add_response_pool') @patch('dyn.tm.services.DSFRuleset.create') # just lets us ignore the pool.create calls @patch('dyn.tm.services.DSFResponsePool.create') def test_mod_dynamic_rulesets_create_CNAME(self, _, ruleset_create_mock, add_response_pool_mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) td_mock = MagicMock() td_mock._rulesets = [] provider._traffic_director_monitor = MagicMock() provider._find_or_create_dynamic_pool = MagicMock() td_mock.all_response_pools = [] provider._find_or_create_dynamic_pool.side_effect = [ _DummyPool('default'), _DummyPool('one'), _DummyPool('two'), _DummyPool('three'), ] change = Create(self.dynamic_cname_record) provider._mod_dynamic_rulesets(td_mock, change) add_response_pool_mock.assert_has_calls(( # default call('default'), # first dynamic and it's fallback call('one'), call('default', index=999), # 2nd dynamic and it's fallback call('three'), call('default', index=999), # 3nd dynamic and it's fallback call('two'), call('default', index=999), )) ruleset_create_mock.assert_has_calls(( call(td_mock, index=0), call(td_mock, index=0), call(td_mock, index=0), call(td_mock, index=0), )) # have to patch the place it's imported into, not where it lives @patch('octodns.provider.dyn.get_response_pool') @patch('dyn.tm.services.DSFRuleset.add_response_pool') @patch('dyn.tm.services.DSFRuleset.create') # just lets us ignore the pool.create calls @patch('dyn.tm.services.DSFResponsePool.create') def test_mod_dynamic_rulesets_existing(self, _, ruleset_create_mock, add_response_pool_mock, get_response_pool_mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) ruleset_mock = MagicMock() ruleset_mock.response_pools = [_DummyPool('three')] td_mock = MagicMock() td_mock._rulesets = [ ruleset_mock, ] provider._traffic_director_monitor = MagicMock() provider._find_or_create_dynamic_pool = MagicMock() # Matching ttl td_mock.ttl = self.dynamic_a_record.ttl unused_pool = _DummyPool('unused') td_mock.all_response_pools = \ ruleset_mock.response_pools + [unused_pool] get_response_pool_mock.return_value = unused_pool provider._find_or_create_dynamic_pool.side_effect = [ _DummyPool('default'), _DummyPool('one'), _DummyPool('two'), ruleset_mock.response_pools[0], ] change = Create(self.dynamic_a_record) provider._mod_dynamic_rulesets(td_mock, change) add_response_pool_mock.assert_has_calls(( # default call('default'), # first dynamic and it's fallback call('one'), call('default', index=999), # 2nd dynamic and it's fallback call('three'), call('default', index=999), # 3nd dynamic, from existing, and it's fallback call('two'), call('three', index=999), call('default', index=999), )) ruleset_create_mock.assert_has_calls(( call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), )) # unused poll should have been deleted self.assertTrue(unused_pool.deleted) # old ruleset ruleset should be deleted, it's pool will have been # reused ruleset_mock.delete.assert_called_once() # have to patch the place it's imported into, not where it lives @patch('octodns.provider.dyn.get_response_pool') @patch('dyn.tm.services.DSFRuleset.add_response_pool') @patch('dyn.tm.services.DSFRuleset.create') # just lets us ignore the pool.create calls @patch('dyn.tm.services.DSFResponsePool.create') def test_mod_dynamic_rulesets_fallback_loop(self, _, ruleset_create_mock, add_response_pool_mock, get_response_pool_mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) ruleset_mock = MagicMock() ruleset_mock.response_pools = [_DummyPool('three')] td_mock = MagicMock() td_mock._rulesets = [ ruleset_mock, ] provider._traffic_director_monitor = MagicMock() provider._find_or_create_dynamic_pool = MagicMock() # Matching ttl td_mock.ttl = self.dynamic_fallback_loop.ttl unused_pool = _DummyPool('unused') td_mock.all_response_pools = \ ruleset_mock.response_pools + [unused_pool] get_response_pool_mock.return_value = unused_pool provider._find_or_create_dynamic_pool.side_effect = [ _DummyPool('default'), _DummyPool('one'), _DummyPool('two'), ruleset_mock.response_pools[0], ] change = Create(self.dynamic_fallback_loop) provider._mod_dynamic_rulesets(td_mock, change) add_response_pool_mock.assert_has_calls(( # default call('default'), # first dynamic and it's fallback call('one'), call('default', index=999), # 2nd dynamic and it's fallback (no loop) call('three'), call('two', index=999), call('default', index=999), # 3nd dynamic and it's fallback (no loop) call('two'), call('three', index=999), call('default', index=999), )) ruleset_create_mock.assert_has_calls(( call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), call(td_mock, index=2), )) # unused poll should have been deleted self.assertTrue(unused_pool.deleted) # old ruleset ruleset should be deleted, it's pool will have been # reused ruleset_mock.delete.assert_called_once() with open('./tests/fixtures/dyn-traffic-director-get.json') as fh: traffic_director_response = loads(fh.read()) @property def traffic_directors_response(self): return { 'data': [{ 'active': 'Y', 'label': 'unit.tests.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '2ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'some.other.:A', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '3ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }, { 'active': 'Y', 'label': 'other format', 'nodes': [], 'notifiers': [], 'pending_change': '', 'rulesets': [], 'service_id': '4ERWXQNsb_IKG2YZgYqkPvk0PBM', 'ttl': '300' }] } @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_create(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # will be tested separately provider._mod_dynamic_rulesets = MagicMock() mock.side_effect = [ # create traffic director self.traffic_director_response, # get traffic directors self.traffic_directors_response ] provider._mod_dynamic_Create(None, Create(self.dynamic_a_record)) # td now lives in cache self.assertTrue('A' in provider.traffic_directors['unit.tests.']) # should have seen 1 gen call provider._mod_dynamic_rulesets.assert_called_once() def test_mod_dynamic_update_dynamic_dynamic(self): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # update of an existing dynamic td # pre-populate the cache with our mock td provider._traffic_directors = { 'unit.tests.': { 'A': 42, } } # mock _mod_dynamic_rulesets provider._mod_dynamic_rulesets = MagicMock() dyn = self.dynamic_a_record change = Update(dyn, dyn) provider._mod_dynamic_Update(None, change) # still in cache self.assertTrue('A' in provider.traffic_directors['unit.tests.']) # should have seen 1 gen call provider._mod_dynamic_rulesets.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_update_dynamic_geo(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a dynamic td to a geo record provider._mod_geo_Update = MagicMock() change = Update(self.dynamic_a_record, self.geo_a_record) provider._mod_dynamic_Update(42, change) # should have seen a call to create the new geo record provider._mod_geo_Update.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_update_dynamic_regular(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a dynamic td to a regular record provider._mod_Create = MagicMock() provider._mod_dynamic_Delete = MagicMock() change = Update(self.dynamic_a_record, self.regular_a_record) provider._mod_dynamic_Update(42, change) # should have seen a call to create the new regular record provider._mod_Create.assert_called_once_with(42, change) # should have seen a call to delete the old td record provider._mod_dynamic_Delete.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_update_geo_dynamic(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a geo record to a dynamic td # pre-populate the cache with our mock td provider._traffic_directors = { 'unit.tests.': { 'A': 42, } } # mock _mod_dynamic_rulesets provider._mod_dynamic_rulesets = MagicMock() change = Update(self.geo_a_record, self.dynamic_a_record) provider._mod_dynamic_Update(None, change) # still in cache self.assertTrue('A' in provider.traffic_directors['unit.tests.']) # should have seen 1 gen call provider._mod_dynamic_rulesets.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_update_regular_dynamic(self, _): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # convert a regular record to a dynamic td provider._mod_dynamic_Create = MagicMock() provider._mod_Delete = MagicMock() change = Update(self.regular_a_record, self.dynamic_a_record) provider._mod_dynamic_Update(42, change) # should have seen a call to create the new geo record provider._mod_dynamic_Create.assert_called_once_with(42, change) # should have seen a call to delete the old regular record provider._mod_Delete.assert_called_once_with(42, change) @patch('dyn.core.SessionEngine.execute') def test_mod_dynamic_delete(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) td_mock = MagicMock() provider._traffic_directors = { 'unit.tests.': { 'A': td_mock, } } provider._mod_dynamic_Delete(None, Delete(self.dynamic_a_record)) # delete called td_mock.delete.assert_called_once() # removed from cache self.assertFalse('A' in provider.traffic_directors['unit.tests.']) @patch('dyn.core.SessionEngine.execute') def test_apply_traffic_directors_dynamic(self, mock): provider = DynProvider('test', 'cust', 'user', 'pass', traffic_directors_enabled=True) # will be tested separately provider._mod_dynamic_Create = MagicMock() changes = [Create(self.dynamic_a_record)] provider._apply_traffic_directors(self.zone, changes, None) provider._mod_dynamic_Create.assert_called_once()