diff --git a/octodns/provider/azuredns.py b/octodns/provider/azuredns.py index fd5555d..845d4d5 100644 --- a/octodns/provider/azuredns.py +++ b/octodns/provider/azuredns.py @@ -301,38 +301,10 @@ def _get_monitor(record): def _check_valid_dynamic(record): typ = record._type - dynamic = record.dynamic if typ in ['A', 'AAAA']: - # A/AAAA records cannot be aliased to Traffic Managers that contain - # other nested Traffic Managers. Due to this limitation, A/AAAA - # dynamic records can do only one of geo-fencing, fallback and - # weighted RR. So let's validate that the record adheres to this - # limitation. - data = dynamic._data() - values = set(record.values) - pools = data['pools'].values() - seen_values = set() - rr = False - fallback = False - for pool in pools: - vals = pool['values'] - if len(vals) > 1: - rr = True - pool_values = set(val['value'] for val in vals) - if pool.get('fallback'): - fallback = True - seen_values.update(pool_values) - - if values != seen_values: - msg = ('{} {}: All pool values of A/AAAA dynamic records must be ' - 'included in top-level \'values\'.') - raise AzureException(msg.format(record.fqdn, record._type)) - - geo = any(r.get('geos') for r in data['rules']) - - if [rr, fallback, geo].count(True) > 1: - msg = ('{} {}: A/AAAA dynamic records must use at most one of ' - 'round-robin, fallback and geo-fencing') + if len(record.values) > 1: + # we don't yet support multi-value defaults + msg = '{} {}: A/AAAA dynamic records can only have a single value' raise AzureException(msg.format(record.fqdn, record._type)) elif typ != 'CNAME': # dynamic records of unsupported type @@ -911,14 +883,6 @@ class AzureProvider(BaseProvider): active = set() profiles = self._generate_traffic_managers(record) - # this should not happen with above check, check again here to - # prevent undesired changes - if record._type in ['A', 'AAAA'] and len(profiles) > 1: - msg = ('Unknown error: {} {} needs more than 1 Traffic ' - 'Managers which is not supported for A/AAAA dynamic ' - 'records').format(record.fqdn, record._type) - raise AzureException(msg) - for profile in profiles: name = profile.name @@ -1170,8 +1134,7 @@ class AzureProvider(BaseProvider): return traffic_managers - def _sync_traffic_managers(self, record): - desired_profiles = self._generate_traffic_managers(record) + def _sync_traffic_managers(self, desired_profiles): seen = set() tm_sync = self._tm_client.profiles.create_or_update @@ -1230,12 +1193,22 @@ class AzureProvider(BaseProvider): record = change.new dynamic = getattr(record, 'dynamic', False) + root_profile = None + endpoints = [] if dynamic: - self._sync_traffic_managers(record) + profiles = self._generate_traffic_managers(record) + root_profile = profiles[-1] + if record._type in ['A', 'AAAA'] and len(profiles) > 1: + # A/AAAA records cannot be aliased to Traffic Managers that + # contain other nested Traffic Managers. To work around this + # limitation, we remove nesting before adding the record, and + # then add the nested endpoints later. + endpoints = root_profile.endpoints + root_profile.endpoints = [] + self._sync_traffic_managers(profiles) - profile = self._get_tm_for_dynamic_record(record) ar = _AzureRecord(self._resource_group, record, - traffic_manager=profile) + traffic_manager=root_profile) create = self._dns_client.record_sets.create_or_update create(resource_group_name=ar.resource_group, @@ -1244,6 +1217,12 @@ class AzureProvider(BaseProvider): record_type=ar.record_type, parameters=ar.params) + if endpoints: + # add nested endpoints for A/AAAA dynamic record limitation after + # record creation + root_profile.endpoints = endpoints + self._sync_traffic_managers([root_profile]) + self.log.debug('* Success Create: {}'.format(record)) def _apply_Update(self, change): @@ -1256,19 +1235,35 @@ class AzureProvider(BaseProvider): ''' existing = change.existing new = change.new + typ = new._type existing_is_dynamic = getattr(existing, 'dynamic', False) new_is_dynamic = getattr(new, 'dynamic', False) update_record = True if new_is_dynamic: - active = self._sync_traffic_managers(new) - # only TTL is configured in record, everything else goes inside - # traffic managers, so no need to update if TTL is unchanged - # and existing record is already aliased to its traffic manager - if existing.ttl == new.ttl and existing_is_dynamic: + endpoints = [] + profiles = self._generate_traffic_managers(new) + root_profile = profiles[-1] + + if typ in ['A', 'AAAA']: + if existing_is_dynamic: + # update to the record is not needed + update_record = False + elif len(profiles) > 1: + # record needs to aliased; remove nested endpoints, we + # will add them at the end + endpoints = root_profile.endpoints + root_profile.endpoints = [] + elif existing.ttl == new.ttl and existing_is_dynamic: + # CNAME dynamic records only have TTL in them, everything else + # goes inside the aliased traffic managers; skip update if TTL + # is unchanged and existing record is already aliased to its + # traffic manager update_record = False + active = self._sync_traffic_managers(profiles) + if update_record: profile = self._get_tm_for_dynamic_record(new) ar = _AzureRecord(self._resource_group, new, @@ -1282,6 +1277,10 @@ class AzureProvider(BaseProvider): parameters=ar.params) if new_is_dynamic: + # add any pending nested endpoints + if endpoints: + root_profile.endpoints = endpoints + self._sync_traffic_managers([root_profile]) # let's cleanup unused traffic managers self._traffic_managers_gc(new, active) elif existing_is_dynamic: diff --git a/tests/test_octodns_provider_azuredns.py b/tests/test_octodns_provider_azuredns.py index b92372f..85fa572 100644 --- a/tests/test_octodns_provider_azuredns.py +++ b/tests/test_octodns_provider_azuredns.py @@ -4,7 +4,6 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from logging import debug from octodns.record import Create, Update, Delete, Record from octodns.provider.azuredns import _AzureRecord, AzureProvider, \ @@ -543,7 +542,14 @@ class TestAzureDnsProvider(TestCase): tm_sync = provider._tm_client.profiles.create_or_update def side_effect(rg, name, profile): - return profile + return Profile( + id=profile.id, + name=profile.name, + traffic_routing_method=profile.traffic_routing_method, + dns_config=profile.dns_config, + monitor_config=profile.monitor_config, + endpoints=profile.endpoints, + ) tm_sync.side_effect = side_effect @@ -1044,128 +1050,31 @@ class TestAzureDnsProvider(TestCase): provider._extra_changes(zone, desired, changes) self.assertTrue('duplicate endpoint' in text_type(ctx)) - def test_extra_changes_invalid_dynamic_A(self): + def test_extra_changes_A_multi_defaults(self): provider = self._get_provider() - # too many test case combinations, here's a method to generate them - def record_data(all_values=True, rr=True, fallback=True, geo=True): - data = { - 'type': 'A', - 'ttl': 60, - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '11.11.11.11'}, - {'value': '12.12.12.12'}, - ], - 'fallback': 'two', - }, - 'two': { - 'values': [ - {'value': '2.2.2.2'}, - ], - }, - }, - 'rules': [ - {'geos': ['EU'], 'pool': 'two'}, - {'pool': 'one'}, - ], - } - } - dynamic = data['dynamic'] - if not rr: - dynamic['pools']['one']['values'].pop() - if not fallback: - dynamic['pools']['one'].pop('fallback') - if not geo: - rule = dynamic['rules'].pop(0) - if not fallback: - dynamic['pools'].pop(rule['pool']) - # put all pool values in default - data['values'] = [ - v['value'] - for p in dynamic['pools'].values() - for v in p['values'] - ] - if not all_values: - rm = list(dynamic['pools'].values())[0]['values'][0]['value'] - data['values'].remove(rm) - return data - - # test all combinations - values = [True, False] - combos = [ - [arg1, arg2, arg3, arg4] - for arg1 in values - for arg2 in values - for arg3 in values - for arg4 in values - ] - for all_values, rr, fallback, geo in combos: - args = [all_values, rr, fallback, geo] - - if not any(args): - # all False, invalid use-case - continue - - debug('[all_values, rr, fallback, geo] = %s', args) - data = record_data(*args) - desired = Zone(name=zone.name, sub_zones=[]) - record = Record.new(desired, 'foo', data) - desired.add_record(record) - - features = args[1:] - if all_values and features.count(True) <= 1: - # assert does not raise exception - provider._extra_changes(zone, desired, [Create(record)]) - continue - - with self.assertRaises(AzureException) as ctx: - msg = text_type(ctx) - provider._extra_changes(zone, desired, [Create(record)]) - if not all_values: - self.assertTrue('included in top-level \'values\'' in msg) - else: - self.assertTrue('at most one of' in msg) - - @patch('octodns.provider.azuredns._check_valid_dynamic') - def test_extra_changes_dynamic_A_multiple_profiles(self, mock_cvd): - provider = self._get_provider() - - # bypass validity check to trigger mutliple-profiles check - mock_cvd.return_value = True - - desired = Zone(name=zone.name, sub_zones=[]) - record = Record.new(desired, 'foo', { + record = Record.new(zone, 'foo', data={ 'type': 'A', 'ttl': 60, - 'values': ['11.11.11.11', '12.12.12.12', '2.2.2.2'], + 'values': ['1.1.1.1', '8.8.8.8'], 'dynamic': { 'pools': { 'one': { - 'values': [ - {'value': '11.11.11.11'}, - {'value': '12.12.12.12'}, - ], - 'fallback': 'two', - }, - 'two': { - 'values': [ - {'value': '2.2.2.2'}, - ], + 'values': [{'value': '1.1.1.1'}], }, }, 'rules': [ - {'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}, ], } }) + + # test that extra changes doesn't show any changes + desired = Zone(zone.name, sub_zones=[]) desired.add_record(record) with self.assertRaises(AzureException) as ctx: - provider._extra_changes(zone, desired, [Create(record)]) - self.assertTrue('more than 1 Traffic Managers' in text_type(ctx)) + provider._extra_changes(zone, desired, []) + self.assertEqual('single value' in text_type(ctx)) def test_generate_tm_profile(self): provider, zone, record = self._get_dynamic_package() @@ -1798,44 +1707,110 @@ class TestAzureDnsProvider(TestCase): changes = provider._extra_changes(zone, desired, []) self.assertEqual(len(changes), 0) - def test_dynamic_A_geo(self): + def test_dynamic_A(self): provider = self._get_provider() external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' + nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' record = Record.new(zone, 'foo', data={ 'type': 'A', 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2', '3.3.3.3'], + 'values': ['9.9.9.9'], 'dynamic': { 'pools': { 'one': { 'values': [ - {'value': '1.1.1.1'}, + {'value': '11.11.11.11'}, + {'value': '12.12.12.12'}, ], + 'fallback': 'two' }, 'two': { 'values': [ {'value': '2.2.2.2'}, ], }, - 'three': { - 'values': [ - {'value': '3.3.3.3'}, - ], - }, }, 'rules': [ - {'geos': ['AS'], 'pool': 'one'}, - {'geos': ['AF'], 'pool': 'two'}, - {'pool': 'three'}, + {'geos': ['AF'], 'pool': 'one'}, + {'pool': 'two'}, ], } }) profiles = provider._generate_traffic_managers(record) - self.assertEqual(len(profiles), 1) + self.assertEqual(len(profiles), 4) self.assertTrue(_profile_is_match(profiles[0], Profile( + name='foo--unit--tests-A-pool-one', + traffic_routing_method='Weighted', + dns_config=DnsConfig( + relative_name='foo--unit--tests-a-pool-one', ttl=record.ttl), + monitor_config=_get_monitor(record), + endpoints=[ + Endpoint( + name='one--11.11.11.11', + type=external, + target='11.11.11.11', + weight=1, + ), + Endpoint( + name='one--12.12.12.12', + type=external, + target='12.12.12.12', + weight=1, + ), + ], + ))) + self.assertTrue(_profile_is_match(profiles[1], Profile( + name='foo--unit--tests-A-rule-one', + traffic_routing_method='Priority', + dns_config=DnsConfig( + relative_name='foo--unit--tests-a-rule-one', ttl=record.ttl), + monitor_config=_get_monitor(record), + endpoints=[ + Endpoint( + name='one', + type=nested, + target_resource_id=profiles[0].id, + priority=1, + ), + Endpoint( + name='two', + type=external, + target='2.2.2.2', + priority=2, + ), + Endpoint( + name='--default--', + type=external, + target='9.9.9.9', + priority=3, + ), + ], + ))) + self.assertTrue(_profile_is_match(profiles[2], Profile( + name='foo--unit--tests-A-rule-two', + traffic_routing_method='Priority', + dns_config=DnsConfig( + relative_name='foo--unit--tests-a-rule-two', ttl=record.ttl), + monitor_config=_get_monitor(record), + endpoints=[ + Endpoint( + name='two', + type=external, + target='2.2.2.2', + priority=1, + ), + Endpoint( + name='--default--', + type=external, + target='9.9.9.9', + priority=2, + ), + ], + ))) + self.assertTrue(_profile_is_match(profiles[3], Profile( name='foo--unit--tests-A', traffic_routing_method='Geographic', dns_config=DnsConfig( @@ -1843,21 +1818,15 @@ class TestAzureDnsProvider(TestCase): monitor_config=_get_monitor(record), endpoints=[ Endpoint( - name='one--default--', - type=external, - target='1.1.1.1', - geo_mapping=['GEO-AS'], - ), - Endpoint( - name='two--default--', - type=external, - target='2.2.2.2', + name='one', + type=nested, + target_resource_id=profiles[1].id, geo_mapping=['GEO-AF'], ), Endpoint( - name='three--default--', - type=external, - target='3.3.3.3', + name='two', + type=nested, + target_resource_id=profiles[2].id, geo_mapping=['WORLD'], ), ], @@ -1867,8 +1836,7 @@ class TestAzureDnsProvider(TestCase): tm_sync = provider._tm_client.profiles.create_or_update create = provider._dns_client.record_sets.create_or_update provider._apply_Create(Create(record)) - # A dynamic record can only have 1 profile - tm_sync.assert_called_once() + self.assertEqual(tm_sync.call_count, len(profiles) + 1) create.assert_called_once() # test broken alias @@ -1882,141 +1850,7 @@ class TestAzureDnsProvider(TestCase): # test that same record gets populated back from traffic managers tm_list = provider._tm_client.profiles.list_by_resource_group tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = 'Microsoft.Network/dnszones/{}'.format(record._type) - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_A_fallback(self): - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '1.1.1.1'}, - ], - 'fallback': 'two', - }, - 'two': { - 'values': [ - {'value': '2.2.2.2'}, - ], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-A', - traffic_routing_method='Priority', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one--default--', - type=external, - target='1.1.1.1', - priority=1, - ), - Endpoint( - name='two--default--', - type=external, - target='2.2.2.2', - priority=2, - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = 'Microsoft.Network/dnszones/{}'.format(record._type) - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_A_weighted_rr(self): - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1', '8.8.8.8'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '1.1.1.1', 'weight': 11}, - {'value': '8.8.8.8', 'weight': 8}, - ], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-A', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one--1.1.1.1--default--', - type=external, - target='1.1.1.1', - weight=11, - ), - Endpoint( - name='one--8.8.8.8--default--', - type=external, - target='8.8.8.8', - weight=8, - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles + provider._populate_traffic_managers() azrecord = RecordSet( ttl=60, target_resource=SubResource(id=profiles[-1].id), @@ -2119,7 +1953,8 @@ class TestAzureDnsProvider(TestCase): } # test no change - seen = provider._sync_traffic_managers(record) + profiles = provider._generate_traffic_managers(record) + seen = provider._sync_traffic_managers(profiles) self.assertEqual(seen, expected_seen) tm_sync.assert_not_called() @@ -2135,7 +1970,8 @@ class TestAzureDnsProvider(TestCase): } new_record = Record.new(zone, record.name, data) tm_sync.reset_mock() - seen2 = provider._sync_traffic_managers(new_record) + profiles = provider._generate_traffic_managers(new_record) + seen2 = provider._sync_traffic_managers(profiles) self.assertEqual(seen2, expected_seen) tm_sync.assert_called_once() @@ -2145,17 +1981,14 @@ class TestAzureDnsProvider(TestCase): ) self.assertEqual(new_profile.endpoints[0].weight, 14) - @patch( - 'octodns.provider.azuredns.AzureProvider._generate_traffic_managers') - def test_sync_traffic_managers_duplicate(self, mock_gen_tms): + def test_sync_traffic_managers_duplicate(self): provider, zone, record = self._get_dynamic_package() tm_sync = provider._tm_client.profiles.create_or_update # change and duplicate profiles profile = self._get_tm_profiles(provider)[0] profile.name = 'changing_this_to_trigger_sync' - mock_gen_tms.return_value = [profile, profile] - provider._sync_traffic_managers(record) + provider._sync_traffic_managers([profile, profile]) # it should only be called once for duplicate profiles tm_sync.assert_called_once() @@ -2232,7 +2065,6 @@ class TestAzureDnsProvider(TestCase): tm_sync = provider._tm_client.profiles.create_or_update - zone = Zone(name='unit.tests.', sub_zones=[]) record = self._get_dynamic_record(zone) profiles = self._get_tm_profiles(provider) @@ -2345,6 +2177,116 @@ class TestAzureDnsProvider(TestCase): dns_update.assert_called_once() tm_delete.assert_not_called() + def test_apply_update_dynamic_A(self): + # existing is simple, new is dynamic + provider = self._get_provider() + simple_record = Record.new(zone, 'foo', data={ + 'type': 'A', + 'ttl': 3600, + 'values': ['1.1.1.1', '2.2.2.2'], + }) + dynamic_record = Record.new(zone, simple_record.name, data={ + 'type': 'A', + 'ttl': 60, + 'values': ['1.1.1.1'], + 'dynamic': { + 'pools': { + 'one': { + 'values': [ + {'value': '8.8.8.8'}, + {'value': '4.4.4.4'}, + ], + 'fallback': 'two', + }, + 'two': { + 'values': [{'value': '9.9.9.9'}], + }, + }, + 'rules': [ + {'geos': ['AF'], 'pool': 'two'}, + {'pool': 'one'}, + ], + } + }) + num_tms = len(provider._generate_traffic_managers(dynamic_record)) + change = Update(simple_record, dynamic_record) + provider._apply_Update(change) + tm_sync, dns_update, tm_delete = ( + provider._tm_client.profiles.create_or_update, + provider._dns_client.record_sets.create_or_update, + provider._tm_client.profiles.delete + ) + # sync is called once for each profile, plus 1 at the end for nested + # endpoints to workaround A/AAAA nesting limitation in Azure + self.assertEqual(tm_sync.call_count, num_tms + 1) + dns_update.assert_called_once() + tm_delete.assert_not_called() + + # both are dynamic, healthcheck port is changed to trigger sync on + # all profiles + provider = self._get_provider() + dynamic_record2 = Record.new(zone, dynamic_record.name, data={ + 'type': dynamic_record._type, + 'ttl': 300, + 'values': dynamic_record.values, + 'dynamic': dynamic_record.dynamic._data(), + 'octodns': { + 'healthcheck': {'port': 4433}, + } + }) + change = Update(dynamic_record, dynamic_record2) + provider._apply_Update(change) + tm_sync, dns_update, tm_delete = ( + provider._tm_client.profiles.create_or_update, + provider._dns_client.record_sets.create_or_update, + provider._tm_client.profiles.delete + ) + # sync is called once for each profile, extra call at the end is not + # needed when existing dynamic record is already aliased to its root + # profile + self.assertEqual(tm_sync.call_count, num_tms) + dns_update.assert_not_called() + tm_delete.assert_not_called() + + def test_apply_update_dynamic_A_singluar(self): + # existing is simple, new is dynamic that needs only one profile + provider = self._get_provider() + simple_record = Record.new(zone, 'foo', data={ + 'type': 'A', + 'ttl': 3600, + 'values': ['1.1.1.1', '2.2.2.2'], + }) + dynamic_record = Record.new(zone, simple_record.name, data={ + 'type': 'A', + 'ttl': 60, + 'values': ['1.1.1.1'], + 'dynamic': { + 'pools': { + 'one': { + 'values': [ + {'value': '8.8.8.8'}, + {'value': '1.1.1.1'}, + ], + }, + }, + 'rules': [ + {'pool': 'one'}, + ], + } + }) + num_tms = len(provider._generate_traffic_managers(dynamic_record)) + self.assertEqual(num_tms, 1) + change = Update(simple_record, dynamic_record) + provider._apply_Update(change) + tm_sync, dns_update, tm_delete = ( + provider._tm_client.profiles.create_or_update, + provider._dns_client.record_sets.create_or_update, + provider._tm_client.profiles.delete + ) + self.assertEqual(tm_sync.call_count, num_tms) + dns_update.assert_called_once() + tm_delete.assert_not_called() + def test_apply_delete_dynamic(self): provider, existing, record = self._get_dynamic_package() provider._populate_traffic_managers()