diff --git a/octodns/provider/powerdns.py b/octodns/provider/powerdns.py index 8d75163..bcb6980 100644 --- a/octodns/provider/powerdns.py +++ b/octodns/provider/powerdns.py @@ -19,8 +19,8 @@ class PowerDnsBaseProvider(BaseProvider): 'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT')) TIMEOUT = 5 - def __init__(self, id, host, api_key, port=8081, scheme="http", - timeout=TIMEOUT, *args, **kwargs): + 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 @@ -28,6 +28,8 @@ class PowerDnsBaseProvider(BaseProvider): self.scheme = scheme self.timeout = timeout + self._powerdns_version = None + sess = Session() sess.headers.update({'X-API-Key': api_key}) self._sess = sess @@ -36,7 +38,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() @@ -165,6 +168,42 @@ class PowerDnsBaseProvider(BaseProvider): 'ttl': rrset['ttl'] } + @property + def powerdns_version(self): + if self._powerdns_version is None: + try: + 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)) + raise + + version = resp.json()['version'] + self.log.debug('powerdns_version: got version %s from server', + version) + self._powerdns_version = [int(p) for p in version.split('.')] + + return self._powerdns_version + + @property + def soa_edit_api(self): + # >>> [4, 4, 3] >= [4, 3] + # True + # >>> [4, 3, 3] >= [4, 3] + # True + # >>> [4, 1, 3] >= [4, 3] + # False + if self.powerdns_version >= [4, 3]: + return 'DEFAULT' + return 'INCEPTION-INCREMENT' + + @property + def check_status_not_found(self): + # >=4.2.x returns 404 when not found + return self.powerdns_version >= [4, 2] + def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, target, lenient) @@ -174,11 +213,20 @@ class PowerDnsBaseProvider(BaseProvider): 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 == 422: + elif e.response.status_code == 404 \ + and self.check_status_not_found: + # 404 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: # 422 means powerdns doesn't know anything about the requested # domain. We'll just ignore it here and leave the zone # untouched. @@ -338,23 +386,34 @@ class PowerDnsBaseProvider(BaseProvider): self.log.debug('_apply: patched') except HTTPError as e: error = self._get_error(e) - if e.response.status_code != 422 or \ - not 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) - # 422 means powerdns doesn't know anything about the requested - # domain. We'll try to create it with the correct records instead - # of update. Hopefully all the mods are creates :-) + # 404 or 422 means powerdns doesn't know anything about the + # requested domain. We'll try to create it with the correct + # records instead of update. Hopefully all the mods are + # creates :-) data = { 'name': desired.name, 'kind': 'Master', 'masters': [], 'nameservers': [], 'rrsets': mods, - 'soa_edit_api': 'INCEPTION-INCREMENT', + 'soa_edit_api': self.soa_edit_api, 'serial': 0, } try: @@ -391,13 +450,15 @@ class PowerDnsProvider(PowerDnsBaseProvider): ''' def __init__(self, id, host, api_key, port=8081, nameserver_values=None, - nameserver_ttl=600, *args, **kwargs): + 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', id, host, port, nameserver_values, nameserver_ttl) super(PowerDnsProvider, self).__init__(id, host=host, api_key=api_key, - port=port, *args, **kwargs) + port=port, + *args, **kwargs) self.nameserver_values = nameserver_values self.nameserver_ttl = nameserver_ttl diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py index 6baee6c..fd877ef 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.powerdns_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.powerdns_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"}) + self.assertEquals(provider.powerdns_version, [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"}) + self.assertEquals(provider.powerdns_version, [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._powerdns_version = None + self.assertNotEquals(provider.powerdns_version, [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.']) + + # Test version 4.1.0 + provider._powerdns_version = None + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'version': "4.1.10"}) + 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._powerdns_version = None + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'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._powerdns_version = None + with requests_mock() as mock: + mock.get('http://non.existent:8081/api/v1/servers/localhost', + status_code=200, json={'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"}) + self.assertEquals(provider.powerdns_version, [4, 1, 10]) + # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='Unauthorized') @@ -64,15 +147,25 @@ class TestPowerDnsProvider(TestCase): provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) - # Non-existent zone doesn't populate anything + # Non-existent zone in PowerDNS <4.3.0 doesn't populate anything 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.2.0 doesn't populate anything + + provider._powerdns_version = [4, 2, 0] + with requests_mock() as mock: + mock.get(ANY, status_code=404, text='Not Found') + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(set(), zone.records) + + provider._powerdns_version = [4, 1, 0] + # The rest of this is messy/complicated b/c it's dealing with mocking expected = Zone('unit.tests.', []) @@ -116,7 +209,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 @@ -127,9 +220,24 @@ class TestPowerDnsProvider(TestCase): self.assertEquals(expected_n, provider.apply(plan)) self.assertFalse(plan.exists) + provider._powerdns_version = [4, 2, 0] + with requests_mock() as mock: + # get 404's, unknown zone + mock.get(ANY, status_code=404, text='') + # patch 404's, unknown zone + mock.patch(ANY, status_code=404, text=dumps(not_found)) + # post 201, is response to the create with data + mock.post(ANY, status_code=201, text=assert_rrsets_callback) + + plan = provider.plan(expected) + self.assertEquals(expected_n, len(plan.changes)) + self.assertEquals(expected_n, provider.apply(plan)) + self.assertFalse(plan.exists) + + provider._powerdns_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)) @@ -143,7 +251,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='') @@ -153,7 +261,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 @@ -174,6 +282,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 @@ -245,6 +355,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', @@ -278,6 +390,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))