summaryrefslogtreecommitdiff
path: root/designate
diff options
context:
space:
mode:
authorMichael Hood <mhood@ns1.com>2021-03-05 08:04:29 -0800
committerMichael Hood <mhood@ns1.com>2021-03-08 10:55:06 -0800
commit5aac48f08b441cbde8c2078a9ace0551d3d19058 (patch)
tree7414f0fda74cd155d9d1cbeb26f300888b133f97 /designate
parent75668d084c2a27fcc25c67741ca309bfe43b1322 (diff)
downloaddesignate-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.py136
-rw-r--r--designate/tests/unit/backend/test_ns1.py217
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'
+ )