The New Agent

A new Designate service to be run on a DNS master that is the 'reflection' of
MiniDNS. It receives Create/NOTIFY/Delete actions, and calls into a backend
that greatly resembles the style of backends in Designate before Server Pools.

This is a squashed commit that formerly contained the following 'Agent' topics:
Basic Service
Add basic NOTIFY support
Add AXFR Capability
Add Support for Receiving Private CLASS/RRDATA Messages
Add Backend Capabilities
Cleanup (Changes here: http://paste.openstack.org/show/160022/)

Implements: blueprint new-agent
Change-Id: I610687878be9dee8065ebc492f662e86bbeb8ce7
This commit is contained in:
Tim Simmons 2014-12-03 16:02:31 +00:00
parent 8f59a264ec
commit fbdd8e9400
20 changed files with 1312 additions and 0 deletions

View File

@ -0,0 +1,39 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from oslo.config import cfg
cfg.CONF.register_group(cfg.OptGroup(
name='service:agent', title="Configuration for the Agent Service"
))
OPTS = [
cfg.IntOpt('workers', default=None,
help='Number of agent worker processes to spawn'),
cfg.StrOpt('host', default='0.0.0.0',
help='The Agent Bind Host'),
cfg.IntOpt('port', default=5358,
help='mDNS Port Number'),
cfg.IntOpt('tcp-backlog', default=100,
help='The Agent TCP Backlog'),
cfg.ListOpt('allow-notify', default=[],
help='List of IP addresses allowed to NOTIFY The Agent'),
cfg.ListOpt('masters', default=[],
help='List of masters for the Agent, format ip:port'),
cfg.StrOpt('backend-driver', default='bind9',
help='The backend driver to use'),
]
cfg.CONF.register_opts(OPTS, group='service:agent')

63
designate/agent/axfr.py Normal file
View File

@ -0,0 +1,63 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 dns
import dns.zone
from oslo.config import cfg
from oslo_log import log as logging
from designate.i18n import _LI
from designate.i18n import _LE
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
class AXFR(object):
def __init__(self):
self.masters = []
for server in CONF['service:agent'].masters:
raw_server = server.split(':')
master = {'ip': raw_server[0], 'port': int(raw_server[1])}
self.masters.append(master)
LOG.info(_LI("Agent masters: %(masters)s") %
{'masters': self.masters})
def do_axfr(self, zone_name):
"""
Performs an AXFR for a given zone name
"""
# TODO(Tim): Try the first master, try others if they exist
master = self.masters[0]
LOG.info(_LI("Doing AXFR for %(name)s from %(host)s") %
{'name': zone_name, 'host': master})
xfr = dns.query.xfr(master['ip'], zone_name, relativize=False,
port=master['port'])
try:
# TODO(Tim): Add a timeout to this function
raw_zone = dns.zone.from_xfr(xfr, relativize=False)
except Exception:
LOG.exception(_LE("There was a problem with the AXFR"))
raise
LOG.debug("AXFR Successful for %s" % raw_zone.origin.to_text())
return raw_zone

212
designate/agent/handler.py Normal file
View File

@ -0,0 +1,212 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 dns
from oslo.config import cfg
from oslo_log import log as logging
from designate.agent import axfr
from designate.backend import agent_backend
from designate.i18n import _LW
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
# Command and Control OPCODE
CC = 14
# Private DNS CLASS Uses
ClassCC = 65280
# Private RR Code Uses
SUCCESS = 65280
FAILURE = 65281
CREATE = 65282
DELETE = 65283
class RequestHandler(object):
def __init__(self):
self.xfr = axfr.AXFR()
self.allow_notify = CONF['service:agent'].allow_notify
backend_driver = cfg.CONF['service:agent'].backend_driver
self.backend = agent_backend.get_backend(backend_driver, self)
def __call__(self, request):
"""
:param request: DNS Request Message
:return: DNS Response Message
"""
# TODO(Tim): Handle multiple questions
rdtype = request.question[0].rdtype
rdclass = request.question[0].rdclass
opcode = request.opcode()
if opcode == dns.opcode.NOTIFY:
response = self._handle_notify(request)
elif opcode == CC:
if rdclass == ClassCC:
if rdtype == CREATE:
response = self._handle_create(request)
elif rdtype == DELETE:
response = self._handle_delete(request)
else:
response = self._handle_query_error(request,
dns.rcode.REFUSED)
else:
response = self._handle_query_error(request, dns.rcode.REFUSED)
else:
# Unhandled OpCodes include STATUS, QUERY, IQUERY, UPDATE
response = self._handle_query_error(request, dns.rcode.REFUSED)
# TODO(Tim): Answer Type 65XXX queries
return response
def _handle_query_error(self, request, rcode):
"""
Construct an error response with the rcode passed in.
:param request: The decoded request from the wire.
:param rcode: The response code to send back.
:return: A dns response message with the response code set to rcode
"""
response = dns.message.make_response(request)
response.set_rcode(rcode)
return response
def _handle_create(self, request):
response = dns.message.make_response(request)
question = request.question[0]
requester = request.environ['addr'][0]
domain_name = question.name.to_text()
if not self._allowed(request, requester, "CREATE", domain_name):
response.set_rcode(dns.rcode.from_text("REFUSED"))
return response
serial = self.backend.find_domain_serial(domain_name)
if serial is not None:
LOG.warn(_LW("Refusing CREATE for %(name)s, zone already exists") %
{'name': domain_name})
response.set_rcode(dns.rcode.from_text("REFUSED"))
return response
LOG.debug("Received %(verb)s for %(name)s from %(host)s" %
{'verb': "CREATE", 'name': domain_name, 'host': requester})
try:
zone = self.xfr.do_axfr(domain_name)
self.backend.create_domain(zone)
except Exception:
response.set_rcode(dns.rcode.from_text("SERVFAIL"))
return response
# Provide an authoritative answer
response.flags |= dns.flags.AA
return response
def _handle_notify(self, request):
"""
Constructs the response to a NOTIFY and acts accordingly on it.
* Decodes the NOTIFY
* Checks if the master sending the NOTIFY is allowed to notify
* Does a serial check to see if further action needs to be taken
* Kicks off an AXFR and returns a valid response
"""
response = dns.message.make_response(request)
question = request.question[0]
requester = request.environ['addr'][0]
domain_name = question.name.to_text()
if not self._allowed(request, requester, "NOTIFY", domain_name):
response.set_rcode(dns.rcode.from_text("REFUSED"))
return response
serial = self.backend.find_domain_serial(domain_name)
if serial is None:
LOG.warn(_LW("Refusing NOTIFY for %(name)s, doesn't exist") %
{'name': domain_name})
response.set_rcode(dns.rcode.from_text("REFUSED"))
return response
LOG.debug("Received %(verb)s for %(name)s from %(host)s" %
{'verb': "NOTIFY", 'name': domain_name, 'host': requester})
# According to RFC we should query the server that sent the NOTIFY
# TODO(Tim): Reenable this when it makes more sense
# resolver = dns.resolver.Resolver()
# resolver.nameservers = [requester]
# This assumes that the Master is running on port 53
# soa_answer = resolver.query(domain_name, 'SOA')
# Check that the serial is < serial above
try:
zone = self.xfr.do_axfr(domain_name)
self.backend.update_domain(zone)
except Exception:
response.set_rcode(dns.rcode.from_text("SERVFAIL"))
return response
# Provide an authoritative answer
response.flags |= dns.flags.AA
return response
def _handle_delete(self, request):
"""
Constructs the response to a DELETE and acts accordingly on it.
* Decodes the message for zone name
* Checks if the master sending the DELETE is in the allowed notify list
* Checks if the zone exists (maybe?)
* Kicks a call to the backend to delete the zone in question
"""
response = dns.message.make_response(request)
question = request.question[0]
requester = request.environ['addr'][0]
domain_name = question.name.to_text()
if not self._allowed(request, requester, "DELETE", domain_name):
response.set_rcode(dns.rcode.from_text("REFUSED"))
return response
LOG.debug("Received DELETE for %(name)s from %(host)s" %
{'name': domain_name, 'host': requester})
# Provide an authoritative answer
response.flags |= dns.flags.AA
# Call into the backend to Delete
try:
self.backend.delete_domain(domain_name)
except Exception:
response.set_rcode(dns.rcode.from_text("SERVFAIL"))
return response
return response
def _allowed(self, request, requester, op, domain_name):
if requester not in self.allow_notify:
LOG.warn(_LW("%(verb)s for %(name)s from %(server)s refused") %
{'verb': op, 'name': domain_name, 'server': requester})
return False
return True

View File

@ -0,0 +1,42 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.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.
class Middleware(object):
def __init__(self, application):
self.application = application
def process_request(self, request):
"""Called on each request.
If this returns None, the next application down the stack will be
executed. If it returns a response then that response will be returned
and execution will stop here.
"""
return None
def process_response(self, response):
"""Do whatever you'd like to the response."""
return response
def __call__(self, request):
response = self.process_request(request)
if response:
return response
response = self.application(request)
return self.process_response(response)

180
designate/agent/service.py Normal file
View File

@ -0,0 +1,180 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 socket
import struct
import dns
from oslo.config import cfg
from oslo_log import log as logging
from designate import service
from designate.agent import handler
from designate.agent import middleware
from designate.backend import agent_backend
from designate.i18n import _LE
from designate.i18n import _LI
from designate.i18n import _LW
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
class Service(service.TCPService):
def __init__(self, *args, **kwargs):
super(Service, self).__init__(*args, **kwargs)
backend_driver = cfg.CONF['service:agent'].backend_driver
self.backend = agent_backend.get_backend(backend_driver, self)
# Create an instance of the RequestHandler class
self.application = handler.RequestHandler()
# Wrap the application in any middleware required
# TODO(kiall): In the future, we want to allow users to pick+choose
# the middleware to be applied, similar to how we do this
# in the API.
self.application = middleware.Middleware(self.application)
# Bind to the TCP port
LOG.info(_LI('Opening TCP Listening Socket on %(host)s:%(port)d') %
{'host': CONF['service:agent'].host,
'port': CONF['service:agent'].port})
self._sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock_tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock_tcp.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self._sock_tcp.bind((CONF['service:agent'].host,
CONF['service:agent'].port))
self._sock_tcp.listen(CONF['service:agent'].tcp_backlog)
# Bind to the UDP port
LOG.info(_LI('Opening UDP Listening Socket on %(host)s:%(port)d') %
{'host': CONF['service:agent'].host,
'port': CONF['service:agent'].port})
self._sock_udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock_udp.bind((CONF['service:agent'].host,
CONF['service:agent'].port))
def start(self):
super(Service, self).start()
self.backend.start()
self.tg.add_thread(self._handle_tcp)
self.tg.add_thread(self._handle_udp)
LOG.info(_LI("Started Agent Service"))
def stop(self):
super(Service, self).stop()
LOG.info(_LI("Stopped Agent Service"))
def _deserialize_request(self, payload, addr):
"""
Deserialize a DNS Request Packet
:param payload: Raw DNS query payload
:param addr: Tuple of the client's (IP, Port)
"""
try:
request = dns.message.from_wire(payload)
except dns.exception.DNSException:
LOG.error(_LE("Failed to deserialize packet from "
"%(host)s:%(port)d") %
{'host': addr[0], 'port': addr[1]})
return None
else:
# Create + Attach the initial "environ" dict. This is similar to
# the environ dict used in typical WSGI middleware.
request.environ = {'addr': addr}
return request
def _serialize_response(self, response):
"""
Serialize a DNS Response Packet
:param response: DNS Response Message
"""
return response.to_wire()
def _handle_tcp(self):
LOG.info(_LI("_handle_tcp thread started"))
while True:
client, addr = self._sock_tcp.accept()
LOG.debug("Handling TCP Request from: %(host)s:%(port)d" %
{'host': addr[0], 'port': addr[1]})
payload = client.recv(65535)
(expected_length,) = struct.unpack('!H', payload[0:2])
actual_length = len(payload[2:])
# For now we assume all requests are one packet
# TODO(vinod): Handle multipacket requests
if (expected_length != actual_length):
LOG.warn(_LW("got a packet with unexpected length from "
"%(host)s:%(port)d. Expected length=%(elen)d. "
"Actual length=%(alen)d.") %
{'host': addr[0], 'port': addr[1],
'elen': expected_length, 'alen': actual_length})
client.close()
else:
self.tg.add_thread(self._handle, addr, payload[2:], client)
def _handle_udp(self):
LOG.info(_LI("_handle_udp thread started"))
while True:
# TODO(kiall): Determine the appropriate default value for
# UDP recvfrom.
payload, addr = self._sock_udp.recvfrom(8192)
LOG.debug("Handling UDP Request from: %(host)s:%(port)d" %
{'host': addr[0], 'port': addr[1]})
self.tg.add_thread(self._handle, addr, payload)
def _handle(self, addr, payload, client=None):
"""
Handle a DNS Query
:param addr: Tuple of the client's (IP, Port)
:param payload: Raw DNS query payload
:param client: Client socket (for TCP only)
"""
try:
request = self._deserialize_request(payload, addr)
if request is None:
# We failed to deserialize the request, generate a failure
# response using a made up request.
response = dns.message.make_response(
dns.message.make_query('unknown', dns.rdatatype.A))
response.set_rcode(dns.rcode.FORMERR)
else:
response = self.application(request)
# send back a response only if present
if response:
response = self._serialize_response(response)
if client is not None:
# Handle TCP Responses
msg_length = len(response)
tcp_response = struct.pack("!H", msg_length) + response
client.send(tcp_response)
client.close()
else:
# Handle UDP Responses
self._sock_udp.sendto(response, addr)
except Exception:
LOG.exception(_LE("Unhandled exception while processing request "
"from %(host)s:%(port)d") %
{'host': addr[0], 'port': addr[1]})

View File

@ -0,0 +1,28 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from oslo_log import log as logging
from designate.backend.agent_backend.base import AgentBackend
LOG = logging.getLogger(__name__)
def get_backend(backend_driver, agent_service):
LOG.debug("Loading backend driver: %s" % backend_driver)
cls = AgentBackend.get_driver(backend_driver)
return cls(agent_service)

View File

@ -0,0 +1,57 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 abc
from oslo_log import log as logging
from designate.plugin import DriverPlugin
LOG = logging.getLogger(__name__)
class AgentBackend(DriverPlugin):
"""Base class for backend implementations"""
__plugin_type__ = 'backend'
__plugin_ns__ = 'designate.backend.agent_backend'
def __init__(self, agent_service):
super(AgentBackend, self).__init__()
self.agent_service = agent_service
def start(self):
pass
def stop(self):
pass
@abc.abstractmethod
def find_domain_serial(self, domain_name):
"""Find a DNS Domain"""
@abc.abstractmethod
def create_domain(self, domain):
"""Create a DNS domain"""
"""Domain is a DNSPython Zone object"""
@abc.abstractmethod
def update_domain(self, domain):
"""Update a DNS domain"""
"""Domain is a DNSPython Zone object"""
@abc.abstractmethod
def delete_domain(self, domain_name):
"""Delete a DNS domain"""

View File

@ -0,0 +1,149 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 time
import os
import dns
from oslo.config import cfg
from oslo.concurrency import lockutils
from oslo_log import log as logging
from designate.backend.agent_backend import base
from designate import exceptions
from designate import utils
from designate.i18n import _LI
LOG = logging.getLogger(__name__)
CFG_GROUP = 'backend:agent:bind9'
class Bind9Backend(base.AgentBackend):
__plugin_name__ = 'bind9'
@classmethod
def get_cfg_opts(cls):
group = cfg.OptGroup(
name='backend:agent:bind9', title="Configuration for bind9 backend"
)
opts = [
cfg.StrOpt('rndc-host', default='127.0.0.1', help='RNDC Host'),
cfg.IntOpt('rndc-port', default=953, help='RNDC Port'),
cfg.StrOpt('rndc-config-file', default=None,
help='RNDC Config File'),
cfg.StrOpt('rndc-key-file', default=None, help='RNDC Key File'),
cfg.StrOpt('zone-file-path', default='$state_path/zones',
help='Path where zone files are stored')
]
return [(group, opts)]
def start(self):
LOG.info(_LI("Started bind9 backend"))
def find_domain_serial(self, domain_name):
LOG.debug("Finding %s" % domain_name)
resolver = dns.resolver.Resolver()
resolver.nameservers = ['127.0.0.1']
try:
rdata = resolver.query(domain_name, 'SOA')[0]
except Exception:
return None
return rdata.serial
def create_domain(self, domain):
LOG.debug("Creating %s" % domain.origin.to_text())
self._sync_domain(domain, new_domain_flag=True)
def update_domain(self, domain):
LOG.debug("Updating %s" % domain.origin.to_text())
self._sync_domain(domain)
def delete_domain(self, domain_name):
LOG.debug('Delete Domain: %s' % domain_name)
rndc_op = 'delzone'
# RNDC doesn't like the trailing dot on the domain name
rndc_call = self._rndc_base() + [rndc_op, domain_name[:-1]]
utils.execute(*rndc_call)
def _rndc_base(self):
rndc_call = [
'rndc',
'-s', cfg.CONF[CFG_GROUP].rndc_host,
'-p', str(cfg.CONF[CFG_GROUP].rndc_port),
]
if cfg.CONF[CFG_GROUP].rndc_config_file:
rndc_call.extend(['-c',
cfg.CONF[CFG_GROUP].rndc_config_file])
if cfg.CONF[CFG_GROUP].rndc_key_file:
rndc_call.extend(['-k',
cfg.CONF[CFG_GROUP].rndc_key_file])
return rndc_call
def _sync_domain(self, domain, new_domain_flag=False):
"""Sync a single domain's zone file and reload bind config"""
domain_name = domain.origin.to_text()
# NOTE: Only one thread should be working with the Zonefile at a given
# time. The sleep(1) below introduces a not insignificant risk
# of more than 1 thread working with a zonefile at a given time.
with lockutils.lock('bind9-%s' % domain_name):
LOG.debug('Synchronising Domain: %s' % domain_name)
zone_path = cfg.CONF[CFG_GROUP].zone_file_path
output_path = os.path.join(zone_path,
'%szone' % domain_name)
domain.to_file(output_path)
rndc_call = self._rndc_base()
if new_domain_flag:
rndc_op = [
'addzone',
'%s { type master; file "%s"; };' % (domain_name,
output_path),
]
rndc_call.extend(rndc_op)
else:
rndc_op = 'reload'
rndc_call.extend([rndc_op])
rndc_call.extend([domain_name])
if not new_domain_flag:
# NOTE: Bind9 will only ever attempt to re-read a zonefile if
# the file's timestamp has changed since the previous
# reload. A one second sleep ensures we cross over a
# second boundary before allowing the next change.
time.sleep(1)
LOG.debug('Calling RNDC with: %s' % " ".join(rndc_call))
self._execute_rndc(rndc_call)
def _execute_rndc(self, rndc_call):
try:
LOG.debug('Executing RNDC call: %s' % " ".join(rndc_call))
utils.execute(*rndc_call)
except utils.processutils.ProcessExecutionError as e:
LOG.debug('RNDC call failure: %s' % e)
raise exceptions.Backend(e)

View File

@ -0,0 +1,44 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from oslo_log import log as logging
from designate.backend.agent_backend import base
from designate.i18n import _LI
LOG = logging.getLogger(__name__)
class FakeBackend(base.AgentBackend):
__plugin_name__ = 'fake'
def start(self):
LOG.info(_LI("Started fake backend, Pool Manager will not work!"))
def stop(self):
LOG.info(_LI("Stopped fake backend"))
def find_domain_serial(self, domain_name):
LOG.debug("Finding %s" % domain_name)
return 0
def create_domain(self, domain):
LOG.debug("Creating %s" % domain.origin.to_text())
def update_domain(self, domain):
LOG.debug("Updating %s" % domain.origin.to_text())
def delete_domain(self, domain_name):
LOG.debug('Delete Domain: %s' % domain_name)

37
designate/cmd/agent.py Normal file
View File

@ -0,0 +1,37 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 sys
from oslo.config import cfg
from oslo_log import log as logging
from designate import service
from designate import utils
from designate.agent import service as agent_service
CONF = cfg.CONF
CONF.import_opt('workers', 'designate.agent', group='service:agent')
def main():
utils.read_config('designate', sys.argv)
logging.setup(CONF, 'designate')
server = agent_service.Service.create(
binary='designate-agent')
service.serve(server, workers=CONF['service:agent'].workers)
service.wait()

View File

@ -128,6 +128,52 @@ class RPCService(Service):
super(RPCService, self).wait()
class TCPService(Service):
"""
Service class to be used for a service that only works in TCP
"""
def __init__(self, host=None, binary=None, service_name=None,
endpoints=None, threads=1000):
super(TCPService, self).__init__(threads)
self.host = host
self.binary = binary
self.service_name = service_name
self.endpoints = endpoints or [self]
@classmethod
def create(cls, host=None, binary=None, service_name=None,
endpoints=None):
"""Instantiates class and passes back application object.
:param host: defaults to CONF.host
:param binary: defaults to basename of executable
"""
if not host:
host = CONF.host
if not binary:
binary = os.path.basename(inspect.stack()[-1][1])
service_obj = cls(host, binary, service_name=service_name,
endpoints=endpoints)
return service_obj
def start(self):
for e in self.endpoints:
if e != self and hasattr(e, 'start'):
e.start()
super(TCPService, self).start()
def stop(self):
for e in self.endpoints:
if e != self and hasattr(e, 'stop'):
e.stop()
super(TCPService, self).stop()
class WSGIService(wsgi.Service, Service):
"""
Service class to be shared by all Designate WSGI Services

View File

@ -0,0 +1,20 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from designate.tests import TestCase
class AgentTestCase(TestCase):
pass

View File

@ -0,0 +1,26 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from oslo.config import cfg
from designate.backend import agent_backend
from designate.agent import service
class BackendTestMixin(object):
def get_backend_driver(self):
return agent_backend.get_backend(
cfg.CONF['service:agent'].backend_driver,
agent_service=service.Service())

View File

@ -0,0 +1,69 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 mock
import dns.zone
from designate.agent import service
from designate.backend import agent_backend
from designate.tests import TestCase
from designate.tests.test_agent.test_backends import BackendTestMixin
class Bind9AgentBackendTestCase(TestCase, BackendTestMixin):
def setUp(self):
super(Bind9AgentBackendTestCase, self).setUp()
# Use a random port
self.config(port=0, group='service:agent')
self.backend = agent_backend.get_backend('bind9',
agent_service=service.Service())
self.backend.start()
def tearDown(self):
super(Bind9AgentBackendTestCase, self).tearDown()
self.backend.agent_service.stop()
self.backend.stop()
def test_find_domain_serial(self):
self.backend.find_domain_serial('example.org.')
@mock.patch('designate.utils.execute')
@mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend'
'._sync_domain'))
def test_create_domain(self, execute, sync):
domain = self._create_dnspy_zone('example.org')
self.backend.create_domain(domain)
@mock.patch('designate.utils.execute')
@mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend'
'._sync_domain'))
def test_update_domain(self, execute, sync):
domain = self._create_dnspy_zone('example.org')
self.backend.update_domain(domain)
@mock.patch('designate.utils.execute')
@mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend'
'._sync_domain'))
def test_delete_domain(self, execute, sync):
self.backend.delete_domain('example.org.')
# Helper
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)

View File

@ -0,0 +1,59 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 dns.zone
from designate.agent import service
from designate.backend import agent_backend
from designate.tests import TestCase
from designate.tests.test_agent.test_backends import BackendTestMixin
class FakeAgentBackendTestCase(TestCase, BackendTestMixin):
def setUp(self):
super(FakeAgentBackendTestCase, self).setUp()
# Use a random port
self.config(port=0, group='service:agent')
self.backend = agent_backend.get_backend('fake',
agent_service=service.Service())
self.backend.start()
def tearDown(self):
super(FakeAgentBackendTestCase, self).tearDown()
self.backend.agent_service.stop()
self.backend.stop()
def test_find_domain_serial(self):
self.backend.find_domain_serial('example.org.')
def test_create_domain(self):
domain = self._create_dnspy_zone('example.org')
self.backend.create_domain(domain)
def test_update_domain(self):
domain = self._create_dnspy_zone('example.org')
self.backend.update_domain(domain)
def test_delete_domain(self):
self.backend.delete_domain('example.org.')
# Helper
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)

View File

@ -0,0 +1,181 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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 binascii
import dns
import mock
import designate
from designate.agent import handler
from designate.tests.test_agent import AgentTestCase
class AgentRequestHandlerTest(AgentTestCase):
def setUp(self):
super(AgentRequestHandlerTest, self).setUp()
self.config(allow_notify=["0.0.0.0"],
backend_driver="fake",
group='service:agent')
self.handler = handler.RequestHandler()
self.addr = ["0.0.0.0", 5558]
@mock.patch.object(dns.resolver.Resolver, 'query')
@mock.patch('designate.agent.axfr.AXFR.do_axfr')
def test_receive_notify(self, func, axfrfunc):
"""
Get a NOTIFY and ensure the response is right,
and an AXFR is triggered
"""
payload = ("1a7220000001000000000000076578616d706c6503636f6d000006"
"0001")
# expected response is NOERROR, other fields are
# opcode NOTIFY
# rcode NOERROR
# flags QR AA
# ;QUESTION
# example.com. IN SOA
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("1a72a4000001000000000000076578616d706c650363"
"6f6d0000060001")
request = dns.message.from_wire(binascii.a2b_hex(payload))
request.environ = {'addr': ["0.0.0.0", 1234]}
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
def test_receive_notify_bad_notifier(self):
"""
Get a NOTIFY from a bad master and refuse it
"""
payload = "243520000001000000000000076578616d706c6503636f6d0000060001"
# expected response is REFUSED, other fields are
# opcode NOTIFY
# rcode REFUSED
# flags QR
# ;QUESTION
# example.com. IN SOA
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("2435a0050001000000000000076578616d706c6503636f6d"
"0000060001")
request = dns.message.from_wire(binascii.a2b_hex(payload))
# Bad 'requester'
request.environ = {'addr': ["6.6.6.6", 1234]}
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
@mock.patch.object(dns.resolver.Resolver, 'query')
@mock.patch('designate.agent.axfr.AXFR.do_axfr')
def test_receive_create(self, func, func2):
"""
Get a CREATE and ensure the response is right,
and an AXFR is triggered, and the proper backend
call is made
"""
payload = "735d70000001000000000000076578616d706c6503636f6d00ff02ff00"
# Expected NOERROR other fields are
# opcode 14
# rcode NOERROR
# flags QR AA
# ;QUESTION
# example.com. CLASS65280 TYPE65282
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("735df4000001000000000000076578616d706c6503636f6d"
"00ff02ff00")
request = dns.message.from_wire(binascii.a2b_hex(payload))
request.environ = {'addr': ["0.0.0.0", 1234]}
with mock.patch.object(
designate.backend.agent_backend.impl_fake.FakeBackend,
'find_domain_serial', return_value=None):
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
def test_receive_create_bad_notifier(self):
"""
Get a NOTIFY from a bad master and refuse it
"""
payload = "8dfd70000001000000000000076578616d706c6503636f6d00ff02ff00"
# expected response is REFUSED, other fields are
# opcode 14
# rcode REFUSED
# flags QR
# ;QUESTION
# example.com. CLASS65280 TYPE65282
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("8dfdf0050001000000000000076578616d706c6503636f6d"
"00ff02ff00")
request = dns.message.from_wire(binascii.a2b_hex(payload))
# Bad 'requester'
request.environ = {'addr': ["6.6.6.6", 1234]}
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
@mock.patch('designate.utils.execute')
def test_receive_delete(self, func):
"""
Get a DELETE and ensure the response is right,
and that the proper backend call is made
"""
payload = "3b9970000001000000000000076578616d706c6503636f6d00ff03ff00"
# Expected NOERROR other fields are
# opcode 14
# rcode NOERROR
# flags QR AA
# ;QUESTION
# example.com. CLASS65280 TYPE65283
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("3b99f4000001000000000000076578616d706c6503636f6d"
"00ff03ff00")
request = dns.message.from_wire(binascii.a2b_hex(payload))
request.environ = {'addr': ["0.0.0.0", 1234]}
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
def test_receive_delete_bad_notifier(self):
"""
Get a message with an unsupported OPCODE and make
sure that it is refused
"""
payload = "e6da70000001000000000000076578616d706c6503636f6d00ff03ff00"
# expected response is REFUSED, other fields are
# opcode 14
# rcode REFUSED
# flags QR
# ;QUESTION
# example.com. CLASS65280 TYPE65283
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("e6daf0050001000000000000076578616d706c6503636f6d"
"00ff03ff00")
request = dns.message.from_wire(binascii.a2b_hex(payload))
# Bad 'requester'
request.environ = {'addr': ["6.6.6.6", 1234]}
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))

View File

@ -0,0 +1,30 @@
# Copyright 2014 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.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.
from designate.tests.test_agent import AgentTestCase
class AgentServiceTest(AgentTestCase):
def setUp(self):
super(AgentServiceTest, self).setUp()
# Use a random port
self.config(port=0, group='service:agent')
self.service = self.start_service('agent')
def test_stop(self):
# NOTE: Start is already done by the fixture in start_service()
self.service.stop()

View File

@ -101,6 +101,8 @@ def register_plugin_opts():
# Register Backend Plugin Config Options
plugin.Plugin.register_cfg_opts('designate.backend')
plugin.Plugin.register_extra_cfg_opts('designate.backend')
plugin.Plugin.register_cfg_opts('designate.backend.agent_backend')
plugin.Plugin.register_extra_cfg_opts('designate.backend.agent_backend')
def resource_string(*args):

View File

@ -118,6 +118,19 @@ debug = False
#port = 5354
#tcp_backlog =1 00
#-----------------------
# Agent Service
#-----------------------
[service:agent]
#workers = None
#host = 0.0.0.0
#port = 5358
#tcp_backlog = 100
#allow_notify = 127.0.0.1
#masters = 127.0.0.1:5354
#backend_driver = fake
#-----------------------
# Pool Manager Service
#-----------------------
@ -253,3 +266,13 @@ debug = False
[backend:bind9:6a5032b6-2d96-43ee-b25b-7d784e2bf3b2]
# host = 127.0.0.1
# port = 53
#############################
## Agent Backend Configuration
#############################
[backend:agent:bind9]
#rndc_host = 127.0.0.1
#rndc_port = 953
#rndc_config_file = /etc/rndc.conf
#rndc_key_file = /etc/rndc.key
#zone_file_path = $state_path/zones

View File

@ -37,6 +37,7 @@ console_scripts =
designate-mdns = designate.cmd.mdns:main
designate-pool-manager = designate.cmd.pool_manager:main
designate-sink = designate.cmd.sink:main
designate-agent = designate.cmd.agent:main
designate.api.v1 =
domains = designate.api.v1.domains:blueprint
@ -75,6 +76,10 @@ designate.backend =
#dynect = designate.backend.impl_dynect:DynECTBackend
#ipa = designate.backend.impl_ipa:IPABackend
designate.backend.agent_backend =
bind9 = designate.backend.agent_backend.impl_bind9:Bind9Backend
fake = designate.backend.agent_backend.impl_fake:FakeBackend
designate.network_api =
fake = designate.network_api.fake:FakeNetworkAPI
neutron = designate.network_api.neutron:NeutronNetworkAPI