From ee7b36b496a46d5000ac706849b8fbc11a32b925 Mon Sep 17 00:00:00 2001 From: Maikel Poot Date: Wed, 10 Jun 2020 13:34:44 +0200 Subject: [PATCH] Added server version checking --- octodns/provider/powerdns.py | 96 +++++++++++++++--- tests/test_octodns_provider_powerdns.py | 125 +++++++++++++++++++----- 2 files changed, 179 insertions(+), 42 deletions(-) diff --git a/octodns/provider/powerdns.py b/octodns/provider/powerdns.py index 847e292..c9b5e01 100644 --- a/octodns/provider/powerdns.py +++ b/octodns/provider/powerdns.py @@ -19,13 +19,15 @@ class PowerDnsBaseProvider(BaseProvider): 'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT')) TIMEOUT = 5 - def __init__(self, id, host, api_key, soa_edit_api, port=8081, + def __init__(self, id, host, api_key, port=8081, scheme="http", timeout=TIMEOUT, *args, **kwargs): super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs) self.host = host self.port = port - self.soa_edit_api = soa_edit_api + self.version_detected = '' + self.soa_edit_api = "INCEPTION-INCREMENT" + self.check_status_not_found = False self.scheme = scheme self.timeout = timeout @@ -37,7 +39,8 @@ class PowerDnsBaseProvider(BaseProvider): self.log.debug('_request: method=%s, path=%s', method, path) url = '{}://{}:{}/api/v1/servers/localhost/{}' \ - .format(self.scheme, self.host, self.port, path) + .format(self.scheme, self.host, self.port, path).rstrip("/") + # Strip trailing / from url. resp = self._sess.request(method, url, json=data, timeout=self.timeout) self.log.debug('_request: status=%d', resp.status_code) resp.raise_for_status() @@ -166,20 +169,75 @@ class PowerDnsBaseProvider(BaseProvider): 'ttl': rrset['ttl'] } + def detect_version(self): + # Only detect version once + if self.version_detected != '': + self.log.debug('detect_version: version %s allready detected', + self.version_detected) + return self.version_detected + + try: + self.log.debug('detect_version: getting version from server') + resp = self._get('') + except HTTPError as e: + if e.response.status_code == 401: + # Nicer error message for auth problems + raise Exception('PowerDNS unauthorized host={}' + .format(self.host)) + else: + raise + + self.version_detected = resp.json()["version"] + self.log.debug('detect_version: got version %s from server', + self.version_detected) + self.configure_for_version(self.version_detected) + + def configure_for_version(self, version): + major, minor, patch = version.split('.', 2) + major, minor, patch = int(major), int(minor), int(patch) + self.log.debug('configure_for_version: configure for ' + 'major: %s, minor: %s, patch: %s', + major, minor, patch) + + # Defaults for v4.0.0 + self.soa_edit_api = "INCEPTION-INCREMENT" + self.check_status_not_found = False + + if major == 4 and minor >= 2: + self.log.debug("configure_for_version: Version >= 4.2") + self.soa_edit_api = "INCEPTION-INCREMENT" + self.check_status_not_found = True + + if major == 4 and minor >= 3: + self.log.debug("configure_for_version: Version >= 4.3") + self.soa_edit_api = "DEFAULT" + self.check_status_not_found = True + def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, target, lenient) + self.detect_version() + resp = None try: resp = self._get('zones/{}'.format(zone.name)) self.log.debug('populate: loaded') except HTTPError as e: + error = self._get_error(e) if e.response.status_code == 401: # Nicer error message for auth problems raise Exception('PowerDNS unauthorized host={}' .format(self.host)) - elif e.response.status_code in (404, 422): + elif e.response.status_code == 404 \ + and self.check_status_not_found: + # 404 or 422 means powerdns doesn't know anything about the + # requested domain. We'll just ignore it here and leave the + # zone untouched. + pass + elif e.response.status_code == 422 \ + and error.startswith('Could not find domain ') \ + and not self.check_status_not_found: # 404 or 422 means powerdns doesn't know anything about the # requested domain. We'll just ignore it here and leave the # zone untouched. @@ -339,13 +397,22 @@ class PowerDnsBaseProvider(BaseProvider): self.log.debug('_apply: patched') except HTTPError as e: error = self._get_error(e) - if e.response.status_code != 404 and \ - not (e.response.status_code == 422 and - error.startswith('Could not find domain ')): - self.log.error('_apply: status=%d, text=%s', - e.response.status_code, - e.response.text) + if not ( + ( + e.response.status_code == 404 and + self.check_status_not_found + ) or ( + e.response.status_code == 422 and + error.startswith('Could not find domain ') and + not self.check_status_not_found + ) + ): + self.log.error( + '_apply: status=%d, text=%s', + e.response.status_code, + e.response.text) raise + self.log.info('_apply: creating zone=%s', desired.name) # 404 or 422 means powerdns doesn't know anything about the # requested domain. We'll try to create it with the correct @@ -398,17 +465,14 @@ class PowerDnsProvider(PowerDnsBaseProvider): ''' def __init__(self, id, host, api_key, port=8081, nameserver_values=None, - nameserver_ttl=600, soa_edit_api='INCEPTION-INCREMENT', + nameserver_ttl=600, *args, **kwargs): self.log = logging.getLogger('PowerDnsProvider[{}]'.format(id)) self.log.debug('__init__: id=%s, host=%s, port=%d, ' - 'nameserver_values=%s, nameserver_ttl=%d' - 'soa_edit_api=%s', - id, host, port, nameserver_values, nameserver_ttl, - soa_edit_api) + 'nameserver_values=%s, nameserver_ttl=%d', + id, host, port, nameserver_values, nameserver_ttl) super(PowerDnsProvider, self).__init__(id, host=host, api_key=api_key, port=port, - soa_edit_api=soa_edit_api, *args, **kwargs) self.nameserver_values = nameserver_values diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py index 7223617..9ba28af 100644 --- a/tests/test_octodns_provider_powerdns.py +++ b/tests/test_octodns_provider_powerdns.py @@ -41,11 +41,94 @@ with open('./tests/fixtures/powerdns-full-data.json') as fh: class TestPowerDnsProvider(TestCase): + def test_provider_version_detection(self): + provider = PowerDnsProvider('test', 'non.existent', 'api-key', + nameserver_values=['8.8.8.8.', + '9.9.9.9.']) + # Bad auth + with requests_mock() as mock: + mock.get(ANY, status_code=401, text='Unauthorized') + + with self.assertRaises(Exception) as ctx: + provider.detect_version() + self.assertTrue('unauthorized' in text_type(ctx.exception)) + + # Api not found + with requests_mock() as mock: + mock.get(ANY, status_code=404, text='Not Found') + + with self.assertRaises(Exception) as ctx: + provider.detect_version() + self.assertTrue('404' in text_type(ctx.exception)) + + # Test version detection + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': "4.1.10"}) + + provider.detect_version() + self.assertEquals(provider.version_detected, '4.1.10') + + # Test version detection for second time (should stay at 4.1.10) + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': "4.2.0"}) + provider.detect_version() + self.assertEquals(provider.version_detected, '4.1.10') + + # Test version detection + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': "4.2.0"}) + + # Reset version, so detection will try again + provider.version_detected = '' + provider.detect_version() + self.assertNotEquals(provider.version_detected, '4.1.10') + + def test_provider_version_config(self): + provider = PowerDnsProvider('test', 'non.existent', 'api-key', + nameserver_values=['8.8.8.8.', + '9.9.9.9.']) + + provider.check_status_not_found = None + provider.soa_edit_api = 'something else' + + # Test version 4.1.0 + provider.configure_for_version("4.1.0") + self.assertEquals(provider.soa_edit_api, 'INCEPTION-INCREMENT') + self.assertFalse( + provider.check_status_not_found, + 'check_status_not_found should be false ' + 'for version 4.1.x and below') + + # Test version 4.2.0 + provider.configure_for_version("4.2.0") + self.assertEquals(provider.soa_edit_api, 'INCEPTION-INCREMENT') + self.assertTrue( + provider.check_status_not_found, + 'check_status_not_found should be true for version 4.2.x') + + # Test version 4.3.0 + provider.configure_for_version("4.3.0") + self.assertEquals(provider.soa_edit_api, 'DEFAULT') + self.assertTrue( + provider.check_status_not_found, + 'check_status_not_found should be true for version 4.3.x') + def test_provider(self): provider = PowerDnsProvider('test', 'non.existent', 'api-key', nameserver_values=['8.8.8.8.', '9.9.9.9.']) + # Test version detection + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': "4.1.10"}) + + provider.detect_version() + self.assertEquals(provider.version_detected, '4.1.10') + # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='Unauthorized') @@ -68,18 +151,19 @@ class TestPowerDnsProvider(TestCase): with requests_mock() as mock: mock.get(ANY, status_code=422, json={'error': "Could not find domain 'unit.tests.'"}) - zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) - # Non-existent zone in PowerDNS >=4.3.0 doesn't populate anything + # Non-existent zone in PowerDNS >=4.2.0 doesn't populate anything + with requests_mock() as mock: mock.get(ANY, status_code=404, text='Not Found') - + provider.configure_for_version("4.2.0") zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) + provider.configure_for_version("4.1.0") # The rest of this is messy/complicated b/c it's dealing with mocking @@ -124,7 +208,7 @@ class TestPowerDnsProvider(TestCase): not_found = {'error': "Could not find domain 'unit.tests.'"} with requests_mock() as mock: # get 422's, unknown zone - mock.get(ANY, status_code=422, text='') + mock.get(ANY, status_code=422, text=dumps(not_found)) # patch 422's, unknown zone mock.patch(ANY, status_code=422, text=dumps(not_found)) # post 201, is response to the create with data @@ -136,6 +220,7 @@ class TestPowerDnsProvider(TestCase): self.assertFalse(plan.exists) with requests_mock() as mock: + provider.configure_for_version('4.2.0') # get 404's, unknown zone mock.get(ANY, status_code=404, text='') # patch 404's, unknown zone @@ -147,10 +232,11 @@ class TestPowerDnsProvider(TestCase): self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, provider.apply(plan)) self.assertFalse(plan.exists) + provider.configure_for_version('4.1.0') with requests_mock() as mock: # get 422's, unknown zone - mock.get(ANY, status_code=422, text='') + mock.get(ANY, status_code=422, text=dumps(not_found)) # patch 422's, data = {'error': "Key 'name' not present or not a String"} mock.patch(ANY, status_code=422, text=dumps(data)) @@ -164,7 +250,7 @@ class TestPowerDnsProvider(TestCase): with requests_mock() as mock: # get 422's, unknown zone - mock.get(ANY, status_code=422, text='') + mock.get(ANY, status_code=422, text=dumps(not_found)) # patch 500's, things just blew up mock.patch(ANY, status_code=500, text='') @@ -174,7 +260,7 @@ class TestPowerDnsProvider(TestCase): with requests_mock() as mock: # get 422's, unknown zone - mock.get(ANY, status_code=422, text='') + mock.get(ANY, status_code=422, text=dumps(not_found)) # patch 500's, things just blew up mock.patch(ANY, status_code=422, text=dumps(not_found)) # post 422's, something wrong with create @@ -195,6 +281,8 @@ class TestPowerDnsProvider(TestCase): # A small change to a single record with requests_mock() as mock: mock.get(ANY, status_code=200, text=FULL_TEXT) + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': '4.1.0'}) missing = Zone(expected.name, []) # Find and delete the SPF record @@ -266,6 +354,8 @@ class TestPowerDnsProvider(TestCase): }] } mock.get(ANY, status_code=200, json=data) + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': '4.1.0'}) unrelated_record = Record.new(expected, '', { 'type': 'A', @@ -299,6 +389,8 @@ class TestPowerDnsProvider(TestCase): }] } mock.get(ANY, status_code=200, json=data) + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': '4.1.0'}) plan = provider.plan(expected) self.assertEquals(1, len(plan.changes)) @@ -312,22 +404,3 @@ class TestPowerDnsProvider(TestCase): plan = provider.plan(expected) self.assertEquals(1, len(plan.changes)) - - def test_soa_edit_api(self): - provider = PowerDnsProvider('test', 'non.existent', 'api-key', - soa_edit_api='DEFAULT') - - def assert_soa_edit_api_callback(request, context): - data = loads(request.body) - self.assertEquals('DEFAULT', data['soa_edit_api']) - return '' - - with requests_mock() as mock: - mock.get(ANY, status_code=404) - mock.patch(ANY, status_code=404) - mock.post(ANY, status_code=204, text=assert_soa_edit_api_callback) - zone = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(zone) - plan = provider.plan(zone) - provider.apply(plan)