Add NS1 backend
Introduce an NS1 backend. Signed-off-by: Michael Hood <mhood@ns1.com> Change-Id: I80fe08238005a94161e2dbcc89e77c90cde0a715
This commit is contained in:
parent
75668d084c
commit
5aac48f08b
|
@ -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)
|
|
@ -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'
|
||||
)
|
|
@ -0,0 +1,107 @@
|
|||
# Configure the NS1 backend
|
||||
|
||||
# Requirements:
|
||||
# A working NS1 managed DNS / DDI environment is needed to use this DevStack plugin.
|
||||
|
||||
# Enable with:
|
||||
# DESIGNATE_BACKEND_DRIVER=ns1
|
||||
|
||||
# Dependencies:
|
||||
# ``functions`` file
|
||||
# ``designate`` configuration
|
||||
|
||||
# install_designate_backend - install any external requirements
|
||||
# configure_designate_backend - make configuration changes, including those to other services
|
||||
# init_designate_backend - initialize databases, etc.
|
||||
# start_designate_backend - start any external services
|
||||
# stop_designate_backend - stop any external services
|
||||
# cleanup_designate_backend - remove transient data and cache
|
||||
|
||||
# Save trace setting
|
||||
DP_NS1_XTRACE=$(set +o | grep xtrace)
|
||||
set +o xtrace
|
||||
|
||||
# Defaults
|
||||
# --------
|
||||
|
||||
DESIGNATE_NS1_DNS_IP=${DESIGNATE_NS1_DNS_IP:-172.31.45.104}
|
||||
DESIGNATE_NS1_DNS_PORT=${DESIGNATE_NS1_DNS_PORT:-5333}
|
||||
DESIGNATE_NS1_XFR_IP=${DESIGNATE_NS1_XFR_IP:-172.31.45.104}
|
||||
DESIGNATE_NS1_XFR_PORT=${DESIGNATE_NS1_XFR_PORT:-5400}
|
||||
DESIGNATE_NS1_API_IP=${DESIGNATE_NS1_API_IP:-172.31.45.104}
|
||||
DESIGNATE_NS1_API_TOKEN=${DESIGNATE_NS1_API_TOKEN:-default}
|
||||
|
||||
|
||||
# Entry Points
|
||||
# ------------
|
||||
|
||||
|
||||
# install_designate_backend - install any external requirements
|
||||
function install_designate_backend {
|
||||
if is_ubuntu; then
|
||||
install_package python-dev libxslt1-dev libxslt1.1 libxml2-dev libxml2 libssl-dev
|
||||
elif is_fedora; then
|
||||
install_package python-devel libxslt1-devel libxslt1.1 libxml2-devel libxml2 libssl-devel
|
||||
fi
|
||||
}
|
||||
|
||||
# configure_designate_backend - make configuration changes, including those to other services
|
||||
function configure_designate_backend {
|
||||
|
||||
# Generate Designate pool.yaml file
|
||||
sudo tee $DESIGNATE_CONF_DIR/pools.yaml > /dev/null <<EOF
|
||||
---
|
||||
- name: default
|
||||
description: DevStack NS1 Pool
|
||||
attributes: {}
|
||||
|
||||
ns_records:
|
||||
- hostname: $DESIGNATE_DEFAULT_NS_RECORD
|
||||
priority: 1
|
||||
|
||||
nameservers:
|
||||
- host: $DESIGNATE_NS1_DNS_IP
|
||||
port: $DESIGNATE_NS1_DNS_PORT
|
||||
|
||||
targets:
|
||||
- type: ns1
|
||||
description: NS1 Managed DNS
|
||||
|
||||
masters:
|
||||
- host: $(ipv6_unquote $DESIGNATE_SERVICE_HOST)
|
||||
port: $DESIGNATE_SERVICE_PORT_MDNS
|
||||
|
||||
options:
|
||||
host: $DESIGNATE_NS1_XFR_IP
|
||||
port: $DESIGNATE_NS1_XFR_PORT
|
||||
api_endpoint: $DESIGNATE_NS1_API_IP
|
||||
api_token: $DESIGNATE_NS1_API_TOKEN
|
||||
# NOTE: TSIG key has to be set manually if it's necessary
|
||||
#tsigkey_name: testkey
|
||||
#tsigkey_hash: hmac-sha512
|
||||
#tsigkey_value: 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg==
|
||||
EOF
|
||||
}
|
||||
|
||||
# init_designate_backend - initialize databases, etc.
|
||||
function init_designate_backend {
|
||||
:
|
||||
}
|
||||
|
||||
# start_designate_backend - start any external services
|
||||
function start_designate_backend {
|
||||
:
|
||||
}
|
||||
|
||||
# stop_designate_backend - stop any external services
|
||||
function stop_designate_backend {
|
||||
:
|
||||
}
|
||||
|
||||
# cleanup_designate_backend - remove transient data and cache
|
||||
function cleanup_designate_backend {
|
||||
:
|
||||
}
|
||||
|
||||
# Restore xtrace
|
||||
$DP_NS1_XTRACE
|
|
@ -0,0 +1,66 @@
|
|||
..
|
||||
Copyright 2021 NS1 inc. https://www.ns1.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.
|
||||
|
||||
.. _backend-ns1:
|
||||
|
||||
NS1 Backend
|
||||
===========
|
||||
|
||||
NS1 Configuration
|
||||
-----------------
|
||||
|
||||
|
||||
1. Configure the NS1 Backend using this sample target snippet
|
||||
|
||||
.. literalinclude:: sample_yaml_snippets/ns1.yaml
|
||||
:language: yaml
|
||||
|
||||
2. Then update the pools in designate
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ designate-manage pool update
|
||||
|
||||
See :ref:`designate_manage_pool` for further details on
|
||||
the ``designate-manage pool`` command, and :ref:`pools`
|
||||
for information about the yaml file syntax
|
||||
|
||||
|
||||
TSIG Key Configuration
|
||||
----------------------
|
||||
|
||||
In some cases a deployer may need to use tsig keys to sign AXFR (zone transfer)
|
||||
requests. As NS1 does not support a per host key setup, this needs to be set
|
||||
on a per zone basis, on creation.
|
||||
|
||||
To do this, generate a tsigkey using any of available utilities
|
||||
(e.g. tsig-keygen):
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ tsig-keygen -a hmac-sha512 testkey
|
||||
key "testkey" {
|
||||
algorithm hmac-sha512;
|
||||
secret "vQbMI3u5QGUyRu6FWRm16eL0F0dfOOmVJjWKCTg4mIMNnba0g2PLrV+0G92WcTfJrgqZ20a4hv3RWDICKCcJhw==";
|
||||
};
|
||||
|
||||
Then insert it into Designate. Make sure the pool id is correct
|
||||
(the ``--resource-id`` below.)
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
openstack tsigkey create --name testkey --algorithm hmac-sha512 --secret 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg== --scope POOL --resource-id 794ccc2c-d751-44fe-b57f-8894c9f5c842
|
||||
|
||||
Then add it to the ``pools.yaml`` file as shown in the example.
|
|
@ -0,0 +1,25 @@
|
|||
targets:
|
||||
- type: ns1
|
||||
description: NS1 DNS Server
|
||||
|
||||
# List out the designate-mdns servers from which NS1 servers should
|
||||
# request zone transfers (AXFRs) from.
|
||||
masters:
|
||||
- host: 192.0.2.1
|
||||
port: 5354
|
||||
|
||||
# NS1 Configuration options
|
||||
options:
|
||||
#NS1 XFR container ip and port
|
||||
host: 192.0.2.2
|
||||
port: 5302
|
||||
#NS1 API enpoint IP address or name (Core container). Enter only base address or name.
|
||||
#Plugin will generate full api address, e.g. https://192.0.2.2/v1/zones/<zone name>
|
||||
api_endpoint: 192.0.2.2
|
||||
#NS1 API key
|
||||
api_token: changeme
|
||||
# If a tsigkey is needed, uncomment the line below and insert the key name, algorithm and value
|
||||
# NOTE: TSIG key has to be set manually
|
||||
#tsigkey_name: testkey
|
||||
#tsigkey_hash: hmac-sha512
|
||||
#tsigkey_value: 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg==
|
|
@ -52,6 +52,7 @@ backend-impl-akamai=Akamai eDNS
|
|||
backend-impl-akamai_v2=Akamai DNS v2
|
||||
backend-impl-infoblox-xfr=Infoblox (XFR)
|
||||
backend-impl-nsd4=NSD4
|
||||
backend-impl-ns1=NS1 DNS
|
||||
backend-impl-agent=Agent
|
||||
backend-impl-bind9-agent=Bind9 (Agent)
|
||||
backend-impl-denominator=Denominator
|
||||
|
@ -80,6 +81,11 @@ notes=Akamai has turned off the eDNS API - see https://community.akamai.com/cust
|
|||
[backends.backend-impl-akamai_v2]
|
||||
docs=akamai_v2_backend_docs
|
||||
|
||||
[backends.backend-impl-ns1]
|
||||
docs=ns1_backend_docs
|
||||
status=untested
|
||||
config=backends/sample_yaml_snippets/ns1.yaml
|
||||
|
||||
[backends.backend-impl-agent]
|
||||
|
||||
[backends.backend-impl-bind9-agent]
|
||||
|
|
|
@ -80,6 +80,7 @@ designate.backend =
|
|||
infoblox = designate.backend.impl_infoblox:InfobloxBackend
|
||||
fake = designate.backend.impl_fake:FakeBackend
|
||||
agent = designate.backend.agent:AgentPoolBackend
|
||||
ns1 = designate.backend.impl_ns1:NS1Backend
|
||||
|
||||
designate.backend.agent_backend =
|
||||
bind9 = designate.backend.agent_backend.impl_bind9:Bind9Backend
|
||||
|
|
Loading…
Reference in New Issue