From 93ac8b3a3318c0b762d399fa78b8ce981d44b3d9 Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Wed, 12 Aug 2015 17:45:38 -0500 Subject: [PATCH] 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 --- .../devref/external_dns_integration.rst | 43 +++ neutron/common/config.py | 2 + neutron/db/dns_db.py | 328 ++++++++++++++++++ neutron/db/l3_db.py | 29 +- .../alembic_migrations/versions/EXPAND_HEAD | 2 +- ...tes_to_support_external_dns_integration.py | 87 +++++ neutron/extensions/dns.py | 92 ++++- neutron/plugins/ml2/common/exceptions.py | 5 + .../plugins/ml2/extensions/dns_integration.py | 328 ++++++++++++++++++ neutron/services/externaldns/__init__.py | 0 neutron/services/externaldns/driver.py | 74 ++++ .../services/externaldns/drivers/__init__.py | 0 .../externaldns/drivers/designate/__init__.py | 0 .../externaldns/drivers/designate/driver.py | 206 +++++++++++ .../services/l3_router/l3_router_plugin.py | 7 +- .../l3_router/test_l3_dvr_router_plugin.py | 9 +- neutron/tests/unit/extensions/test_l3.py | 14 +- neutron/tests/unit/plugins/ml2/test_plugin.py | 3 +- ...on-with-external-dns-f56ec8a4993b1fc4.yaml | 14 + requirements.txt | 1 + setup.cfg | 3 + 21 files changed, 1228 insertions(+), 19 deletions(-) create mode 100644 doc/source/devref/external_dns_integration.rst create mode 100644 neutron/db/dns_db.py create mode 100644 neutron/db/migration/alembic_migrations/versions/mitaka/expand/659bf3d90664_add_attributes_to_support_external_dns_integration.py create mode 100644 neutron/plugins/ml2/extensions/dns_integration.py create mode 100644 neutron/services/externaldns/__init__.py create mode 100644 neutron/services/externaldns/driver.py create mode 100644 neutron/services/externaldns/drivers/__init__.py create mode 100644 neutron/services/externaldns/drivers/designate/__init__.py create mode 100644 neutron/services/externaldns/drivers/designate/driver.py create mode 100644 releasenotes/notes/add-integration-with-external-dns-f56ec8a4993b1fc4.yaml diff --git a/doc/source/devref/external_dns_integration.rst b/doc/source/devref/external_dns_integration.rst new file mode 100644 index 00000000000..0e71b83bf70 --- /dev/null +++ b/doc/source/devref/external_dns_integration.rst @@ -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 `_. + +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. diff --git a/neutron/common/config.py b/neutron/common/config.py index c2bba5a87c6..f3ca14a9166 100644 --- a/neutron/common/config.py +++ b/neutron/common/config.py @@ -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")), diff --git a/neutron/db/dns_db.py b/neutron/db/dns_db.py new file mode 100644 index 00000000000..f62bc98ee9b --- /dev/null +++ b/neutron/db/dns_db.py @@ -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}) diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index 23ea98c3746..ef5206f318e 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -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) + floatingip_dict = self._make_floatingip_dict( + floatingip_db, process_extensions=False) + dns_actions_data = self._process_dns_floatingip_create_precommit( + context, floatingip_dict, fip) - return self._make_floatingip_dict(floatingip_db) + 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) diff --git a/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD b/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD index d6602ab23ba..149c2aa76e0 100644 --- a/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD +++ b/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD @@ -1 +1 @@ -c3a73f615e4 +659bf3d90664 diff --git a/neutron/db/migration/alembic_migrations/versions/mitaka/expand/659bf3d90664_add_attributes_to_support_external_dns_integration.py b/neutron/db/migration/alembic_migrations/versions/mitaka/expand/659bf3d90664_add_attributes_to_support_external_dns_integration.py new file mode 100644 index 00000000000..407ea483dbc --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/mitaka/expand/659bf3d90664_add_attributes_to_support_external_dns_integration.py @@ -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')) diff --git a/neutron/extensions/dns.py b/neutron/extensions/dns.py index 93e1ffbbb67..559751f32d2 100644 --- a/neutron/extensions/dns.py +++ b/neutron/extensions/dns.py @@ -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): diff --git a/neutron/plugins/ml2/common/exceptions.py b/neutron/plugins/ml2/common/exceptions.py index 349a80e674f..7f0f7525fab 100644 --- a/neutron/plugins/ml2/common/exceptions.py +++ b/neutron/plugins/ml2/common/exceptions.py @@ -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.") diff --git a/neutron/plugins/ml2/extensions/dns_integration.py b/neutron/plugins/ml2/extensions/dns_integration.py new file mode 100644 index 00000000000..224bb95d915 --- /dev/null +++ b/neutron/plugins/ml2/extensions/dns_integration.py @@ -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() diff --git a/neutron/services/externaldns/__init__.py b/neutron/services/externaldns/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/services/externaldns/driver.py b/neutron/services/externaldns/driver.py new file mode 100644 index 00000000000..e25de81e038 --- /dev/null +++ b/neutron/services/externaldns/driver.py @@ -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 + """ diff --git a/neutron/services/externaldns/drivers/__init__.py b/neutron/services/externaldns/drivers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/services/externaldns/drivers/designate/__init__.py b/neutron/services/externaldns/drivers/designate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/services/externaldns/drivers/designate/driver.py b/neutron/services/externaldns/drivers/designate/driver.py new file mode 100644 index 00000000000..599b3fcef99 --- /dev/null +++ b/neutron/services/externaldns/drivers/designate/driver.py @@ -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@')), +] + +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 diff --git a/neutron/services/l3_router/l3_router_plugin.py b/neutron/services/l3_router/l3_router_plugin.py index 558d6da137f..9dfd16986d7 100644 --- a/neutron/services/l3_router/l3_router_plugin.py +++ b/neutron/services/l3_router/l3_router_plugin.py @@ -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 @@ -40,7 +41,8 @@ class L3RouterPlugin(service_base.ServicePluginBase, l3_hamode_db.L3_HA_NAT_db_mixin, l3_gwmode_db.L3_NAT_db_mixin, l3_dvrscheduler_db.L3_DVRsch_db_mixin, - l3_hascheduler_db.L3_HA_scheduler_db_mixin): + l3_hascheduler_db.L3_HA_scheduler_db_mixin, + dns_db.DNSDbMixin): """Implementation of the Neutron L3 Router Service Plugin. @@ -53,7 +55,8 @@ class L3RouterPlugin(service_base.ServicePluginBase, """ supported_extension_aliases = ["dvr", "router", "ext-gw-mode", "extraroute", "l3_agent_scheduler", - "l3-ha", "router_availability_zone"] + "l3-ha", "router_availability_zone", + "dns-integration"] @resource_registry.tracked_resources(router=l3_db.Router, floatingip=l3_db.FloatingIP) diff --git a/neutron/tests/functional/services/l3_router/test_l3_dvr_router_plugin.py b/neutron/tests/functional/services/l3_router/test_l3_dvr_router_plugin.py index 0c91a3fc0c6..84c3b327b97 100644 --- a/neutron/tests/functional/services/l3_router/test_l3_dvr_router_plugin.py +++ b/neutron/tests/functional/services/l3_router/test_l3_dvr_router_plugin.py @@ -242,7 +242,8 @@ class L3DvrTestCase(ml2_test_base.ML2TestFramework): floating_ip = {'floating_network_id': ext_net_id, 'router_id': router['id'], 'port_id': int_port['port']['id'], - 'tenant_id': int_port['port']['tenant_id']} + 'tenant_id': int_port['port']['tenant_id'], + 'dns_name': '', 'dns_domain': ''} with mock.patch.object( self.l3_plugin, '_l3_rpc_notifier') as l3_notif: self.l3_plugin.create_floatingip( @@ -307,7 +308,8 @@ class L3DvrTestCase(ml2_test_base.ML2TestFramework): floating_ip = {'floating_network_id': ext_net_id, 'router_id': router1['id'], 'port_id': int_port1['port']['id'], - 'tenant_id': int_port1['port']['tenant_id']} + 'tenant_id': int_port1['port']['tenant_id'], + 'dns_name': '', 'dns_domain': ''} floating_ip = self.l3_plugin.create_floatingip( self.context, {'floatingip': floating_ip}) @@ -365,7 +367,8 @@ class L3DvrTestCase(ml2_test_base.ML2TestFramework): floating_ip = {'floating_network_id': ext_net_id, 'router_id': router['id'], 'port_id': int_port['port']['id'], - 'tenant_id': int_port['port']['tenant_id']} + 'tenant_id': int_port['port']['tenant_id'], + 'dns_name': '', 'dns_domain': ''} floating_ip = self.l3_plugin.create_floatingip( self.context, {'floatingip': floating_ip}) with mock.patch.object( diff --git a/neutron/tests/unit/extensions/test_l3.py b/neutron/tests/unit/extensions/test_l3.py index 9bdaaccd1fd..93051885362 100644 --- a/neutron/tests/unit/extensions/test_l3.py +++ b/neutron/tests/unit/extensions/test_l3.py @@ -33,15 +33,18 @@ from neutron.callbacks import registry from neutron.callbacks import resources from neutron.common import constants as l3_constants from neutron.common import exceptions as n_exc +from neutron.common import utils from neutron import context from neutron.db import common_db_mixin from neutron.db import db_base_plugin_v2 +from neutron.db import dns_db from neutron.db import external_net_db from neutron.db import l3_agentschedulers_db from neutron.db import l3_attrs_db from neutron.db import l3_db from neutron.db import l3_dvr_db from neutron.db import l3_dvrscheduler_db +from neutron.extensions import dns from neutron.extensions import external_net from neutron.extensions import l3 from neutron.extensions import portbindings @@ -263,9 +266,9 @@ class TestL3NatBasePlugin(db_base_plugin_v2.NeutronDbPluginV2, # This plugin class is for tests with plugin that integrates L3. class TestL3NatIntPlugin(TestL3NatBasePlugin, - l3_db.L3_NAT_db_mixin): + l3_db.L3_NAT_db_mixin, dns_db.DNSDbMixin): - supported_extension_aliases = ["external-net", "router"] + supported_extension_aliases = ["external-net", "router", "dns-integration"] # This plugin class is for tests with plugin that integrates L3 and L3 agent @@ -293,9 +296,9 @@ class TestNoL3NatPlugin(TestL3NatBasePlugin): # delegate away L3 routing functionality class TestL3NatServicePlugin(common_db_mixin.CommonDbMixin, l3_dvr_db.L3_NAT_with_dvr_db_mixin, - l3_db.L3_NAT_db_mixin): + l3_db.L3_NAT_db_mixin, dns_db.DNSDbMixin): - supported_extension_aliases = ["router"] + supported_extension_aliases = ["router", "dns-integration"] def get_plugin_type(self): return service_constants.L3_ROUTER_NAT @@ -1190,6 +1193,9 @@ class L3NatTestCaseBase(L3NatTestCaseMixin): expected_port_update = { 'device_owner': l3_constants.DEVICE_OWNER_ROUTER_INTF, 'device_id': r['router']['id']} + plugin = manager.NeutronManager.get_plugin() + if utils.is_extension_supported(plugin, dns.Dns.get_alias()): + expected_port_update['dns_name'] = '' update_port.assert_called_with( mock.ANY, p['port']['id'], {'port': expected_port_update}) # fetch port and confirm device_id diff --git a/neutron/tests/unit/plugins/ml2/test_plugin.py b/neutron/tests/unit/plugins/ml2/test_plugin.py index 4db89f88664..16bd3e89a46 100644 --- a/neutron/tests/unit/plugins/ml2/test_plugin.py +++ b/neutron/tests/unit/plugins/ml2/test_plugin.py @@ -558,7 +558,8 @@ class TestMl2PortsV2(test_plugin.TestPortsV2, Ml2PluginV2TestCase): l3plugin.create_floatingip( context.get_admin_context(), {'floatingip': {'floating_network_id': n['network']['id'], - 'tenant_id': n['network']['tenant_id']}} + 'tenant_id': n['network']['tenant_id'], + 'dns_name': '', 'dns_domain': ''}} ) self._delete('networks', n['network']['id']) flips = l3plugin.get_floatingips(context.get_admin_context()) diff --git a/releasenotes/notes/add-integration-with-external-dns-f56ec8a4993b1fc4.yaml b/releasenotes/notes/add-integration-with-external-dns-f56ec8a4993b1fc4.yaml new file mode 100644 index 00000000000..f8379f70515 --- /dev/null +++ b/releasenotes/notes/add-integration-with-external-dns-f56ec8a4993b1fc4.yaml @@ -0,0 +1,14 @@ +--- +prelude: > + Support integration with external DNS service. +features: + - Floating IPs can have dns_name and dns_domain attributes associated + with them + - Ports can have a dns_name attribute associated with them. The network + where a port is created can have a dns_domain associated with it + - Floating IPs and ports will be published in an external DNS service + if they have dns_name and dns_domain attributes associated with them. + - The reference driver integrates neutron with designate + - Drivers for other DNSaaS can be implemented + - Driver is configured in the default section of neutron.conf using + parameter 'external_dns_driver' diff --git a/requirements.txt b/requirements.txt index 530e95d5018..a8afc346190 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,3 +43,4 @@ oslo.versionedobjects>=0.13.0 # Apache-2.0 ovs>=2.4.0;python_version=='2.7' # Apache-2.0 python-novaclient!=2.33.0,>=2.29.0 # Apache-2.0 +python-designateclient>=1.5.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 62b79ceb294..07f5a205f0a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -100,6 +100,7 @@ neutron.ml2.extension_drivers = testdb = neutron.tests.unit.plugins.ml2.drivers.ext_test:TestDBExtensionDriver port_security = neutron.plugins.ml2.extensions.port_security:PortSecurityExtensionDriver qos = neutron.plugins.ml2.extensions.qos:QosExtensionDriver + dns = neutron.plugins.ml2.extensions.dns_integration:DNSExtensionDriverML2 neutron.openstack.common.cache.backends = memory = neutron.openstack.common.cache._backends.memory:MemoryBackend neutron.ipam_drivers = @@ -112,6 +113,8 @@ neutron.qos.agent_drivers = sriov = neutron.plugins.ml2.drivers.mech_sriov.agent.extension_drivers.qos_driver:QosSRIOVAgentDriver neutron.agent.linux.pd_drivers = dibbler = neutron.agent.linux.dibbler:PDDibbler +neutron.services.external_dns_drivers = + designate = neutron.services.externaldns.drivers.designate.driver:Designate # These are for backwards compat with Icehouse notification_driver configuration values # TODO(mriedem): Remove these once liberty-eol happens. oslo.messaging.notify.drivers =