diff options
author | Michael Hood <mhood@ns1.com> | 2021-03-05 08:04:29 -0800 |
---|---|---|
committer | Michael Hood <mhood@ns1.com> | 2021-03-08 10:55:06 -0800 |
commit | 5aac48f08b441cbde8c2078a9ace0551d3d19058 (patch) | |
tree | 7414f0fda74cd155d9d1cbeb26f300888b133f97 /designate | |
parent | 75668d084c2a27fcc25c67741ca309bfe43b1322 (diff) | |
download | designate-5aac48f08b441cbde8c2078a9ace0551d3d19058.tar.gz |
Add NS1 backend
Introduce an NS1 backend.
Signed-off-by: Michael Hood <mhood@ns1.com>
Change-Id: I80fe08238005a94161e2dbcc89e77c90cde0a715
Diffstat (limited to 'designate')
-rw-r--r-- | designate/backend/impl_ns1.py | 136 | ||||
-rw-r--r-- | designate/tests/unit/backend/test_ns1.py | 217 |
2 files changed, 353 insertions, 0 deletions
diff --git a/designate/backend/impl_ns1.py b/designate/backend/impl_ns1.py new file mode 100644 index 00000000..a23ad1ba --- /dev/null +++ b/designate/backend/impl_ns1.py @@ -0,0 +1,136 @@ +# Copyright 2021 NS1 Inc. https://www.ns1.com +# +# Author: Dragan Blagojevic <dblagojevic@daitan.com> +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import requests +from oslo_config import cfg +from oslo_log import log as logging + +from designate import exceptions +from designate.backend import base + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class NS1Backend(base.Backend): + __plugin_name__ = 'ns1' + + __backend_status__ = 'untested' + + def __init__(self, target): + super(NS1Backend, self).__init__(target) + + self.api_endpoint = "https://" + self.options.get('api_endpoint') + self.api_token = self.options.get('api_token') + self.tsigkey_name = self.options.get('tsigkey_name', None) + self.tsigkey_hash = self.options.get('tsigkey_hash', None) + self.tsigkey_value = self.options.get('tsigkey_value', None) + + self.headers = { + "X-NSONE-Key": self.api_token + } + + def _build_url(self, zone): + return "%s/v1/zones/%s" % (self.api_endpoint, zone.name.rstrip('.')) + + def _get_master(self): + try: + return self.masters[0] + except IndexError as e: + LOG.error('No masters host set in pools.yaml') + raise exceptions.Backend(e) + + def _check_zone_exists(self, zone): + + try: + requests.get( + self._build_url(zone), + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + if e.response.status_code == 404: + return False + else: + LOG.error('HTTP error in check zone exists. Zone %s', zone) + raise exceptions.Backend(e) + except requests.ConnectionError as e: + LOG.error('Connection error in check zone exists. Zone %s', zone) + raise exceptions.Backend(e) + + return True + + def create_zone(self, context, zone): + + master = self._get_master() + # designate requires "." at end of zone name, NS1 requires omitting + data = { + "zone": zone.name.rstrip('.'), + "secondary": { + "enabled": True, + "primary_ip": master.host, + "primary_port": master.port + } + } + if self.tsigkey_name: + tsig = { + "enabled": True, + "hash": self.tsigkey_hash, + "name": self.tsigkey_name, + "key": self.tsigkey_value + } + data['secondary']['tsig'] = tsig + + if not self._check_zone_exists(zone): + try: + requests.put( + self._build_url(zone), + json=data, + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + # check if the zone was actually created + if self._check_zone_exists(zone): + LOG.info("%s was created with an error. Deleting zone", + zone.name) + try: + self.delete_zone(context, zone) + except exceptions.Backend: + LOG.error('Could not delete errored zone %s', + zone.name) + raise exceptions.Backend(e) + else: + LOG.info("Can't create zone %s because it already exists", + zone.name) + + self.mdns_api.notify_zone_changed( + context, zone, self.host, self.port, self.timeout, + self.retry_interval, self.max_retries, self.delay) + + def delete_zone(self, context, zone): + """Delete a DNS zone""" + + # First verify that the zone exists + if self._check_zone_exists(zone): + try: + requests.delete( + self._build_url(zone), + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + raise exceptions.Backend(e) + else: + LOG.warning("Trying to delete zone %s but that zone is not " + "present in the ns1 backend. Assuming success.", + zone) diff --git a/designate/tests/unit/backend/test_ns1.py b/designate/tests/unit/backend/test_ns1.py new file mode 100644 index 00000000..edb15de8 --- /dev/null +++ b/designate/tests/unit/backend/test_ns1.py @@ -0,0 +1,217 @@ +# Copyright 2021 NS1 Inc. https://www.ns1.com +# +# Author: Dragan Blagojevic <dblagojevic@daitan.com> +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import requests_mock +from designate import exceptions +from designate import objects +from designate.backend import impl_ns1 +import designate.tests +from designate.tests import fixtures + + +class NS1BackendTestCase(designate.tests.TestCase): + def setUp(self): + super(NS1BackendTestCase, self).setUp() + self.stdlog = fixtures.StandardLogging() + self.useFixture(self.stdlog) + + self.api_address = 'https://192.0.2.3/v1/zones/example.com' + self.context = self.get_context() + self.zone = objects.Zone( + id='e2bed4dc-9d01-11e4-89d3-123b93f75cba', + name='example.com.', + email='example@example.com', + ) + + self.target = { + 'id': '4588652b-50e7-46b9-b688-a9bad40a873e', + 'type': 'ns1', + 'masters': [ + {'host': '192.0.2.1', 'port': 53}, + {'host': '192.0.2.2', 'port': 35}, + ], + 'options': [ + {'key': 'api_endpoint', 'value': '192.0.2.3'}, + {'key': 'api_token', 'value': 'test_key'}, + ], + } + self.target_tsig = { + 'id': '4588652b-50e7-46b9-b688-a9bad40a873e', + 'type': 'ns1', + 'masters': [ + {'host': '192.0.2.1', 'port': 53}, + {'host': '192.0.2.2', 'port': 35}, + ], + 'options': [ + {'key': 'api_endpoint', 'value': '192.0.2.3'}, + {'key': 'api_token', 'value': 'test_key'}, + {'key': 'tsigkey_name', 'value': 'test_key'}, + {'key': 'tsigkey_hash', 'value': 'hmac-sha512'}, + {'key': 'tsigkey_value', 'value': 'aaaabbbbccc'}, + ], + } + self.put_request_json = { + 'zone': u'example.com', + 'secondary': { + 'enabled': True, + 'primary_ip': '192.0.2.1', + 'primary_port': 53 + } + } + self.put_request_tsig_json = { + 'zone': u'example.com', + 'secondary': { + 'enabled': True, + 'primary_ip': '192.0.2.1', + 'primary_port': 53, + 'tsig': { + 'enabled': True, + 'hash': 'hmac-sha512', + 'name': 'test_key', + 'key': 'aaaabbbbccc' + } + } + } + + self.backend = impl_ns1.NS1Backend( + objects.PoolTarget.from_dict(self.target) + ) + self.backend_tsig = impl_ns1.NS1Backend( + objects.PoolTarget.from_dict(self.target_tsig) + ) + + @requests_mock.mock() + def test_create_zone_success(self, req_mock): + req_mock.put(self.api_address) + req_mock.get( + self.api_address, + status_code=404 + ) + + self.backend.create_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.json(), + self.put_request_json + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_with_tsig_success(self, req_mock): + req_mock.put(self.api_address) + req_mock.get( + self.api_address, + status_code=404 + ) + + self.backend_tsig.create_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.json(), + self.put_request_tsig_json + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_already_exists(self, req_mock): + + req_mock.get(self.api_address, status_code=200) + req_mock.put(self.api_address) + + self.backend.create_zone(self.context, self.zone) + + self.assertIn( + "Can't create zone example.com. because it already exists", + self.stdlog.logger.output + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_fail(self, req_mock): + req_mock.put( + self.api_address, + status_code=500, + ) + req_mock.get( + self.api_address, + status_code=404, + ) + + self.assertRaisesRegexp( + exceptions.Backend, + '500 Server Error: None for url: ' + '%s' % self.api_address, + self.backend.create_zone, self.context, self.zone + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_success(self, req_mock): + req_mock.delete(self.api_address, status_code=200) + req_mock.get(self.api_address, status_code=200) + + self.backend.delete_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_missing(self, req_mock): + req_mock.delete(self.api_address, status_code=200) + req_mock.get(self.api_address, status_code=404) + + self.backend.delete_zone(self.context, self.zone) + + self.assertIn( + "Trying to delete zone " + "<Zone id:'e2bed4dc-9d01-11e4-89d3-123b93f75cba' type:'None' " + "name:'example.com.' pool_id:'None' serial:'None' action:'None' " + "status:'None'> " + "but that zone is not " + "present in the ns1 backend. Assuming success.", + self.stdlog.logger.output + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_fail(self, req_mock): + req_mock.delete(self.api_address, status_code=500) + req_mock.get(self.api_address, status_code=200) + + self.assertRaisesRegexp( + exceptions.Backend, + '500 Server Error: None for url: ' + '%s' % self.api_address, + self.backend.delete_zone, self.context, self.zone + ) + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) |