External DNS driver reference implementation

An interface with an external DNS service is defined for Neutron. A reference
implementation is also included, based on Designate. The interface and the
driver will enable users to publish in the external DNS service the dns_name
and dns_domain attributes associated with floating ips, ports and networks. As
a consequence, the floating ips and networks api is extended to manage dns_name
and dns_domain attributes. The dns_name attribute was added to ports in a
preceding commit

DocImpact: Introduce config option external_dns_driver to specify a driver
for external dns integration. For more info, see
doc/source/devref/external_dns_integration.rst

APIImpact
Implements: blueprint external-dns-resolution

Change-Id: Ic298ad2558410ab9a614f22e1757d1fc8b22c482
changes/13/212213/39
Miguel Lavalle 7 years ago committed by Miguel Lavalle
parent 424e6e4711
commit 93ac8b3a33
  1. 43
      doc/source/devref/external_dns_integration.rst
  2. 2
      neutron/common/config.py
  3. 328
      neutron/db/dns_db.py
  4. 31
      neutron/db/l3_db.py
  5. 2
      neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD
  6. 87
      neutron/db/migration/alembic_migrations/versions/mitaka/expand/659bf3d90664_add_attributes_to_support_external_dns_integration.py
  7. 92
      neutron/extensions/dns.py
  8. 5
      neutron/plugins/ml2/common/exceptions.py
  9. 328
      neutron/plugins/ml2/extensions/dns_integration.py
  10. 0
      neutron/services/externaldns/__init__.py
  11. 74
      neutron/services/externaldns/driver.py
  12. 0
      neutron/services/externaldns/drivers/__init__.py
  13. 0
      neutron/services/externaldns/drivers/designate/__init__.py
  14. 206
      neutron/services/externaldns/drivers/designate/driver.py
  15. 7
      neutron/services/l3_router/l3_router_plugin.py
  16. 9
      neutron/tests/functional/services/l3_router/test_l3_dvr_router_plugin.py
  17. 14
      neutron/tests/unit/extensions/test_l3.py
  18. 3
      neutron/tests/unit/plugins/ml2/test_plugin.py
  19. 14
      releasenotes/notes/add-integration-with-external-dns-f56ec8a4993b1fc4.yaml
  20. 1
      requirements.txt
  21. 3
      setup.cfg

@ -0,0 +1,43 @@
..
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.
Integration with external DNS services
======================================
Since the Mitaka release, neutron has an interface defined to interact with an
external DNS service. This interface is based on an abstract driver that can be
used as the base class to implement concrete drivers to interact with various
DNS services. The reference implementation of such a driver integrates neutron
with
`OpenStack Designate <http://docs.openstack.org/developer/designate/index.html>`_.
This integration allows users to publish *dns_name* and *dns_domain*
attributes associated with floating IP addresses, ports, and networks in an
external DNS service.
Changes to the neutron API
--------------------------
To support integration with an external DNS service, the *dns_name* and
*dns_domain* attributes were added to floating ips, ports and networks. The
*dns_name* specifies the name to be associated with a corresponding IP address,
both of which will be published to an existing domain with the name
*dns_domain* in the external DNS service.
Specifically, floating ips, ports and networks are extended as follows:
* Floating ips have a *dns_name* and a *dns_domain* attribute.
* Ports have a *dns_name* attribute.
* Networks have a *dns_domain* attributes.

