Merge "Add gdnsd backend"

This commit is contained in:
Jenkins 2016-07-14 10:37:31 +00:00 committed by Gerrit Code Review
commit 106dfb0762
8 changed files with 621 additions and 1 deletions

View File

@ -0,0 +1,245 @@
# Copyright 2016 Hewlett Packard Enterprise Development Company LP
#
# Author: Federico Ceratto <federico.ceratto@hpe.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.agent_backend.impl_gdnsd
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
gdnsd agent backend
Create, update, delete zones locally on a gdnsd resolver using the
gdnsd utility.
Supported Knot versions: >= 2.1, < 3
`User documentation <backends/gdnsd_agent.html>`_
.. WARNING::
Untested, do not use in production.
.. NOTE::
If the backend is killed during a configuration transaction it might be
required to manually abort the transaction with `sudo gdnsd conf-abort`
Configured in [service:agent:gdnsd]
"""
import os
import tempfile
import string
import dns
import dns.resolver
from oslo_concurrency.processutils import ProcessExecutionError
from oslo_config import cfg
from oslo_log import log as logging
from designate import utils
from designate import exceptions
from designate.backend.agent_backend import base
from designate.i18n import _LI
from designate.i18n import _LE
LOG = logging.getLogger(__name__)
CFG_GROUP = 'backend:agent:gdnsd'
# rootwrap requires a command name instead of full path
GDNSD_DEFAULT_PATH = 'gdnsd'
CONFDIR_PATH = '/etc/gdnsd'
SOA_QUERY_TIMEOUT = 1
ZONE_FILE_PERMISSIONS = 0o0644
def filter_exceptions(fn):
# Let Backend() exceptions pass through, log out every other exception
# and re-raise it as Backend()
def wrapper(*a, **kw):
try:
return fn(*a, **kw)
except exceptions.Backend as e:
raise e
except Exception as e:
LOG.error(_LE("Unhandled exception %s"), e.message, exc_info=True)
raise exceptions.Backend(e.message)
return wrapper
class GdnsdBackend(base.AgentBackend):
__plugin_name__ = 'gdnsd'
__backend_status__ = 'experimental'
@classmethod
def get_cfg_opts(cls):
group = cfg.OptGroup(
name=CFG_GROUP, title="Configuration for gdnsd backend"
)
opts = [
cfg.StrOpt('gdnsd-cmd-name',
help='gdnsd executable path or rootwrap command name',
default=GDNSD_DEFAULT_PATH),
cfg.StrOpt('confdir-path',
help='gdnsd configuration directory path',
default=CONFDIR_PATH),
cfg.StrOpt('query-destination', default='127.0.0.1',
help='Host to query when finding zones')
]
return [(group, opts)]
def __init__(self, *a, **kw):
"""Configure the backend"""
super(GdnsdBackend, self).__init__(*a, **kw)
self._gdnsd_cmd_name = cfg.CONF[CFG_GROUP].gdnsd_cmd_name
LOG.info(_LI("gdnsd command: %r"), self._gdnsd_cmd_name)
self._confdir_path = cfg.CONF[CFG_GROUP].confdir_path
self._zonedir_path = os.path.join(self._confdir_path, 'zones')
LOG.info(_LI("gdnsd conf directory: %r"), self._confdir_path)
self._resolver = dns.resolver.Resolver(configure=False)
self._resolver.timeout = SOA_QUERY_TIMEOUT
self._resolver.lifetime = SOA_QUERY_TIMEOUT
self._resolver.nameservers = [cfg.CONF[CFG_GROUP].query_destination]
LOG.info(_LI("Resolvers: %r"), self._resolver.nameservers)
self._check_dirs(self._zonedir_path)
def start(self):
"""Start the backend, check gdnsd configuration
:raises: exception.Backend on invalid configuration
"""
LOG.info(_LI("Started gdnsd backend"))
self._check_conf()
def _check_conf(self):
"""Run gdnsd to check its configuration
"""
try:
out, err = utils.execute(
cfg.CONF[CFG_GROUP].gdnsd_cmd_name,
'-D', '-x', 'checkconf', '-c', self._confdir_path,
run_as_root=False,
)
except ProcessExecutionError as e:
LOG.error(_LE("Command output: %(out)r Stderr: %(err)r"), {
'out': e.stdout, 'err': e.stderr
})
raise exceptions.Backend("Configuration check failed")
def _check_dirs(self, *dirnames):
"""Check if directories are writable
"""
for dn in dirnames:
if not os.path.isdir(dn):
raise exceptions.Backend("Missing directory %s" % dn)
if not os.access(dn, os.W_OK):
raise exceptions.Backend("Directory not writable: %s" % dn)
def find_zone_serial(self, zone_name):
"""Query the local resolver for a zone
Times out after SOA_QUERY_TIMEOUT
"""
LOG.debug("Finding %s", zone_name)
try:
rdata = self._resolver.query(
zone_name, rdtype=dns.rdatatype.SOA)[0]
return rdata.serial
except Exception:
return None
def _generate_zone_filename(self, zone_name):
"""Generate a filename for a zone file
"/" is traslated into "@"
Non-valid characters are translated into \ NNN
where NNN is a decimal integer in the range 0 - 255
The filename is lowercase
:returns: valid filename (string)
"""
valid_chars = "-_.@%s%s" % (string.ascii_letters, string.digits)
fname = zone_name.replace('/', '@').lower()
fname = [c if c in valid_chars else "\03%d" % ord(c)
for c in fname]
return ''.join(fname)
def _write_zone_file(self, zone):
"""Create or update a zone file atomically.
The zone file is written to a unique temp file and then renamed
"""
zone_name = zone.origin.to_text().rstrip('.')
zone_base_fname = self._generate_zone_filename(zone_name)
zone_fname = os.path.join(self._zonedir_path, zone_base_fname)
try:
# gdnsd ignores hidden files
tmp_zone_fname = tempfile.mkstemp(
prefix=".%s" % zone_base_fname,
dir=self._zonedir_path,
)[1]
LOG.debug("Writing zone %r to %r and renaming it to %r",
zone_name, tmp_zone_fname, zone_fname)
zone.to_file(tmp_zone_fname)
os.chmod(tmp_zone_fname, ZONE_FILE_PERMISSIONS)
os.rename(tmp_zone_fname, zone_fname)
finally:
try:
os.remove(tmp_zone_fname)
except OSError:
pass
@filter_exceptions
def create_zone(self, zone):
"""Create a new Zone
Do not raise exceptions if the zone already exists.
:param zone: zone to be created
:type zone: raw pythondns Zone
:raises: exceptions.Backend on error
"""
# The zone might be already in place due to a race condition between
# checking if the zone is there and creating it across different
# greenlets
self._write_zone_file(zone)
@filter_exceptions
def update_zone(self, zone):
"""Instruct Djbdns DNS to perform AXFR from MiniDNS
:param zone: zone to be created
:type zone: raw pythondns Zone
:raises: exceptions.Backend on error
"""
self._write_zone_file(zone)
@filter_exceptions
def delete_zone(self, zone_name):
"""Delete a new Zone
Do not raise exceptions if the zone does not exist.
:param zone_name: zone name
:type zone_name: str
:raises: exceptions.Backend on error
"""
zone_name = zone_name.rstrip('.')
LOG.debug('Deleting Zone: %s', zone_name)
zone_fn = self._generate_zone_filename(zone_name)
zone_fn = os.path.join(self._zonedir_path, zone_fn)
try:
os.remove(zone_fn)
LOG.debug('Deleted Zone: %s', zone_name)
except OSError as e:
if os.errno.ENOENT == e.errno:
LOG.info(_LI("Zone datafile %s was already deleted"), zone_fn)
return
raise

View File

@ -0,0 +1,156 @@
# Copyright 2016 Hewlett Packard Enterprise Development Company LP
#
# Author: Federico Ceratto <federico.ceratto@hpe.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.
"""
Functional-test the Gdnsd 2 agent backend
These tests rely on creating directories and files and running
gdnsd.
gdnsd must be installed
"""
from textwrap import dedent as de
import glob
import os
import tempfile
import unittest
from oslo_concurrency.processutils import ProcessExecutionError
from oslo_config import cfg
from oslo_config import fixture as cfg_fixture
import dns
import fixtures
import mock
import testtools
from designate import exceptions
from designate.backend.agent_backend.impl_gdnsd import CFG_GROUP
from designate.backend.agent_backend.impl_gdnsd import GdnsdBackend
from designate.tests import TestCase
TMPFS_DIR = "/dev/shm"
ROOT_TMP_DIR = TMPFS_DIR if os.path.isdir(TMPFS_DIR) else "/tmp"
GDNSD_BIN_PATH = "/usr/sbin/gdnsd"
GDNSD_NOT_AVAILABLE = not os.path.isfile(GDNSD_BIN_PATH)
ZONE_TPL = """
$ORIGIN %(name)s
%(name)s 3600 IN SOA ns.%(name)s email.%(name)s. 1421777854 3600 600 86400 3600
%(name)s 3600 IN NS ns.%(name)s
ns 300 IN A 127.0.0.1
""" # noqa
class GdnsdAgentBackendTestCase(TestCase):
def setUp(self):
super(GdnsdAgentBackendTestCase, self).setUp()
self.conf_dir_path = tempfile.mkdtemp(dir=ROOT_TMP_DIR)
self.zones_dir_path = os.path.join(self.conf_dir_path, 'zones')
os.mkdir(self.zones_dir_path)
self.CONF = self.useFixture(cfg_fixture.Config(cfg.CONF)).conf
cfg.CONF.set_override('confdir_path', self.conf_dir_path,
CFG_GROUP, enforce_type=True)
cfg.CONF.set_override('gdnsd_cmd_name', GDNSD_BIN_PATH,
CFG_GROUP, enforce_type=True)
self.backend = GdnsdBackend('foo')
def tearDown(self):
super(GdnsdAgentBackendTestCase, self).tearDown()
for zone_fn in glob.glob(os.path.join(self.zones_dir_path, "*.org")):
os.remove(zone_fn)
os.rmdir(self.zones_dir_path)
os.rmdir(self.conf_dir_path)
def _patch_ob(self, *a, **kw):
self.useFixture(fixtures.MockPatchObject(*a, **kw))
def _create_dnspy_zone(self, name):
name = name.rstrip('.')
zone_text = ZONE_TPL % {'name': name}
return dns.zone.from_text(zone_text, check_origin=False)
def _create_dnspy_zone_with_records(self, name):
zone_text = (
'$ORIGIN %(name)s\n'
'@ 3600 IN SOA %(ns)s email.%(name)s 1421777854 3600 600 86400 3600\n' # noqa
' 3600 IN NS %(ns)s\n'
' 1800 IN A 173.194.123.30\n'
' 1800 IN A 173.194.123.31\n'
's 2400 IN AAAA 2001:db8:cafe::1\n'
's 2400 IN AAAA 2001:db8:cafe::2\n'
% {'name': name, 'ns': 'ns1.example.net.'}
)
return dns.zone.from_text(zone_text, check_origin=False)
@mock.patch('designate.utils.execute', return_value=("", ""))
def test_start(self, *mocks):
self.backend.start()
@mock.patch('designate.utils.execute', side_effect=ProcessExecutionError)
def test_exec_error(self, *mocks):
with testtools.ExpectedException(exceptions.Backend):
self.backend._check_conf()
def test_create_zone(self, *mocks):
zone = self._create_dnspy_zone('example.org')
self.backend.create_zone(zone)
zone_fn = os.path.join(self.zones_dir_path, "example.org")
expected = de("""\
ns 300 IN A 127.0.0.1
@ 3600 IN SOA ns email.example.org. 1421777854 3600 600 86400 3600
@ 3600 IN NS ns
""")
with open(zone_fn) as f:
self.assertEqual(expected, f.read())
@unittest.skipIf(GDNSD_NOT_AVAILABLE, "gdnsd binary not installed")
def test_create_zone_and_check(self):
zone = self._create_dnspy_zone('example.org')
self.backend.create_zone(zone)
self.backend._check_conf()
def test_update_zone(self):
zone = self._create_dnspy_zone_with_records('example.org')
self.backend.update_zone(zone)
zone_fn = os.path.join(self.zones_dir_path, "example.org")
expected = de("""\
@ 3600 IN SOA ns1.example.net. email 1421777854 3600 600 86400 3600
@ 3600 IN NS ns1.example.net.
@ 1800 IN A 173.194.123.30
@ 1800 IN A 173.194.123.31
s 2400 IN AAAA 2001:db8:cafe::1
s 2400 IN AAAA 2001:db8:cafe::2
""") # noqa
with open(zone_fn) as f:
self.assertEqual(expected, f.read())
@unittest.skipIf(GDNSD_NOT_AVAILABLE, "gdnsd binary not installed")
def test_update_zone_and_check(self):
zone = self._create_dnspy_zone_with_records('example.org')
self.backend.update_zone(zone)
self.backend._check_conf()
def test_delete_zone(self):
foo_fn = os.path.join(self.zones_dir_path, 'foo')
with open(foo_fn, 'w') as f:
f.write("42")
self.backend.delete_zone('foo')
self.assertFalse(os.path.isfile(foo_fn))
self.backend.delete_zone('foo')

View File

@ -0,0 +1,85 @@
# Copyright 2016 Hewlett Packard Enterprise Development Company LP
#
# Author: Federico Ceratto <federico.ceratto@hpe.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.
"""
Unit-test the Gdnsd 2 agent backend
These tests do not rely on creating directories and files or running
executables from the gdnsd suite
"""
import dns.zone
import fixtures
import mock
from designate.backend.agent_backend.impl_gdnsd import GdnsdBackend
from designate.tests import TestCase
import designate.backend.agent_backend.impl_gdnsd # noqa
class GdnsdAgentBackendUnitTestCase(TestCase):
def setUp(self):
super(GdnsdAgentBackendUnitTestCase, self).setUp()
self.useFixture(fixtures.MockPatchObject(
GdnsdBackend, '_check_dirs'
))
self.backend = GdnsdBackend('foo')
self.useFixture(fixtures.MockPatchObject(self.backend._resolver,
'query'))
def tearDown(self):
super(GdnsdAgentBackendUnitTestCase, self).tearDown()
def _create_dnspy_zone(self, name):
zone_text = (
'$ORIGIN %(name)s\n%(name)s 3600 IN SOA %(ns)s '
'email.email.com. 1421777854 3600 600 86400 3600\n%(name)s '
'3600 IN NS %(ns)s\n') % {'name': name, 'ns': 'ns1.designate.com'}
return dns.zone.from_text(zone_text, check_origin=False)
def test_init(self):
self.assertEqual(1, self.backend._resolver.timeout)
self.assertEqual(1, self.backend._resolver.lifetime)
self.assertEqual(['127.0.0.1'], self.backend._resolver.nameservers)
self.assertEqual('/etc/gdnsd/zones',
self.backend._zonedir_path)
self.assertEqual('gdnsd', self.backend._gdnsd_cmd_name)
def test__generate_zone_filename(self):
fn = self.backend._generate_zone_filename("A/bc-d_e.f")
self.assertEqual("a@bc-d_e.f", fn)
def test_find_zone_serial(self):
class Data(object):
serial = 3
self.backend._resolver.query.return_value = [Data(), ]
serial = self.backend.find_zone_serial('example.com')
self.assertEqual(3, serial)
def test_find_zone_serial_error(self):
self.backend._resolver.query.side_effect = RuntimeError('foo')
serial = self.backend.find_zone_serial('example.com')
self.assertEqual(None, serial)
@mock.patch('designate.backend.agent_backend.impl_gdnsd.os.remove')
def test_delete_zone(self, mock_osremove):
self.backend.delete_zone('foo-bar.example.org.')
mock_osremove.assert_called_once_with(
"/etc/gdnsd/zones/foo-bar.example.org")

View File

@ -87,6 +87,16 @@ Agent Backend KnotDNS
:undoc-members:
:show-inheritance:
Agent Backend gdnsd
===================
.. automodule:: designate.backend.agent_backend.impl_gdnsd
:members:
:special-members:
:private-members:
:undoc-members:
:show-inheritance:
Agent Backend Djbdns
====================

View File

@ -0,0 +1,115 @@
..
Copyright 2016 Hewlett Packard Enterprise Development Company LP
Author: Federico Ceratto <federico.ceratto@hpe.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.
gdnsd Agent backend
*******************
User documentation
==================
This page documents the Agent backend for `gdnsd <http://gdnsd.org/>`_.
The agent runs on the same host as the resolver. It receives DNS messages from Mini DNS using private DNS OPCODEs and classes and creates/updates/deletes zones on gdnsd using zone files under the gdnsd configuration directory.
The backend supports gdnsd from version 2.0
`gdnsd documentation <https://github.com/gdnsd/gdnsd/wiki>`_
Setting up gdnsd on Ubuntu Vivid
--------------------------------
Run as root:
.. code-block:: bash
apt-get update
apt-get install gdnsd
Configuring gdnsd
-----------------
Assuming gdnsd has been freshly installed on the system, run as root:
.. code-block:: bash
# Monitor syslog during the next steps
tail -f /var/log/syslog
# config check should be successful
/usr/sbin/gdnsd checkconf
# Start the daemon if needed
service gdnsd status
service gdnsd start
# gdnsd should be listening on TCP and UDP ports
netstat -lnptu | grep '/gdnsd'
# Test the daemon: it should respond with "gdnsd"
dig @127.0.0.1 CH TXT +short
Configure the "service.agent" and "backend.agent.gdnsd" sections in /etc/designate/designate.conf
Look in designate.conf.example for more complete examples
.. code-block:: ini
[service:agent]
backend_driver = gdnsd
# Place here the MiniDNS ipaddr and port (not the agent itself)
masters = 192.168.27.100:5354
[backend:agent:gdnsd]
#gdnsd_cmd_name = gdnsd
#confdir_path = /etc/gdnsd
#query_destination = 127.0.0.1
Ensure that the "zones" directory under "confdir_path" (default /etc/gdnsd) is readable and writable by the system user running the Designate Agent
Create an agent pool:
.. code-block:: bash
# Fetch the existing pool(s) if needed
designate-manage pool generate_file --file /tmp/pool.yaml
# Edit the file (see below) and reload it as:
designate-manage pool update --file /tmp/pool.yaml
The "targets" section in pool.yaml should look like:
.. code-block:: ini
targets:
- description: gdnsd agent
masters:
- host: <MiniDNS IP addr>
port: 5354
options: {}
options:
- host: <Agent IP addr>
port: 5358
type: agent
Start the Designate Agent. You should see log messages similar to:
.. code-block:: bash
2016-05-03 15:13:38.193 INFO designate.backend.agent_backend.impl_gdnsd [-] gdnsd command: 'gdnsd'
2016-05-03 15:13:38.193 INFO designate.backend.agent_backend.impl_gdnsd [-] gdnsd conf directory: '/etc/gdnsd'
2016-05-03 15:13:38.194 INFO designate.backend.agent_backend.impl_gdnsd [-] Resolvers: ['127.0.0.1']

View File

@ -55,7 +55,7 @@ backend-impl-bind9-agent=Bind9 (Agent)
backend-impl-denominator=Denominator
backend-impl-knot2-agent=Knot2 (Agent)
backend-impl-djbdns-agent=Djbdns (Agent)
backend-impl-gdnsd-agent=Gdnsd (Agent)
[backends.backend-impl-bind9]
@ -83,6 +83,9 @@ type=agent
[backends.backend-impl-djbdns-agent]
type=agent
[backends.backend-impl-gdnsd-agent]
type=agent
[backends.backend-impl-infoblox-xfr]
status=release-compatible
maintainers=Infoblox OpenStack Team <openstack-maintainer@infoblox.com>

View File

@ -479,6 +479,11 @@ debug = False
#name = dynect
#config_file = /etc/denominator.conf
[backend:agent:gdnsd]
#gdnsd_cmd_name = gdnsd
#confdir_path = /etc/gdnsd
#query_destination = 127.0.0.1
########################
## Library Configuration
########################

View File

@ -95,6 +95,7 @@ designate.backend.agent_backend =
djbdns = designate.backend.agent_backend.impl_djbdns:DjbdnsBackend
denominator = designate.backend.agent_backend.impl_denominator:DenominatorBackend
fake = designate.backend.agent_backend.impl_fake:FakeBackend
gdnsd = designate.backend.agent_backend.impl_gdnsd:GdnsdBackend
designate.network_api =
fake = designate.network_api.fake:FakeNetworkAPI