diff --git a/octodns/provider/constellix.py b/octodns/provider/constellix.py index 7829386..cb2a53f 100644 --- a/octodns/provider/constellix.py +++ b/octodns/provider/constellix.py @@ -53,6 +53,7 @@ class ConstellixClient(object): self._sess = Session() self._sess.headers.update({'x-cnsdns-apiKey': self.api_key}) self._domains = None + self._pools = None def _current_time(self): return str(int(time.time() * 1000)) @@ -99,7 +100,7 @@ class ConstellixClient(object): zone_id = self.domains.get(name, False) if not zone_id: raise ConstellixClientNotFound() - path = '/{}'.format(zone_id) + path = '/domains/{}'.format(zone_id) return self._request('GET', path).json() def domain_create(self, name): @@ -165,6 +166,48 @@ class ConstellixClient(object): record_id) self._request('DELETE', path) + def pools(self, pool_type): + if self._pools is None: + self._pools = {} + path = '/pools/{}'.format(pool_type) + response = self._request('GET', path).json() + for pool in response: + self._pools[pool['id']] = pool + return self._pools.values() + + def pool(self, pool_type, pool_name): + pools = self.pools(pool_type) + for pool in pools: + if pool['name'] == pool_name: + return pool + return None + + def pool_by_id(self, pool_type, pool_id): + pools = self.pools(pool_type) + for pool in pools: + if pool['id'] == pool_id: + return pool + + def pool_create(self, data): + path = '/pools/{}'.format(data.get('type')) + # This returns a list of items, we want the first one + response = self._request('POST', path, data=data).json()[0] + + # Invalidate our cache + self._pools = None + return response + + def pool_update(self, pool_id, data): + path = '/pools/{}/{}'.format(data.get('type'), pool_id) + try: + self._request('PUT', path, data=data).json() + + except ConstellixClientBadRequest as e: + message = str(e) + if not message or "no changes to save" not in message: + raise e + return data + class ConstellixProvider(BaseProvider): ''' @@ -181,7 +224,7 @@ class ConstellixProvider(BaseProvider): ratelimit_delay: 0.0 ''' SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = False + SUPPORTS_DYNAMIC = True SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) @@ -195,12 +238,41 @@ class ConstellixProvider(BaseProvider): def _data_for_multiple(self, _type, records): record = records[0] + if record['recordOption'] == 'pools': + return self._data_for_pool(_type, record) return { 'ttl': record['ttl'], 'type': _type, 'values': record['value'] } + def _data_for_pool(self, _type, record): + pool_id = record['pools'][0] + pool = self._client.pool_by_id(_type, pool_id) + pool_name = pool['name'].split(':')[-1] + pools = {} + values = [] + pools[pool_name] = { + 'values': [] + } + for value in pool['values']: + pools[pool_name]['values'].append({ + 'value': value['value'], + 'weight': value['weight'] + }) + values.append(value['value']) + return { + 'ttl': record['ttl'], + 'type': _type, + 'dynamic': { + 'pools': pools, + 'rules': [{ + 'pool': pool_name + }] + }, + 'value': values + } + _data_for_A = _data_for_multiple _data_for_AAAA = _data_for_multiple @@ -421,10 +493,65 @@ class ConstellixProvider(BaseProvider): 'roundRobin': values } + def _handle_pools(self, record): + # If we don't have dynamic, then there's no pools + if not getattr(record, 'dynamic', False): + return None + + # Get our first entry in the rules that references a pool + rules = list(filter( + lambda rule: 'pool' in rule.data, + record.dynamic.rules + )) + + pool_name = rules[0].data.get('pool') + + pool = record.dynamic.pools.get(pool_name) + values = pool.data.get('values') + + # Make a pool name based on zone, record, type and name + pool_name = '{}:{}:{}:{}'.format( + record.zone.name, + record.name, + record._type, + pool_name + ) + + # OK, pool is valid, let's create it or update it + return self._create_update_pool( + pool_name = pool_name, + pool_type = record._type, + ttl = record.ttl, + values = values + ) + + def _create_update_pool(self, pool_name, pool_type, ttl, values): + pool = { + 'name': pool_name, + 'type': pool_type, + 'numReturn': 1, + 'minAvailableFailover': 1, + 'ttl': ttl, + 'values': values + } + existing_pool = self._client.pool(pool_type, pool_name) + if not existing_pool: + return self._client.pool_create(pool) + + pool_id = existing_pool['id'] + updated_pool = self._client.pool_update(pool_id, pool) + updated_pool['id'] = pool_id + return updated_pool + def _apply_Create(self, change): new = change.new params_for = getattr(self, '_params_for_{}'.format(new._type)) + pool = self._handle_pools(new) + for params in params_for(new): + if pool: + params['pools'] = [pool['id']] + params['recordOption'] = 'pools' self._client.record_create(new.zone.name, new._type, params) def _apply_Update(self, change): diff --git a/tests/fixtures/constellix-pools.json b/tests/fixtures/constellix-pools.json new file mode 100644 index 0000000..8fbd491 --- /dev/null +++ b/tests/fixtures/constellix-pools.json @@ -0,0 +1,32 @@ +[ + { + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two", + "type": "A", + "numReturn": 1, + "minAvailableFailover": 1, + "createdTs": "2020-09-12T00:44:35Z", + "modifiedTs": "2020-09-12T00:44:35Z", + "appliedDomains": [ + { + "id": 123123, + "name": "unit.tests", + "recordOption": "pools" + } + ], + "appliedTemplates": null, + "unlinkedDomains": [], + "unlinkedTemplates": null, + "itoEnabled": false, + "values": [ + { + "value": "1.2.3.4", + "weight": 1 + }, + { + "value": "1.2.3.5", + "weight": 1 + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/constellix-records.json b/tests/fixtures/constellix-records.json index c1f1fb4..545eada 100644 --- a/tests/fixtures/constellix-records.json +++ b/tests/fixtures/constellix-records.json @@ -595,4 +595,41 @@ "roundRobinFailover": [], "pools": [], "poolsDetail": [] +}, { + "id": 1808520, + "type": "A", + "recordType": "a", + "name": "www.dynamic", + "recordOption": "pools", + "noAnswer": false, + "note": "", + "ttl": 300, + "gtdRegion": 1, + "parentId": 123123, + "parent": "domain", + "source": "Domain", + "modifiedTs": 1565150090588, + "value": [], + "roundRobin": [], + "geolocation": null, + "recordFailover": { + "disabled": false, + "failoverType": 1, + "failoverTypeStr": "Normal (always lowest level)", + "values": [] + }, + "failover": { + "disabled": false, + "failoverType": 1, + "failoverTypeStr": "Normal (always lowest level)", + "values": [] + }, + "roundRobinFailover": [], + "pools": [ + 1808521 + ], + "poolsDetail": [{ + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two" + }] }] diff --git a/tests/test_octodns_provider_constellix.py b/tests/test_octodns_provider_constellix.py index 52a6b4c..d54d1fe 100644 --- a/tests/test_octodns_provider_constellix.py +++ b/tests/test_octodns_provider_constellix.py @@ -15,7 +15,7 @@ from unittest import TestCase from octodns.record import Record from octodns.provider.constellix import \ - ConstellixProvider + ConstellixProvider, ConstellixClientBadRequest from octodns.provider.yaml import YamlProvider from octodns.zone import Zone @@ -48,6 +48,32 @@ class TestConstellixProvider(TestCase): 'value': 'aname.unit.tests.' })) + # Add a dynamic record + expected.add_record(Record.new(expected, 'www.dynamic', { + 'ttl': 300, + 'type': 'A', + 'value': [ + '1.2.3.4', + '1.2.3.5' + ], + 'dynamic': { + 'pools': { + 'two': { + 'values': [{ + 'value': '1.2.3.4', + 'weight': 1 + }, { + 'value': '1.2.3.5', + 'weight': 1 + }], + }, + }, + 'rules': [{ + 'pool': 'two', + }], + }, + })) + for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) @@ -98,23 +124,26 @@ class TestConstellixProvider(TestCase): # No diffs == no changes with requests_mock() as mock: - base = 'https://api.dns.constellix.com/v1/domains' + base = 'https://api.dns.constellix.com/v1' with open('tests/fixtures/constellix-domains.json') as fh: - mock.get('{}{}'.format(base, ''), text=fh.read()) + mock.get('{}{}'.format(base, '/domains'), text=fh.read()) with open('tests/fixtures/constellix-records.json') as fh: - mock.get('{}{}'.format(base, '/123123/records'), + mock.get('{}{}'.format(base, '/domains/123123/records'), + text=fh.read()) + with open('tests/fixtures/constellix-pools.json') as fh: + mock.get('{}{}'.format(base, '/pools/A'), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(15, len(zone.records)) + self.assertEquals(16, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) - self.assertEquals(15, len(again.records)) + self.assertEquals(16, len(again.records)) # bust the cache del provider._zone_records[zone.name] @@ -133,6 +162,11 @@ class TestConstellixProvider(TestCase): 'id': 123123, 'name': 'unit.tests' }], # domain created in apply + [], # No pools returned during populate + [{ + "id": 1808520, + "name": "unit.tests.:www.dynamic:A:two", + }] # pool created in apply ] plan = provider.plan(self.expected) @@ -148,6 +182,28 @@ class TestConstellixProvider(TestCase): # created the domain call('POST', '/domains', data={'names': ['unit.tests']}) ]) + + # Check we tried to get our pool + provider._client._request.assert_has_calls([ + # get all pools to build the cache + call('GET', '/pools/A'), + # created the pool + call('POST', '/pools/A', data={ + 'name': 'unit.tests.:www.dynamic:A:two', + 'type': 'A', + 'numReturn': 1, + 'minAvailableFailover': 1, + 'ttl': 300, + 'values': [{ + "value": "1.2.3.4", + "weight": 1 + }, { + "value": "1.2.3.5", + "weight": 1 + }] + }) + ]) + # These two checks are broken up so that ordering doesn't break things. # Python3 doesn't make the calls in a consistent order so different # things follow the GET / on different runs @@ -169,7 +225,7 @@ class TestConstellixProvider(TestCase): }), ]) - self.assertEquals(18, provider._client._request.call_count) + self.assertEquals(21, provider._client._request.call_count) provider._client._request.reset_mock() @@ -179,6 +235,7 @@ class TestConstellixProvider(TestCase): 'type': 'A', 'name': 'www', 'ttl': 300, + 'recordOption': 'roundRobin', 'value': [ '1.2.3.4', '2.2.3.4', @@ -188,6 +245,7 @@ class TestConstellixProvider(TestCase): 'type': 'A', 'name': 'ttl', 'ttl': 600, + 'recordOption': 'roundRobin', 'value': [ '3.2.3.4' ] @@ -196,14 +254,44 @@ class TestConstellixProvider(TestCase): 'type': 'ALIAS', 'name': 'alias', 'ttl': 600, + 'recordOption': 'roundRobin', 'value': [{ 'value': 'aname.unit.tests.' }] + }, { + "id": 1808520, + "type": "A", + "name": "www.dynamic", + "recordOption": "pools", + "ttl": 300, + "value": [], + "pools": [ + 1808521 + ] } ]) + provider._client.pools = Mock(return_value=[{ + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two", + "type": "A", + "values": [ + { + "value": "1.2.3.4", + "weight": 1 + }, + { + "value": "1.2.3.5", + "weight": 1 + } + ] + }]) + # Domain exists, we don't care about return - resp.json.side_effect = ['{}'] + resp.json.side_effect = [ + ['{}'], + ['{}'], + ] wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'ttl', { @@ -212,9 +300,30 @@ class TestConstellixProvider(TestCase): 'value': '3.2.3.4' })) + wanted.add_record(Record.new(wanted, 'www.dynamic', { + 'ttl': 300, + 'type': 'A', + 'value': [ + '1.2.3.4' + ], + 'dynamic': { + 'pools': { + 'two': { + 'values': [{ + 'value': '1.2.3.4', + 'weight': 1 + }], + }, + }, + 'rules': [{ + 'pool': 'two', + }], + }, + })) + plan = provider.plan(wanted) - self.assertEquals(3, len(plan.changes)) - self.assertEquals(3, provider.apply(plan)) + self.assertEquals(4, len(plan.changes)) + self.assertEquals(4, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ @@ -225,7 +334,190 @@ class TestConstellixProvider(TestCase): 'name': 'ttl', 'ttl': 300 }), + call('PUT', '/pools/A/1808521', data={ + 'name': 'unit.tests.:www.dynamic:A:two', + 'type': 'A', + 'numReturn': 1, + 'minAvailableFailover': 1, + 'ttl': 300, + 'id': 1808521, + 'values': [{ + "value": "1.2.3.4", + "weight": 1 + }] + }), call('DELETE', '/domains/123123/records/A/11189897'), call('DELETE', '/domains/123123/records/A/11189898'), - call('DELETE', '/domains/123123/records/ANAME/11189899') + call('DELETE', '/domains/123123/records/ANAME/11189899'), ], any_order=True) + + def test_dynamic_record_failures(self): + provider = ConstellixProvider('test', 'api', 'secret') + + resp = Mock() + resp.json = Mock() + provider._client._request = Mock(return_value=resp) + + # Let's handle some failures for pools - first if it's not a simple + # weighted pool - we'll be OK as we assume a weight of 1 for all + # entries + provider._client._request.reset_mock() + provider._client.records = Mock(return_value=[ + { + "id": 1808520, + "type": "A", + "name": "www.dynamic", + "recordOption": "pools", + "ttl": 300, + "value": [], + "pools": [ + 1808521 + ] + } + ]) + + provider._client.pools = Mock(return_value=[{ + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two", + "type": "A", + "values": [ + { + "value": "1.2.3.4", + "weight": 1 + } + ] + }]) + + wanted = Zone('unit.tests.', []) + + resp.json.side_effect = [ + ['{}'], + ['{}'], + ] + wanted.add_record(Record.new(wanted, 'www.dynamic', { + 'ttl': 300, + 'type': 'A', + 'value': [ + '1.2.3.4' + ], + 'dynamic': { + 'pools': { + 'two': { + 'values': [{ + 'value': '1.2.3.4' + }], + }, + }, + 'rules': [{ + 'pool': 'two', + }], + }, + })) + + plan = provider.plan(wanted) + self.assertIsNone(plan) + + def test_dynamic_record_updates(self): + provider = ConstellixProvider('test', 'api', 'secret') + + # Constellix API can return an error if you try and update a pool and + # don't change anything, so let's test we handle it silently + + provider._client.records = Mock(return_value=[ + { + "id": 1808520, + "type": "A", + "name": "www.dynamic", + "recordOption": "pools", + "ttl": 300, + "value": [], + "pools": [ + 1808521 + ] + } + ]) + + provider._client.pools = Mock(return_value=[{ + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two", + "type": "A", + "values": [ + { + "value": "1.2.3.4", + "weight": 1 + } + ] + }]) + + wanted = Zone('unit.tests.', []) + + wanted.add_record(Record.new(wanted, 'www.dynamic', { + 'ttl': 300, + 'type': 'A', + 'value': [ + '1.2.3.4' + ], + 'dynamic': { + 'pools': { + 'two': { + 'values': [{ + 'value': '1.2.3.5' + }], + }, + }, + 'rules': [{ + 'pool': 'two', + }], + }, + })) + + # Try an error we can handle + with requests_mock() as mock: + mock.get(ANY, status_code=200, + text='{}') + mock.delete(ANY, status_code=200, + text='{}') + mock.put("https://api.dns.constellix.com/v1/pools/A/1808521", + status_code=400, + text='{"errors": [\"no changes to save\"]}') + mock.post(ANY, status_code=200, + text='[{"id": 1234}]') + + plan = provider.plan(wanted) + self.assertEquals(1, len(plan.changes)) + self.assertEquals(1, provider.apply(plan)) + + # Now what happens if an error happens that we can't handle + with requests_mock() as mock: + mock.get(ANY, status_code=200, + text='{}') + mock.delete(ANY, status_code=200, + text='{}') + mock.put("https://api.dns.constellix.com/v1/pools/A/1808521", + status_code=400, + text='{"errors": [\"generic error\"]}') + mock.post(ANY, status_code=200, + text='[{"id": 1234}]') + + plan = provider.plan(wanted) + self.assertEquals(1, len(plan.changes)) + with self.assertRaises(ConstellixClientBadRequest): + provider.apply(plan) + + def test_pools_that_are_notfound(self): + provider = ConstellixProvider('test', 'api', 'secret') + + provider._client.pools = Mock(return_value=[{ + "id": 1808521, + "name": "unit.tests.:www.dynamic:A:two", + "type": "A", + "values": [ + { + "value": "1.2.3.4", + "weight": 1 + } + ] + }]) + + self.assertIsNone(provider._client.pool_by_id('A', 1)) + self.assertIsNone(provider._client.pool('A', 'foobar'))