@ -127,6 +127,8 @@ core_opts = [
cfg.StrOpt('dns_domain',
default='openstacklocal',
help=_('Domain to use for building the hostnames')),
cfg.StrOpt('external_dns_driver',
help=_('Driver for external DNS integration.')),
cfg.BoolOpt('dhcp_agent_notification', default=True,
help=_("Allow sending resource operation"
" notification to DHCP agent")),

@ -0,0 +1,328 @@
# Copyright (c) 2016 IBM
# All Rights Reserved.
#
# 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 oslo_log import log as logging
import sqlalchemy as sa
from sqlalchemy import orm
from neutron._i18n import _, _LE
from neutron.api.v2 import attributes
from neutron.common import exceptions as n_exc
from neutron.common import utils
from neutron.db import db_base_plugin_v2
from neutron.db import l3_db
from neutron.db import model_base
from neutron.db import models_v2
from neutron.extensions import dns
from neutron.extensions import l3
from neutron import manager
from neutron.plugins.common import constants as service_constants
from neutron.services.externaldns import driver
LOG = logging.getLogger(__name__)
class NetworkDNSDomain(model_base.BASEV2):
network_id = sa.Column(sa.String(36),
sa.ForeignKey('networks.id', ondelete="CASCADE"),
primary_key=True,
index=True)
dns_domain = sa.Column(sa.String(255),
nullable=False)
# Add a relationship to the Network model in order to instruct
# SQLAlchemy to eagerly load this association
network = orm.relationship(models_v2.Network,
backref=orm.backref("dns_domain",
lazy='joined',
uselist=False,
cascade='delete'))
class FloatingIPDNS(model_base.BASEV2):
__tablename__ = 'floatingipdnses'
floatingip_id = sa.Column(sa.String(36),
sa.ForeignKey('floatingips.id',
ondelete="CASCADE"),
primary_key=True,
index=True)
dns_name = sa.Column(sa.String(255),
nullable=False)
dns_domain = sa.Column(sa.String(255),
nullable=False)
published_dns_name = sa.Column(sa.String(255),
nullable=False)
published_dns_domain = sa.Column(sa.String(255),
nullable=False)
# Add a relationship to the FloatingIP model in order to instruct
# SQLAlchemy to eagerly load this association
floatingip = orm.relationship(l3_db.FloatingIP,
backref=orm.backref("dns",
lazy='joined',
uselist=False,
cascade='delete'))
class PortDNS(model_base.BASEV2):
__tablename__ = 'portdnses'
port_id = sa.Column(sa.String(36),
sa.ForeignKey('ports.id',
ondelete="CASCADE"),
primary_key=True,
index=True)
current_dns_name = sa.Column(sa.String(255),
nullable=False)
current_dns_domain = sa.Column(sa.String(255),
nullable=False)
previous_dns_name = sa.Column(sa.String(255),
nullable=False)
previous_dns_domain = sa.Column(sa.String(255),
nullable=False)
# Add a relationship to the Port model in order to instruct
# SQLAlchemy to eagerly load this association
port = orm.relationship(models_v2.Port,
backref=orm.backref("dns",
lazy='joined',
uselist=False,
cascade='delete'))
class DNSActionsData(object):
def __init__(self, current_dns_name=None, current_dns_domain=None,
previous_dns_name=None, previous_dns_domain=None):
self.current_dns_name = current_dns_name
self.current_dns_domain = current_dns_domain
self.previous_dns_name = previous_dns_name
self.previous_dns_domain = previous_dns_domain
class DNSDbMixin(object):
"""Mixin class to add DNS methods to db_base_plugin_v2."""
_dns_driver = None
@property
def dns_driver(self):
if self._dns_driver:
return self._dns_driver
if not cfg.CONF.external_dns_driver:
return
try:
self._dns_driver = driver.ExternalDNSService.get_instance()
LOG.debug("External DNS driver loaded: %s",
cfg.CONF.external_dns_driver)
return self._dns_driver
except ImportError:
LOG.exception(_LE("ImportError exception occurred while loading "
"the external DNS service driver"))
raise dns.ExternalDNSDriverNotFound(
driver=cfg.CONF.external_dns_driver)
def _extend_floatingip_dict_dns(self, floatingip_res, floatingip_db):
floatingip_res['dns_domain'] = ''
floatingip_res['dns_name'] = ''
if floatingip_db.dns:
floatingip_res['dns_domain'] = floatingip_db.dns['dns_domain']
floatingip_res['dns_name'] = floatingip_db.dns['dns_name']
return floatingip_res
# Register dict extend functions for floating ips
db_base_plugin_v2.NeutronDbPluginV2.register_dict_extend_funcs(
l3.FLOATINGIPS, ['_extend_floatingip_dict_dns'])
def _process_dns_floatingip_create_precommit(self, context,
floatingip_data, req_data):
# expects to be called within a plugin's session
dns_domain = req_data.get(dns.DNSDOMAIN)
if not attributes.is_attr_set(dns_domain):
return
if not self.dns_driver:
return
dns_name = req_data[dns.DNSNAME]
self._validate_floatingip_dns(dns_name, dns_domain)
current_dns_name, current_dns_domain = (
self._get_requested_state_for_external_dns_service_create(
context, floatingip_data, req_data))
dns_actions_data = None
if current_dns_name and current_dns_domain:
context.session.add(FloatingIPDNS(
floatingip_id=floatingip_data['id'],
dns_name=req_data[dns.DNSNAME],
dns_domain=req_data[dns.DNSDOMAIN],
published_dns_name=current_dns_name,
published_dns_domain=current_dns_domain))
dns_actions_data = DNSActionsData(
current_dns_name=current_dns_name,
current_dns_domain=current_dns_domain)
floatingip_data['dns_name'] = dns_name
floatingip_data['dns_domain'] = dns_domain
return dns_actions_data
def _process_dns_floatingip_create_postcommit(self, context,
floatingip_data,
dns_actions_data):
if not dns_actions_data:
return
self._add_ips_to_external_dns_service(
context, dns_actions_data.current_dns_domain,
dns_actions_data.current_dns_name,
[floatingip_data['floating_ip_address']])
def _process_dns_floatingip_update_precommit(self, context,
floatingip_data):
# expects to be called within a plugin's session
plugin = manager.NeutronManager.get_service_plugins().get(
service_constants.L3_ROUTER_NAT)
if not utils.is_extension_supported(plugin, dns.Dns.get_alias()):
return
if not self.dns_driver:
return
dns_data_db = context.session.query(FloatingIPDNS).filter_by(
floatingip_id=floatingip_data['id']).one_or_none()
if dns_data_db and dns_data_db['dns_name']:
# dns_name and dns_domain assigned for floating ip. It doesn't
# matter whether they are defined for internal port
return
current_dns_name, current_dns_domain = (
self._get_requested_state_for_external_dns_service_update(
context, floatingip_data))
if dns_data_db:
if (dns_data_db['published_dns_name'] != current_dns_name or
dns_data_db['published_dns_domain'] != current_dns_domain):
dns_actions_data = DNSActionsData(
previous_dns_name=dns_data_db['published_dns_name'],
previous_dns_domain=dns_data_db['published_dns_domain'])
if current_dns_name and current_dns_domain:
dns_data_db['published_dns_name'] = current_dns_name
dns_data_db['published_dns_domain'] = current_dns_domain
dns_actions_data.current_dns_name = current_dns_name
dns_actions_data.current_dns_domain = current_dns_domain
else:
context.session.delete(dns_data_db)
return dns_actions_data
else:
return
if current_dns_name and current_dns_domain:
context.session.add(FloatingIPDNS(
floatingip_id=floatingip_data['id'],
dns_name='',
dns_domain='',
published_dns_name=current_dns_name,
published_dns_domain=current_dns_domain))
return DNSActionsData(current_dns_name=current_dns_name,
current_dns_domain=current_dns_domain)
def _process_dns_floatingip_update_postcommit(self, context,
floatingip_data,
dns_actions_data):
if not dns_actions_data:
return
if dns_actions_data.previous_dns_name:
self._delete_floatingip_from_external_dns_service(
context, dns_actions_data.previous_dns_domain,
dns_actions_data.previous_dns_name,
[floatingip_data['floating_ip_address']])
if dns_actions_data.current_dns_name:
self._add_ips_to_external_dns_service(
context, dns_actions_data.current_dns_domain,
dns_actions_data.current_dns_name,
[floatingip_data['floating_ip_address']])
def _process_dns_floatingip_delete(self, context, floatingip_data):
plugin = manager.NeutronManager.get_service_plugins().get(
service_constants.L3_ROUTER_NAT)
if not utils.is_extension_supported(plugin, dns.Dns.get_alias()):
return
dns_data_db = context.session.query(FloatingIPDNS).filter_by(
floatingip_id=floatingip_data['id']).one_or_none()
if dns_data_db:
self._delete_floatingip_from_external_dns_service(
context, dns_data_db['published_dns_domain'],
dns_data_db['published_dns_name'],
[floatingip_data['floating_ip_address']])
def _validate_floatingip_dns(self, dns_name, dns_domain):
if dns_domain and not dns_name:
msg = _("dns_domain cannot be specified without a dns_name")
raise n_exc.BadRequest(resource='floatingip', msg=msg)
if dns_name and not dns_domain:
msg = _("dns_name cannot be specified without a dns_domain")
raise n_exc.BadRequest(resource='floatingip', msg=msg)
def _get_internal_port_dns_data(self, context, floatingip_data):
internal_port = context.session.query(models_v2.Port).filter_by(
id=floatingip_data['port_id']).one()
dns_domain = None
if internal_port['dns_name']:
net_dns = context.session.query(NetworkDNSDomain).filter_by(
network_id=internal_port['network_id']).one_or_none()
if net_dns:
dns_domain = net_dns['dns_domain']
return internal_port['dns_name'], dns_domain
def _delete_floatingip_from_external_dns_service(self, context, dns_domain,
dns_name, records):
try:
self.dns_driver.delete_record_set(context, dns_domain, dns_name,
records)
except (dns.DNSDomainNotFound, dns.DuplicateRecordSet) as e:
LOG.exception(_LE("Error deleting Floating IP data from external "
"DNS service. Name: '%(name)s'. Domain: "
"'%(domain)s'. IP addresses '%(ips)s'. DNS "
"service driver message '%(message)s'")
% {"name": dns_name,
"domain": dns_domain,
"message": e.msg,
"ips": ', '.join(records)})
def _get_requested_state_for_external_dns_service_create(self, context,
floatingip_data,
req_data):
fip_dns_name = req_data[dns.DNSNAME]
if fip_dns_name:
return fip_dns_name, req_data[dns.DNSDOMAIN]
if floatingip_data['port_id']:
return self._get_internal_port_dns_data(context, floatingip_data)
return None, None
def _get_requested_state_for_external_dns_service_update(self, context,
floatingip_data):
if floatingip_data['port_id']:
return self._get_internal_port_dns_data(context, floatingip_data)
return None, None
def _add_ips_to_external_dns_service(self, context, dns_domain, dns_name,
records):
try:
self.dns_driver.create_record_set(context, dns_domain, dns_name,
records)
except (dns.DNSDomainNotFound, dns.DuplicateRecordSet) as e:
LOG.exception(_LE("Error publishing floating IP data in external "
"DNS service. Name: '%(name)s'. Domain: "
"'%(domain)s'. DNS service driver message "
"'%(message)s'")
% {"name": dns_name,
"domain": dns_domain,
"message": e.msg})

@ -783,7 +783,8 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
raise l3.FloatingIPNotFound(floatingip_id=id)
return floatingip
def _make_floatingip_dict(self, floatingip, fields=None):
def _make_floatingip_dict(self, floatingip, fields=None,
process_extensions=True):
res = {'id': floatingip['id'],
'tenant_id': floatingip['tenant_id'],
'floating_ip_address': floatingip['floating_ip_address'],
@ -792,6 +793,11 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
'port_id': floatingip['fixed_port_id'],
'fixed_ip_address': floatingip['fixed_ip_address'],
'status': floatingip['status']}
# NOTE(mlavalle): The following assumes this mixin is used in a
# class inheriting from CommonDbMixin, which is true for all existing
# plugins.
if process_extensions:
self._apply_dict_extend_functions(l3.FLOATINGIPS, res, floatingip)
return self._fields(res, fields)
def _get_router_for_floatingip(self, context, internal_port,
@ -1012,8 +1018,15 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
self._update_fip_assoc(context, fip,
floatingip_db, external_port)
context.session.add(floatingip_db)
return self._make_floatingip_dict(floatingip_db)
floatingip_dict = self._make_floatingip_dict(
floatingip_db, process_extensions=False)
dns_actions_data = self._process_dns_floatingip_create_precommit(
context, floatingip_dict, fip)
self._process_dns_floatingip_create_postcommit(context,
floatingip_dict,
dns_actions_data)
return floatingip_dict
def create_floatingip(self, context, floatingip,
initial_status=l3_constants.FLOATINGIP_STATUS_ACTIVE):
@ -1030,7 +1043,13 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
self._update_fip_assoc(context, fip, floatingip_db,
self._core_plugin.get_port(
context.elevated(), fip_port_id))
return old_floatingip, self._make_floatingip_dict(floatingip_db)
floatingip_dict = self._make_floatingip_dict(floatingip_db)
dns_actions_data = self._process_dns_floatingip_update_precommit(
context, floatingip_dict)
self._process_dns_floatingip_update_postcommit(context,
floatingip_dict,
dns_actions_data)
return old_floatingip, floatingip_dict
def _floatingips_to_router_ids(self, floatingips):
return list(set([floatingip['router_id']
@ -1050,6 +1069,8 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
def _delete_floatingip(self, context, id):
floatingip = self._get_floatingip(context, id)
floatingip_dict = self._make_floatingip_dict(floatingip)
self._process_dns_floatingip_delete(context, floatingip_dict)
# Foreign key cascade will take care of the removal of the
# floating IP record once the port is deleted. We can't start
# a transaction first to remove it ourselves because the delete_port
@ -1057,7 +1078,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase):
self._core_plugin.delete_port(context.elevated(),
floatingip['floating_port_id'],
l3_port_check=False)
return self._make_floatingip_dict(floatingip)
return floatingip_dict
def delete_floatingip(self, context, id):
self._delete_floatingip(context, id)

@ -0,0 +1,87 @@
# Copyright 2016 IBM
#
# 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.
#
"""Add tables and attributes to support external DNS integration
Revision ID: 659bf3d90664
Revises: c3a73f615e4
Create Date: 2015-09-11 00:22:47.618593
"""
# revision identifiers, used by Alembic.
revision = '659bf3d90664'
down_revision = 'c3a73f615e4'
from alembic import op
import sqlalchemy as sa
from neutron.extensions import dns
def upgrade():
op.create_table('networkdnsdomains',
sa.Column('network_id',
sa.String(length=36),
nullable=False,
index=True),
sa.Column('dns_domain', sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.ForeignKeyConstraint(['network_id'],
['networks.id'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('network_id'))
op.create_table('floatingipdnses',
sa.Column('floatingip_id',
sa.String(length=36),
nullable=False,
index=True),
sa.Column('dns_name', sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('dns_domain', sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('published_dns_name',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('published_dns_domain',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.ForeignKeyConstraint(['floatingip_id'],
['floatingips.id'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('floatingip_id'))
op.create_table('portdnses',
sa.Column('port_id',
sa.String(length=36),
nullable=False,
index=True),
sa.Column('current_dns_name',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('current_dns_domain',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('previous_dns_name',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.Column('previous_dns_domain',
sa.String(length=dns.FQDN_MAX_LEN),
nullable=False),
sa.ForeignKeyConstraint(['port_id'],
['ports.id'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('port_id'))

@ -22,6 +22,7 @@ from neutron._i18n import _
from neutron.api import extensions
from neutron.api.v2 import attributes as attr
from neutron.common import exceptions as n_exc
from neutron.extensions import l3
DNS_LABEL_MAX_LEN = 63
DNS_LABEL_REGEX = "[a-z0-9-]{1,%d}$" % DNS_LABEL_MAX_LEN
@ -29,6 +30,24 @@ FQDN_MAX_LEN = 255
DNS_DOMAIN_DEFAULT = 'openstacklocal.'
class DNSDomainNotFound(n_exc.NotFound):
message = _("Domain %(dns_domain)s not found in the external DNS service")
class DuplicateRecordSet(n_exc.Conflict):
message = _("Name %(dns_name)s is duplicated in the external DNS service")
class ExternalDNSDriverNotFound(n_exc.NotFound):
message = _("External DNS driver %(driver)s could not be found.")
class InvalidPTRZoneConfiguration(n_exc.Conflict):
message = _("Value of %(parameter)s has to be multiple of %(number)s, "
"with maximum value of %(maximum)s and minimum value of "
"%(minimum)s")
def _validate_dns_name(data, max_len=FQDN_MAX_LEN):
msg = _validate_dns_format(data, max_len)
if msg:
@ -40,6 +59,50 @@ def _validate_dns_name(data, max_len=FQDN_MAX_LEN):
return msg
def _validate_fip_dns_name(data, max_len=FQDN_MAX_LEN):
msg = attr._validate_string(data)
if msg:
return msg
if not data:
return
if data.endswith('.'):
msg = _("'%s' is a FQDN. It should be a relative domain name") % data
return msg
msg = _validate_dns_format(data, max_len)
if msg:
return msg
length = len(data)
if length > max_len - 3:
msg = _("'%(data)s' contains '%(length)s' characters. Adding a "
"domain name will cause it to exceed the maximum length "
"of a FQDN of '%(max_len)s'") % {"data": data,
"length": length,
"max_len": max_len}
return msg
def _validate_dns_domain(data, max_len=FQDN_MAX_LEN):
msg = attr._validate_string(data)
if msg:
return msg
if not data:
return
if not data.endswith('.'):
msg = _("'%s' is not a FQDN") % data
return msg
msg = _validate_dns_format(data, max_len)
if msg:
return msg
length = len(data)
if length > max_len - 2:
msg = _("'%(data)s' contains '%(length)s' characters. Adding a "
"sub-domain will cause it to exceed the maximum length of a "
"FQDN of '%(max_len)s'") % {"data": data,
"length": length,
"max_len": max_len}
return msg
def _validate_dns_format(data, max_len=FQDN_MAX_LEN):
# NOTE: An individual name regex instead of an entire FQDN was used
# because its easier to make correct. The logic should validate that the
@ -133,11 +196,13 @@ def convert_to_lowercase(data):
raise n_exc.InvalidInput(error_message=msg)
attr.validators['type:dns_name'] = (
_validate_dns_name)
attr.validators['type:dns_name'] = (_validate_dns_name)
attr.validators['type:fip_dns_name'] = (_validate_fip_dns_name)
attr.validators['type:dns_domain'] = (_validate_dns_domain)
DNSNAME = 'dns_name'
DNSDOMAIN = 'dns_domain'
DNSASSIGNMENT = 'dns_assignment'
EXTENDED_ATTRIBUTES_2_0 = {
'ports': {
@ -148,7 +213,26 @@ EXTENDED_ATTRIBUTES_2_0 = {
'is_visible': True},
DNSASSIGNMENT: {'allow_post': False, 'allow_put': False,
'is_visible': True},
}
},
l3.FLOATINGIPS: {
DNSNAME: {'allow_post': True, 'allow_put': False,
'default': '',
'convert_to': convert_to_lowercase,
'validate': {'type:fip_dns_name': FQDN_MAX_LEN},
'is_visible': True},
DNSDOMAIN: {'allow_post': True, 'allow_put': False,
'default': '',
'convert_to': convert_to_lowercase,
'validate': {'type:dns_domain': FQDN_MAX_LEN},
'is_visible': True},
},
attr.NETWORKS: {
DNSDOMAIN: {'allow_post': True, 'allow_put': True,
'default': '',
'convert_to': convert_to_lowercase,
'validate': {'type:dns_domain': FQDN_MAX_LEN},
'is_visible': True},
},
}
@ -165,7 +249,7 @@ class Dns(extensions.ExtensionDescriptor):
@classmethod
def get_description(cls):
return "Provides integration with internal DNS."
return "Provides integration with DNS."
@classmethod
def get_updated(cls):

@ -33,3 +33,8 @@ class ExtensionDriverNotFound(exceptions.InvalidConfigurationOption):
"""Required extension driver not found in ML2 config."""
message = _("Extension driver %(driver)s required for "
"service plugin %(service_plugin)s not found.")
class UnknownNetworkType(exceptions.NeutronException):
"""Network with unknown type."""
message = _("Unknown network type %(network_type)s.")

@ -0,0 +1,328 @@
# Copyright (c) 2016 IBM
# All Rights Reserved.
#
# 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 oslo_log import log as logging
from neutron._i18n import _LE, _LI
from neutron.api.v2 import attributes
from neutron.callbacks import events
from neutron.callbacks import registry
from neutron.callbacks import resources
from neutron.db import dns_db
from neutron.db import models_v2
from neutron.extensions import dns
from neutron import manager
from neutron.plugins.common import utils as plugin_utils
from neutron.plugins.ml2 import db
from neutron.plugins.ml2 import driver_api as api
from neutron.services.externaldns import driver
LOG = logging.getLogger(__name__)
class DNSExtensionDriver(api.ExtensionDriver):
_supported_extension_alias = 'dns-integration'
@property
def extension_alias(self):
return self._supported_extension_alias
def process_create_network(self, plugin_context, request_data, db_data):
dns_domain = request_data.get(dns.DNSDOMAIN)
if not attributes.is_attr_set(dns_domain):
return
if dns_domain:
plugin_context.session.add(dns_db.NetworkDNSDomain(
network_id=db_data['id'], dns_domain=dns_domain))
db_data[dns.DNSDOMAIN] = dns_domain
def process_update_network(self, plugin_context, request_data, db_data):
new_value = request_data.get(dns.DNSDOMAIN)
if not attributes.is_attr_set(new_value):
return
current_dns_domain = db_data.get(dns.DNSDOMAIN)
if current_dns_domain == new_value:
return
net_id = db_data['id']
if current_dns_domain:
net_dns_domain = plugin_context.session.query(
dns_db.NetworkDNSDomain).filter_by(network_id=net_id).one()
if new_value:
net_dns_domain['dns_domain'] = new_value
db_data[dns.DNSDOMAIN] = new_value
else:
plugin_context.session.delete(net_dns_domain)
db_data[dns.DNSDOMAIN] = ''
elif new_value:
plugin_context.session.add(dns_db.NetworkDNSDomain(
network_id=net_id, dns_domain=new_value))
db_data[dns.DNSDOMAIN] = new_value
def process_create_port(self, plugin_context, request_data, db_data):
if not request_data[dns.DNSNAME]:
return
network = self._get_network(plugin_context, db_data['network_id'])
if not network[dns.DNSDOMAIN]:
return
if self.external_dns_not_needed(plugin_context, network):
return
plugin_context.session.add(dns_db.PortDNS(
port_id=db_data['id'],
current_dns_name=request_data[dns.DNSNAME],
current_dns_domain=network[dns.DNSDOMAIN],
previous_dns_name='', previous_dns_domain=''))
def process_update_port(self, plugin_context, request_data, db_data):
dns_name = request_data.get(dns.DNSNAME)
if dns_name is None:
return
network = self._get_network(plugin_context, db_data['network_id'])
if not network[dns.DNSDOMAIN]:
return
if self.external_dns_not_needed(plugin_context, network):
return
dns_domain = network[dns.DNSDOMAIN]
dns_data_db = plugin_context.session.query(dns_db.PortDNS).filter_by(
port_id=db_data['id']).one_or_none()
if dns_data_db:
if dns_name:
if dns_data_db['current_dns_name'] != dns_name:
dns_data_db['previous_dns_name'] = (dns_data_db[
'current_dns_name'])
dns_data_db['previous_dns_domain'] = (dns_data_db[
'current_dns_domain'])
dns_data_db['current_dns_name'] = dns_name
dns_data_db['current_dns_domain'] = dns_domain
return
if dns_data_db['current_dns_name']:
dns_data_db['previous_dns_name'] = (dns_data_db[
'current_dns_name'])
dns_data_db['previous_dns_domain'] = (dns_data_db[
'current_dns_domain'])
dns_data_db['current_dns_name'] = ''
dns_data_db['current_dns_domain'] = ''
return
if dns_name:
plugin_context.session.add(dns_db.PortDNS(
port_id=db_data['id'],
current_dns_name=dns_name,
current_dns_domain=dns_domain,
previous_dns_name='', previous_dns_domain=''))
def external_dns_not_needed(self, context, network):
"""Decide if ports in network need to be sent to the DNS service.
:param context: plugin request context
:param network: network dictionary
:return True or False
"""
pass
def extend_network_dict(self, session, db_data, response_data):
response_data[dns.DNSDOMAIN] = ''
if db_data.dns_domain:
response_data[dns.DNSDOMAIN] = db_data.dns_domain[dns.DNSDOMAIN]
return response_data
def extend_port_dict(self, session, db_data, response_data):
response_data[dns.DNSNAME] = db_data[dns.DNSNAME]
return response_data
def _get_network(self, context, network_id):
plugin = manager.NeutronManager.get_plugin()
return plugin.get_network(context, network_id)
class DNSExtensionDriverML2(DNSExtensionDriver):
def initialize(self):
LOG.info(_LI("DNSExtensionDriverML2 initialization complete"))
def _is_tunnel_tenant_network(self, provider_net):
if provider_net['network_type'] == 'geneve':
tunnel_ranges = cfg.CONF.ml2_type_geneve.vni_ranges
elif provider_net['network_type'] == 'vxlan':
tunnel_ranges = cfg.CONF.ml2_type_vxlan.vni_ranges
else:
tunnel_ranges = cfg.CONF.ml2_type_gre.tunnel_id_ranges
segmentation_id = int(provider_net['segmentation_id'])
for entry in tunnel_ranges:
entry = entry.strip()
tun_min, tun_max = entry.split(':')
tun_min = tun_min.strip()
tun_max = tun_max.strip()
return int(tun_min) <= segmentation_id <= int(tun_max)
def _is_vlan_tenant_network(self, provider_net):
network_vlan_ranges = plugin_utils.parse_network_vlan_ranges(
cfg.CONF.ml2_type_vlan.network_vlan_ranges)
vlan_ranges = network_vlan_ranges[provider_net['physical_network']]
if not vlan_ranges:
return False
segmentation_id = int(provider_net['segmentation_id'])
for vlan_range in vlan_ranges:
if vlan_range[0] <= segmentation_id <= vlan_range[1]:
return True
def external_dns_not_needed(self, context, network):
if not DNS_DRIVER:
return True
if network['router:external']:
return True
segments = db.get_network_segments(context.session, network['id'])
if len(segments) > 1:
return False
provider_net = segments[0]
if provider_net['network_type'] == 'local':
return True
if provider_net['network_type'] == 'flat':
return False
if provider_net['network_type'] == 'vlan':
return self._is_vlan_tenant_network(provider_net)
if provider_net['network_type'] in ['gre', 'vxlan', 'geneve']:
return self._is_tunnel_tenant_network(provider_net)
return True
DNS_DRIVER = None
def _get_dns_driver():
global DNS_DRIVER
if DNS_DRIVER:
return DNS_DRIVER
if not cfg.CONF.external_dns_driver:
return
try:
DNS_DRIVER = driver.ExternalDNSService.get_instance()
LOG.debug("External DNS driver loaded: %s",
cfg.CONF.external_dns_driver)
return DNS_DRIVER
except ImportError:
LOG.exception(_LE("ImportError exception occurred while loading "
"the external DNS service driver"))
raise dns.ExternalDNSDriverNotFound(
driver=cfg.CONF.external_dns_driver)
def _create_port_in_external_dns_service(resource, event, trigger, **kwargs):
dns_driver = _get_dns_driver()
if not dns_driver:
return
context = kwargs['context']
port = kwargs['port']
dns_data_db = context.session.query(dns_db.PortDNS).filter_by(
port_id=port['id']).one_or_none()
if not dns_data_db:
return
records = [ip['ip_address'] for ip in port['fixed_ips']]
_send_data_to_external_dns_service(context, dns_driver,
dns_data_db['current_dns_domain'],
dns_data_db['current_dns_name'],
records)
def _send_data_to_external_dns_service(context, dns_driver, dns_domain,
dns_name, records):
try:
dns_driver.create_record_set(context, dns_domain, dns_name, records)
except (dns.DNSDomainNotFound, dns.DuplicateRecordSet) as e:
LOG.exception(_LE("Error publishing port data in external DNS "
"service. Name: '%(name)s'. Domain: '%(domain)s'. "
"DNS service driver message '%(message)s'")
% {"name": dns_name,
"domain": dns_domain,
"message": e.msg})
def _remove_data_from_external_dns_service(context, dns_driver, dns_domain,
dns_name, records):
try:
dns_driver.delete_record_set(context, dns_domain, dns_name, records)
except (dns.DNSDomainNotFound, dns.DuplicateRecordSet) as e:
LOG.exception(_LE("Error deleting port data from external DNS "
"service. Name: '%(name)s'. Domain: '%(domain)s'. "
"IP addresses '%(ips)s'. DNS service driver message "
"'%(message)s'")
% {"name": dns_name,
"domain": dns_domain,
"message": e.msg,
"ips": ', '.join(records)})
def _update_port_in_external_dns_service(resource, event, trigger, **kwargs):
dns_driver = _get_dns_driver()
if not dns_driver:
return
context = kwargs['context']
updated_port = kwargs['port']
original_port = kwargs.get('original_port')
if not original_port:
return
if updated_port[dns.DNSNAME] == original_port[dns.DNSNAME]:
return
dns_data_db = context.session.query(dns_db.PortDNS).filter_by(
port_id=updated_port['id']).one_or_none()
if not dns_data_db:
return
if dns_data_db['previous_dns_name']:
records = [ip['ip_address'] for ip in original_port['fixed_ips']]
_remove_data_from_external_dns_service(
context, dns_driver, dns_data_db['previous_dns_domain'],
dns_data_db['previous_dns_name'], records)
if dns_data_db['current_dns_name']:
records = [ip['ip_address'] for ip in updated_port['fixed_ips']]
_send_data_to_external_dns_service(context, dns_driver,
dns_data_db['current_dns_domain'],
dns_data_db['current_dns_name'],
records)
def _delete_port_in_external_dns_service(resource, event, trigger, **kwargs):
dns_driver = _get_dns_driver()
if not dns_driver:
return
context = kwargs['context']
port_id = kwargs['port_id']
dns_data_db = context.session.query(dns_db.PortDNS).filter_by(
port_id=port_id).one_or_none()
if not dns_data_db:
return
if dns_data_db['current_dns_name']:
ip_allocations = context.session.query(
models_v2.IPAllocation).filter_by(port_id=port_id).all()
records = [alloc['ip_address'] for alloc in ip_allocations]
_remove_data_from_external_dns_service(
context, dns_driver, dns_data_db['current_dns_domain'],
dns_data_db['current_dns_name'], records)
def subscribe():
registry.subscribe(
_create_port_in_external_dns_service, resources.PORT,
events.AFTER_CREATE)
registry.subscribe(
_update_port_in_external_dns_service, resources.PORT,
events.AFTER_UPDATE)
registry.subscribe(
_delete_port_in_external_dns_service, resources.PORT,
events.BEFORE_DELETE)
subscribe()

@ -0,0 +1,74 @@
# Copyright (c) 2016 IBM
# All Rights Reserved.
#
# 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_config import cfg
from oslo_log import log
import six
from neutron import manager
LOG = log.getLogger(__name__)
@six.add_metaclass(abc.ABCMeta)
class ExternalDNSService(object):
"""Interface definition for an external dns service driver."""
def __init__(self):
"""Initialize external dns service driver."""
@classmethod
def get_instance(cls):
"""Return an instance of the configured external DNS driver."""
external_dns_driver_name = cfg.CONF.external_dns_driver
mgr = manager.NeutronManager
LOG.debug("Loading external dns driver: %s", external_dns_driver_name)
driver_class = mgr.load_class_for_provider(
'neutron.services.external_dns_drivers', external_dns_driver_name)
return driver_class()
@abc.abstractmethod
def create_record_set(self, context, dns_domain, dns_name, records):
"""Create a record set in the specified zone.
:param context: neutron api request context
:type context: neutron.context.Context
:param dns_domain: the dns_domain where the record set will be created
:type dns_domain: String
:param dns_name: the name associated with the record set
:type dns_name: String
:param records: the records in the set
:type records: List of Strings
:raises: neutron.extensions.dns.DNSDomainNotFound
neutron.extensions.dns.DuplicateRecordSet
"""
@abc.abstractmethod
def delete_record_set(self, context, dns_domain, dns_name, records):
"""Delete a record set in the specified zone.
:param context: neutron api request context
:type context: neutron.context.Context
:param dns_domain: the dns_domain from which the record set will be
deleted
:type dns_domain: String
:param dns_name: the dns_name associated with the record set to be
deleted
:type dns_name: String
:param records: the records in the set to be deleted
:type records: List of Strings
"""

@ -0,0 +1,206 @@
# Copyright (c) 2016 IBM
# All Rights Reserved.
#
# 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 netaddr
from designateclient import exceptions as d_exc
from designateclient.v2 import client as d_client
from keystoneclient.auth.identity.generic import password
from keystoneclient.auth import token_endpoint
from keystoneclient import session
from oslo_config import cfg
from oslo_log import log
from neutron._i18n import _
from neutron.extensions import dns
from neutron.services.externaldns import driver
IPV4_PTR_ZONE_PREFIX_MIN_SIZE = 8
IPV4_PTR_ZONE_PREFIX_MAX_SIZE = 24
IPV6_PTR_ZONE_PREFIX_MIN_SIZE = 4
IPV6_PTR_ZONE_PREFIX_MAX_SIZE = 124
LOG = log.getLogger(__name__)
_SESSION = None
designate_opts = [
cfg.StrOpt('url',
help=_('URL for connecting to designate')),
cfg.StrOpt('admin_username',
help=_('Username for connecting to designate in admin '
'context')),
cfg.StrOpt('admin_password',
help=_('Password for connecting to designate in admin '
'context'),
secret=True),
cfg.StrOpt('admin_tenant_id',
help=_('Tenant id for connecting to designate in admin '
'context')),
cfg.StrOpt('admin_tenant_name',
help=_('Tenant name for connecting to designate in admin '
'context')),
cfg.StrOpt('admin_auth_url',
help=_('Authorization URL for connecting to designate in admin '
'context')),
cfg.BoolOpt('allow_reverse_dns_lookup', default=True,
help=_('Allow the creation of PTR records')),
cfg.IntOpt('ipv4_ptr_zone_prefix_size', default=24,
help=_('Number of bits in an ipv4 PTR zone that will be considered '
'network prefix. It has to align to byte boundary. Minimum '
'value is 8. Maximum value is 24. As a consequence, range '
'of values is 8, 16 and 24')),
cfg.IntOpt('ipv6_ptr_zone_prefix_size', default=120,
help=_('Number of bits in an ipv6 PTR zone that will be considered '
'network prefix. It has to align to nyble boundary. Minimum '
'value is 4. Maximum value is 124. As a consequence, range '
'of values is 4, 8, 12, 16,..., 124')),
cfg.StrOpt('ptr_zone_email', default='',
help=_('The email address to be used when creating PTR zones. '
'If not specified, the email address will be '
'admin@<dns_domain>')),
]
DESIGNATE_GROUP = 'designate'
CONF = cfg.CONF
CONF.register_opts(designate_opts, DESIGNATE_GROUP)
def get_clients(context):
global _SESSION
if not _SESSION:
_SESSION = session.Session()
auth = token_endpoint.Token(CONF.designate.url, context.auth_token)
client = d_client.Client(session=_SESSION, auth=auth)
admin_auth = password.Password(
auth_url=CONF.designate.admin_auth_url,
username=CONF.designate.admin_username,
password=CONF.designate.admin_password,
tenant_name=CONF.designate.admin_tenant_name,
tenant_id=CONF.designate.admin_tenant_id)
admin_client = d_client.Client(session=_SESSION, auth=admin_auth)
return client, admin_client
class Designate(driver.ExternalDNSService):
"""Driver for Designate."""
def __init__(self):
ipv4_ptr_zone_size = CONF.designate.ipv4_ptr_zone_prefix_size
ipv6_ptr_zone_size = CONF.designate.ipv6_ptr_zone_prefix_size
if (ipv4_ptr_zone_size < IPV4_PTR_ZONE_PREFIX_MIN_SIZE or
ipv4_ptr_zone_size > IPV4_PTR_ZONE_PREFIX_MAX_SIZE or
(ipv4_ptr_zone_size % 8) != 0):
raise dns.InvalidPTRZoneConfiguration(
parameter='ipv4_ptr_zone_size', number='8',
maximum=str(IPV4_PTR_ZONE_PREFIX_MAX_SIZE),
minimum=str(IPV4_PTR_ZONE_PREFIX_MIN_SIZE))
if (ipv6_ptr_zone_size < IPV6_PTR_ZONE_PREFIX_MIN_SIZE or
ipv6_ptr_zone_size > IPV6_PTR_ZONE_PREFIX_MAX_SIZE or
(ipv6_ptr_zone_size % 4) != 0):
raise dns.InvalidPTRZoneConfiguration(
parameter='ipv6_ptr_zone_size', number='4',
maximum=str(IPV6_PTR_ZONE_PREFIX_MAX_SIZE),
minimum=str(IPV6_PTR_ZONE_PREFIX_MIN_SIZE))
def create_record_set(self, context, dns_domain, dns_name, records):
designate, designate_admin = get_clients(context)
v4, v6 = self._classify_records(records)
try:
if v4:
designate.recordsets.create(dns_domain, dns_name, 'A', v4)
if v6:
designate.recordsets.create(dns_domain, dns_name, 'AAAA', v6)
except d_exc.NotFound:
raise dns.DNSDomainNotFound(dns_domain=dns_domain)
except d_exc.Conflict:
raise dns.DuplicateRecordSet(dns_name=dns_name)
if not CONF.designate.allow_reverse_dns_lookup:
return
# Set up the PTR records
recordset_name = '%s.%s' % (dns_name, dns_domain)
ptr_zone_email = 'admin@%s' % dns_domain[:-1]
if CONF.designate.ptr_zone_email:
ptr_zone_email = CONF.designate.ptr_zone_email
for record in records:
in_addr_name = netaddr.IPAddress(record).reverse_dns
in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name)
in_addr_zone_description = (
'An %s zone for reverse lookups set up by Neutron.' %
'.'.join(in_addr_name.split('.')[-3:]))
try:
# Since we don't delete in-addr zones, assume it already
# exists. If it doesn't, create it
designate_admin.recordsets.create(in_addr_zone_name,
in_addr_name, 'PTR',
[recordset_name])
except d_exc.NotFound:
designate_admin.zones.create(
in_addr_zone_name, email=ptr_zone_email,
description=in_addr_zone_description)
designate_admin.recordsets.create(in_addr_zone_name,
in_addr_name, 'PTR',
[recordset_name])
def _classify_records(self, records):
v4 = []
v6 = []
for record in records:
if netaddr.IPAddress(record).version == 4:
v4.append(record)
else:
v6.append(record)
return v4, v6
def _get_in_addr_zone_name(self, in_addr_name):
units = self._get_bytes_or_nybles_to_skip(in_addr_name)
return '.'.join(in_addr_name.split('.')[units:])
def _get_bytes_or_nybles_to_skip(self, in_addr_name):
if 'in-addr.arpa' in in_addr_name:
return (32 - CONF.designate.ipv4_ptr_zone_prefix_size) / 8
return (128 - CONF.designate.ipv6_ptr_zone_prefix_size) / 4
def delete_record_set(self, context, dns_domain, dns_name, records):
designate, designate_admin = get_clients(context)
ids_to_delete = self._get_ids_ips_to_delete(
dns_domain, '%s.%s' % (dns_name, dns_domain), records, designate)
for _id in ids_to_delete:
designate.recordsets.delete(dns_domain, _id)
if not CONF.designate.allow_reverse_dns_lookup:
return
for record in records:
in_addr_name = netaddr.IPAddress(record).reverse_dns
in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name)
designate_admin.recordsets.delete(in_addr_zone_name, in_addr_name)
def _get_ids_ips_to_delete(self, dns_domain, name, records,
designate_client):
try:
recordsets = designate_client.recordsets.list(
dns_domain, criterion={"name": "%s" % name})
except d_exc.NotFound:
raise dns.DNSDomainNotFound(dns_domain=dns_domain)
ids = [rec['id'] for rec in recordsets]
ips = [ip for rec in recordsets for ip in rec['records']]
if set(ips) != set(records):
raise dns.DuplicateRecordSet(dns_name=name)
return ids

@ -23,6 +23,7 @@ from neutron.common import constants as n_const
from neutron.common import rpc as n_rpc
from neutron.common import topics
from neutron.db import common_db_mixin
from neutron.db import dns_db
from neutron.db import extraroute_db
from neutron.db import l3_db
from neutron.db import l3_dvrscheduler_db