
Implement the beginning of a structured pattern for Plugin's to provide configuration into Designate. The chosen pattern has been built to support both config generation, and the ability to define "extra" config options which are based on other config options (i.e. the dynamic sections for each pool server). Change-Id: I1889ac1de3dc90b95533bea3f456ea2bf8e9b845
496 lines
18 KiB
Python
496 lines
18 KiB
Python
# Copyright 2012 Hewlett-Packard Development Company, L.P. All Rights Reserved.
|
|
# Copyright 2012 Managed I.T.
|
|
#
|
|
# Author: Patrick Galbraith <patg@hp.com>
|
|
# Author: Kiall Mac Innes <kiall@managedit.ie>
|
|
#
|
|
# 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 base64
|
|
import threading
|
|
|
|
from oslo.config import cfg
|
|
from oslo.db import options
|
|
from oslo.utils import excutils
|
|
from sqlalchemy.sql import select
|
|
|
|
from designate.openstack.common import log as logging
|
|
from designate.i18n import _LC
|
|
from designate import exceptions
|
|
from designate import utils
|
|
from designate.backend import base
|
|
from designate.backend.impl_powerdns import tables
|
|
from designate.sqlalchemy import session
|
|
from designate.sqlalchemy.expressions import InsertFromSelect
|
|
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
CONF = cfg.CONF
|
|
TSIG_SUPPORTED_ALGORITHMS = ['hmac-md5']
|
|
|
|
|
|
def _map_col(keys, col):
|
|
return dict([(keys[i], col[i]) for i in range(len(keys))])
|
|
|
|
|
|
class PowerDNSBackend(base.Backend):
|
|
__plugin_name__ = 'powerdns'
|
|
|
|
@classmethod
|
|
def get_cfg_opts(cls):
|
|
group = cfg.OptGroup(
|
|
name='backend:powerdns', title="Configuration for PowerDNS Backend"
|
|
)
|
|
|
|
opts = [
|
|
cfg.StrOpt('domain-type', default='NATIVE',
|
|
help='PowerDNS Domain Type'),
|
|
cfg.ListOpt('also-notify', default=[],
|
|
help='List of additional IPs to send NOTIFYs to'),
|
|
] + options.database_opts
|
|
|
|
# TODO(kiall):
|
|
# Overide the default DB connection registered above, to avoid name
|
|
# conflicts between the Designate and PowerDNS databases.
|
|
# CONF.set_default('connection',
|
|
# 'sqlite:///$state_path/powerdns.sqlite',
|
|
# group='backend:powerdns')
|
|
return [(group, opts)]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(PowerDNSBackend, self).__init__(*args, **kwargs)
|
|
|
|
self.local_store = threading.local()
|
|
|
|
def start(self):
|
|
super(PowerDNSBackend, self).start()
|
|
|
|
@property
|
|
def session(self):
|
|
# NOTE: This uses a thread local store, allowing each greenthread to
|
|
# have it's own session stored correctly. Without this, each
|
|
# greenthread may end up using a single global session, which
|
|
# leads to bad things happening.
|
|
global LOCAL_STORE
|
|
|
|
if not hasattr(self.local_store, 'session'):
|
|
self.local_store.session = session.get_session(self.name)
|
|
|
|
return self.local_store.session
|
|
|
|
def _create(self, table, values):
|
|
query = table.insert()
|
|
|
|
resultproxy = self.session.execute(query, values)
|
|
|
|
# Refetch the row, for generated columns etc
|
|
query = select([table])\
|
|
.where(table.c.id == resultproxy.inserted_primary_key[0])
|
|
resultproxy = self.session.execute(query)
|
|
|
|
return _map_col(query.columns.keys(), resultproxy.fetchone())
|
|
|
|
def _update(self, table, values, exc_notfound, id_col=None):
|
|
if id_col is None:
|
|
id_col = table.c.id
|
|
|
|
query = table.update()\
|
|
.where(id_col == values[id_col.name])\
|
|
.values(**values)
|
|
|
|
resultproxy = self.session.execute(query)
|
|
|
|
if resultproxy.rowcount != 1:
|
|
raise exc_notfound()
|
|
|
|
# Refetch the row, for generated columns etc
|
|
query = select([table])\
|
|
.where(id_col == values[id_col.name])
|
|
resultproxy = self.session.execute(query)
|
|
|
|
return _map_col(query.columns.keys(), resultproxy.fetchone())
|
|
|
|
def _get(self, table, id_, exc_notfound, id_col=None):
|
|
if id_col is None:
|
|
id_col = table.c.id
|
|
|
|
query = select([table])\
|
|
.where(id_col == id_)
|
|
|
|
resultproxy = self.session.execute(query)
|
|
|
|
results = resultproxy.fetchall()
|
|
|
|
if len(results) != 1:
|
|
raise exc_notfound()
|
|
|
|
# Map col keys to values in result
|
|
return _map_col(query.columns.keys(), results[0])
|
|
|
|
def _delete(self, table, id_, exc_notfound, id_col=None):
|
|
if id_col is None:
|
|
id_col = table.c.id
|
|
|
|
query = table.delete()\
|
|
.where(id_col == id_)
|
|
|
|
resultproxy = self.session.execute(query)
|
|
|
|
if resultproxy.rowcount != 1:
|
|
raise exc_notfound()
|
|
|
|
# TSIG Key Methods
|
|
def create_tsigkey(self, context, tsigkey):
|
|
"""Create a TSIG Key"""
|
|
|
|
if tsigkey['algorithm'] not in TSIG_SUPPORTED_ALGORITHMS:
|
|
raise exceptions.NotImplemented('Unsupported algorithm')
|
|
|
|
values = {
|
|
'designate_id': tsigkey['id'],
|
|
'name': tsigkey['name'],
|
|
'algorithm': tsigkey['algorithm'],
|
|
'secret': base64.b64encode(tsigkey['secret'])
|
|
}
|
|
|
|
self._create(tables.tsigkeys, values)
|
|
|
|
# NOTE(kiall): Prepare and execute query to install this TSIG Key on
|
|
# every domain. We use a manual query here since anything
|
|
# else would be impossibly slow.
|
|
query_select = select([
|
|
tables.domains.c.id,
|
|
"'TSIG-ALLOW-AXFR'",
|
|
"'%s'" % tsigkey['name']]
|
|
)
|
|
|
|
columns = [
|
|
tables.domain_metadata.c.domain_id,
|
|
tables.domain_metadata.c.kind,
|
|
tables.domain_metadata.c.content,
|
|
]
|
|
|
|
query = InsertFromSelect(tables.domain_metadata, query_select,
|
|
columns)
|
|
|
|
# NOTE(kiall): A TX is required for, at the least, SQLite.
|
|
self.session.begin()
|
|
self.session.execute(query)
|
|
self.session.commit()
|
|
|
|
def update_tsigkey(self, context, tsigkey):
|
|
"""Update a TSIG Key"""
|
|
values = self._get(
|
|
tables.tsigkeys,
|
|
tsigkey['id'],
|
|
exceptions.TsigKeyNotFound,
|
|
id_col=tables.tsigkeys.c.designate_id)
|
|
|
|
# Store a copy of the original name..
|
|
original_name = values['name']
|
|
|
|
values.update({
|
|
'name': tsigkey['name'],
|
|
'algorithm': tsigkey['algorithm'],
|
|
'secret': base64.b64encode(tsigkey['secret'])
|
|
})
|
|
|
|
self._update(tables.tsigkeys, values,
|
|
id_col=tables.tsigkeys.c.designate_id,
|
|
exc_notfound=exceptions.TsigKeyNotFound)
|
|
|
|
# If the name changed, Update the necessary DomainMetadata records
|
|
if original_name != tsigkey['name']:
|
|
query = tables.domain_metadata.update()\
|
|
.where(tables.domain_metadata.c.kind == 'TSIG_ALLOW_AXFR')\
|
|
.where(tables.domain_metadata.c.content == original_name)
|
|
|
|
query.values(content=tsigkey['name'])
|
|
self.session.execute(query)
|
|
|
|
def delete_tsigkey(self, context, tsigkey):
|
|
"""Delete a TSIG Key"""
|
|
try:
|
|
# Delete this TSIG Key itself
|
|
self._delete(
|
|
tables.tsigkeys, tsigkey['id'],
|
|
exceptions.TsigKeyNotFound,
|
|
id_col=tables.tsigkeys.c.designate_id)
|
|
except exceptions.TsigKeyNotFound:
|
|
# If the TSIG Key is already gone, that's ok. We're deleting it
|
|
# anyway, so just log and continue.
|
|
LOG.critical(_LC('Attempted to delete a TSIG key which is '
|
|
'not present in the backend. ID: %s') %
|
|
tsigkey['id'])
|
|
return
|
|
|
|
query = tables.domain_metadata.delete()\
|
|
.where(tables.domain_metadata.c.kind == 'TSIG-ALLOW-AXFR')\
|
|
.where(tables.domain_metadata.c.content == tsigkey['name'])
|
|
self.session.execute(query)
|
|
|
|
# Domain Methods
|
|
def create_domain(self, context, domain):
|
|
try:
|
|
self.session.begin()
|
|
servers = self.central_service.find_servers(self.admin_context)
|
|
|
|
domain_values = {
|
|
'designate_id': domain['id'],
|
|
'name': domain['name'].rstrip('.'),
|
|
'master': servers[0]['name'].rstrip('.'),
|
|
'type': CONF['backend:powerdns'].domain_type,
|
|
'account': context.tenant
|
|
}
|
|
|
|
domain_ref = self._create(tables.domains, domain_values)
|
|
|
|
# Install all TSIG Keys on this domain
|
|
query = select([tables.tsigkeys.c.name])
|
|
resultproxy = self.session.execute(query)
|
|
values = [i for i in resultproxy.fetchall()]
|
|
|
|
self._update_domainmetadata(domain_ref['id'], 'TSIG-ALLOW-AXFR',
|
|
values)
|
|
|
|
# Install all Also Notify's on this domain
|
|
self._update_domainmetadata(domain_ref['id'], 'ALSO-NOTIFY',
|
|
CONF['backend:powerdns'].also_notify)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
self.session.rollback()
|
|
else:
|
|
self.session.commit()
|
|
|
|
def update_domain(self, context, domain):
|
|
domain_ref = self._get(tables.domains, domain['id'],
|
|
exceptions.DomainNotFound,
|
|
id_col=tables.domains.c.designate_id)
|
|
|
|
try:
|
|
self.session.begin()
|
|
|
|
# Update the Records TTLs where necessary
|
|
query = tables.records.update()\
|
|
.where(tables.records.c.domain_id == domain_ref['id'])
|
|
query = query.where(tables.records.c.inherit_ttl == True) # noqa\
|
|
query = query.values(ttl=domain['ttl'])
|
|
self.session.execute(query)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
self.session.rollback()
|
|
else:
|
|
self.session.commit()
|
|
|
|
def delete_domain(self, context, domain):
|
|
try:
|
|
domain_ref = self._get(tables.domains, domain['id'],
|
|
exceptions.DomainNotFound,
|
|
id_col=tables.domains.c.designate_id)
|
|
except exceptions.DomainNotFound:
|
|
# If the Domain is already gone, that's ok. We're deleting it
|
|
# anyway, so just log and continue.
|
|
LOG.critical(_LC('Attempted to delete a domain which is '
|
|
'not present in the backend. ID: %s') %
|
|
domain['id'])
|
|
return
|
|
|
|
self._delete(tables.domains, domain['id'],
|
|
exceptions.DomainNotFound,
|
|
id_col=tables.domains.c.designate_id)
|
|
|
|
# Ensure the records are deleted
|
|
query = tables.records.delete()\
|
|
.where(tables.records.c.domain_id == domain_ref['id'])
|
|
self.session.execute(query)
|
|
|
|
# Ensure domainmetadata is deleted
|
|
query = tables.domain_metadata.delete()\
|
|
.where(tables.domain_metadata.c.domain_id == domain_ref['id'])
|
|
self.session.execute(query)
|
|
|
|
# RecordSet Methods
|
|
def create_recordset(self, context, domain, recordset):
|
|
try:
|
|
self.session.begin(subtransactions=True)
|
|
|
|
# Create all the records..
|
|
for record in recordset.records:
|
|
self.create_record(context, domain, recordset, record)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
self.session.rollback()
|
|
else:
|
|
self.session.commit()
|
|
|
|
def update_recordset(self, context, domain, recordset):
|
|
# TODO(kiall): This is a total kludge. Intended as the simplest
|
|
# possible fix for the issue. This needs to be
|
|
# re-implemented correctly.
|
|
try:
|
|
self.session.begin(subtransactions=True)
|
|
|
|
self.delete_recordset(context, domain, recordset)
|
|
self.create_recordset(context, domain, recordset)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
self.session.rollback()
|
|
else:
|
|
self.session.commit()
|
|
|
|
def delete_recordset(self, context, domain, recordset):
|
|
# Ensure records are deleted
|
|
query = tables.records.delete()\
|
|
.where(tables.records.c.designate_recordset_id == recordset['id'])
|
|
self.session.execute(query)
|
|
|
|
# Record Methods
|
|
def create_record(self, context, domain, recordset, record):
|
|
domain_ref = self._get(tables.domains, domain['id'],
|
|
exceptions.DomainNotFound,
|
|
id_col=tables.domains.c.designate_id)
|
|
|
|
# Priority is stored in the data field for MX / SRV
|
|
priority, data = utils.extract_priority_from_data(
|
|
recordset['type'], record)
|
|
|
|
content = self._sanitize_content(recordset['type'], data)
|
|
ttl = domain['ttl'] if recordset['ttl'] is None else recordset['ttl']
|
|
|
|
record_values = {
|
|
'designate_id': record['id'],
|
|
'designate_recordset_id': record['recordset_id'],
|
|
'domain_id': domain_ref['id'],
|
|
'name': recordset['name'].rstrip('.'),
|
|
'type': recordset['type'],
|
|
'content': content,
|
|
'ttl': ttl,
|
|
'inherit_ttl': True if recordset['ttl'] is None else False,
|
|
'prio': priority,
|
|
'auth': self._is_authoritative(domain, recordset, record)
|
|
}
|
|
|
|
self._create(tables.records, record_values)
|
|
|
|
def update_record(self, context, domain, recordset, record):
|
|
record_ref = self._get_record(record['id'])
|
|
|
|
# Priority is stored in the data field for MX / SRV
|
|
priority, data = utils.extract_priority_from_data(
|
|
recordset['type'], record)
|
|
|
|
content = self._sanitize_content(recordset['type'], data)
|
|
ttl = domain['ttl'] if recordset['ttl'] is None else recordset['ttl']
|
|
|
|
record_ref.update({
|
|
'content': content,
|
|
'ttl': ttl,
|
|
'inherit_ttl': True if recordset['ttl'] is None else False,
|
|
'prio': priority,
|
|
'auth': self._is_authoritative(domain, recordset, record)
|
|
})
|
|
|
|
self._update(tables.records, record_ref,
|
|
exc_notfound=exceptions.RecordNotFound)
|
|
|
|
def delete_record(self, context, domain, recordset, record):
|
|
try:
|
|
record_ref = self._get(tables.records, record['id'],
|
|
exceptions.RecordNotFound,
|
|
id_col=tables.records.c.designate_id)
|
|
except exceptions.RecordNotFound:
|
|
# If the Record is already gone, that's ok. We're deleting it
|
|
# anyway, so just log and continue.
|
|
LOG.critical(_LC('Attempted to delete a record which is '
|
|
'not present in the backend. ID: %s') %
|
|
record['id'])
|
|
else:
|
|
self._delete(tables.records, record_ref['id'],
|
|
exceptions.RecordNotFound)
|
|
|
|
# Internal Methods
|
|
def _update_domainmetadata(self, domain_id, kind, values=None,
|
|
delete=True):
|
|
"""Updates a domain's metadata with new values"""
|
|
# Fetch all current metadata of the specified kind
|
|
values = values or []
|
|
|
|
query = select([tables.domain_metadata.c.content])\
|
|
.where(tables.domain_metadata.c.domain_id == domain_id)\
|
|
.where(tables.domain_metadata.c.kind == kind)
|
|
resultproxy = self.session.execute(query)
|
|
results = resultproxy.fetchall()
|
|
|
|
for metadata_id, content in results:
|
|
if content not in values:
|
|
if delete:
|
|
LOG.debug('Deleting stale domain metadata: %r' %
|
|
([domain_id, kind, content],))
|
|
# Delete no longer necessary values
|
|
# We should never get a notfound here, so UnknownFailure is
|
|
# a reasonable choice.
|
|
self._delete(tables.domain_metadata, metadata_id,
|
|
exceptions.UnknownFailure)
|
|
else:
|
|
# Remove pre-existing values from the list of values to insert
|
|
values.remove(content)
|
|
|
|
# Insert new values
|
|
for value in values:
|
|
LOG.debug('Inserting new domain metadata: %r' %
|
|
([domain_id, kind, value],))
|
|
self._create(
|
|
tables.domain_metadata,
|
|
{
|
|
"domain_id": domain_id,
|
|
"kind": kind,
|
|
"content": value
|
|
})
|
|
|
|
def _is_authoritative(self, domain, recordset, record):
|
|
# NOTE(kiall): See http://doc.powerdns.com/dnssec-modes.html
|
|
if recordset['type'] == 'NS' and recordset['name'] != domain['name']:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def _sanitize_content(self, type, content):
|
|
if type in ('CNAME', 'MX', 'SRV', 'NS', 'PTR'):
|
|
return content.rstrip('.')
|
|
|
|
if type in ('TXT', 'SPF'):
|
|
return '"%s"' % content.replace('"', '\\"')
|
|
|
|
return content
|
|
|
|
def _get_record(self, record_id=None, domain=None, type_=None):
|
|
query = select([tables.records])
|
|
|
|
if record_id:
|
|
query = query.where(tables.records.c.designate_id == record_id)
|
|
|
|
if type_:
|
|
query = query.where(tables.records.c.type == type_)
|
|
|
|
if domain:
|
|
query = query.where(tables.records.c.domain_id == domain['id'])
|
|
|
|
resultproxy = self.session.execute(query)
|
|
results = resultproxy.fetchall()
|
|
|
|
if len(results) < 1:
|
|
raise exceptions.RecordNotFound('No record found')
|
|
elif len(results) > 1:
|
|
raise exceptions.RecordNotFound('Too many records found')
|
|
else:
|
|
return _map_col(query.columns.keys(), results[0])
|