From 3d28fc0bfcf7f8c1fa11f83b6f4f35d82ee2eafd Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Sun, 12 Jul 2015 18:00:50 -0500 Subject: [PATCH] Add dns_label processing for Ports Functionallity is added to enable users to specify a dns_label field during port creation and update. This dns_label field will be used for DNS resolution of the hostname in dnsmasq and also will be used when Neutron can integrate with external DNS systems. Change-Id: I6beab336dfd9b70b1af6e975939c602047faa651 DocImpact APIImpact Closes-Bug: #1459030 Implements: blueprint internal-dns-resolution --- etc/dhcp_agent.ini | 3 +- etc/neutron.conf | 3 + neutron/agent/dhcp/config.py | 6 +- neutron/agent/linux/dhcp.py | 22 +- neutron/common/config.py | 3 + neutron/db/db_base_plugin_common.py | 7 + neutron/db/db_base_plugin_v2.py | 108 +++- neutron/db/ipam_non_pluggable_backend.py | 1 + neutron/db/ipam_pluggable_backend.py | 1 + .../alembic_migrations/versions/HEADS | 2 +- .../34af2b5c5a59_add_dns_name_to_port.py | 38 ++ neutron/db/models_v2.py | 5 +- neutron/extensions/dns.py | 177 +++++++ neutron/plugins/ml2/plugin.py | 2 +- neutron/tests/unit/agent/linux/test_dhcp.py | 95 ++-- neutron/tests/unit/extensions/test_dns.py | 469 ++++++++++++++++++ 16 files changed, 902 insertions(+), 40 deletions(-) create mode 100644 neutron/db/migration/alembic_migrations/versions/liberty/expand/34af2b5c5a59_add_dns_name_to_port.py create mode 100644 neutron/extensions/dns.py create mode 100644 neutron/tests/unit/extensions/test_dns.py diff --git a/etc/dhcp_agent.ini b/etc/dhcp_agent.ini index 7637be6f520..6996ed24fb4 100644 --- a/etc/dhcp_agent.ini +++ b/etc/dhcp_agent.ini @@ -66,7 +66,8 @@ # Location to store DHCP server config files # dhcp_confs = $state_path/dhcp -# Domain to use for building the hostnames +# Domain to use for building the hostnames. This option will be deprecated in +# a future release. It is being replaced by dns_domain in neutron.conf # dhcp_domain = openstacklocal # Override the default dnsmasq settings with this file diff --git a/etc/neutron.conf b/etc/neutron.conf index 1c185a80510..3cca29c2bf0 100644 --- a/etc/neutron.conf +++ b/etc/neutron.conf @@ -114,6 +114,9 @@ # tell dnsmasq to use infinite lease times. # dhcp_lease_duration = 86400 +# Domain to use for building the hostnames +# dns_domain = openstacklocal + # Allow sending resource operation notification to DHCP agent # dhcp_agent_notification = True diff --git a/neutron/agent/dhcp/config.py b/neutron/agent/dhcp/config.py index 06345047e4a..1ff185d83f1 100644 --- a/neutron/agent/dhcp/config.py +++ b/neutron/agent/dhcp/config.py @@ -40,7 +40,11 @@ DHCP_OPTS = [ help=_('Location to store DHCP server config files')), cfg.StrOpt('dhcp_domain', default='openstacklocal', - help=_('Domain to use for building the hostnames')), + help=_('Domain to use for building the hostnames.' + 'This option is deprecated. It has been moved to ' + 'neutron.conf as dns_domain. It will removed from here ' + 'in a future release'), + deprecated_for_removal=True), ] DNSMASQ_OPTS = [ diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 337106edffd..373668f5c0c 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -510,6 +510,11 @@ class Dnsmasq(DhcpLocalProcess): for port in self.network.ports: fixed_ips = self._sort_fixed_ips_for_dnsmasq(port.fixed_ips, v6_nets) + # Confirm whether Neutron server supports dns_name attribute in the + # ports API + dns_assignment = getattr(port, 'dns_assignment', None) + if dns_assignment: + dns_ip_map = {d.ip_address: d for d in dns_assignment} for alloc in fixed_ips: # Note(scollins) Only create entries that are # associated with the subnet being managed by this @@ -523,11 +528,18 @@ class Dnsmasq(DhcpLocalProcess): yield (port, alloc, hostname, fqdn) continue - hostname = 'host-%s' % alloc.ip_address.replace( - '.', '-').replace(':', '-') - fqdn = hostname - if self.conf.dhcp_domain: - fqdn = '%s.%s' % (fqdn, self.conf.dhcp_domain) + # If dns_name attribute is supported by ports API, return the + # dns_assignment generated by the Neutron server. Otherwise, + # generate hostname and fqdn locally (previous behaviour) + if dns_assignment: + hostname = dns_ip_map[alloc.ip_address].hostname + fqdn = dns_ip_map[alloc.ip_address].fqdn + else: + hostname = 'host-%s' % alloc.ip_address.replace( + '.', '-').replace(':', '-') + fqdn = hostname + if self.conf.dhcp_domain: + fqdn = '%s.%s' % (fqdn, self.conf.dhcp_domain) yield (port, alloc, hostname, fqdn) def _get_port_extra_dhcp_opts(self, port): diff --git a/neutron/common/config.py b/neutron/common/config.py index c8e4eebf52c..9b524bedace 100644 --- a/neutron/common/config.py +++ b/neutron/common/config.py @@ -82,6 +82,9 @@ core_opts = [ deprecated_name='dhcp_lease_time', help=_("DHCP lease duration (in seconds). Use -1 to tell " "dnsmasq to use infinite lease times.")), + cfg.StrOpt('dns_domain', + default='openstacklocal', + help=_('Domain to use for building the hostnames')), cfg.BoolOpt('dhcp_agent_notification', default=True, help=_("Allow sending resource operation" " notification to DHCP agent")), diff --git a/neutron/db/db_base_plugin_common.py b/neutron/db/db_base_plugin_common.py index e1e39f5bb25..70ee8c1e9a6 100644 --- a/neutron/db/db_base_plugin_common.py +++ b/neutron/db/db_base_plugin_common.py @@ -168,6 +168,13 @@ class DbBasePluginCommon(common_db_mixin.CommonDbMixin): for ip in port["fixed_ips"]], "device_id": port["device_id"], "device_owner": port["device_owner"]} + if "dns_name" in port: + res["dns_name"] = port["dns_name"] + if "dns_assignment" in port: + res["dns_assignment"] = [{"ip_address": a["ip_address"], + "hostname": a["hostname"], + "fqdn": a["fqdn"]} + for a in port["dns_assignment"]] # Call auxiliary extend functions, if any if process_extensions: self._apply_dict_extend_functions( diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 578f5f08fd2..ca0c73015fe 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -63,6 +63,9 @@ LOG = logging.getLogger(__name__) # IP allocations being cleaned up by cascade. AUTO_DELETE_PORT_OWNERS = [constants.DEVICE_OWNER_DHCP] +DNS_DOMAIN_DEFAULT = 'openstacklocal.' +FQDN_MAX_LEN = 255 + def _check_subnet_not_used(context, subnet_id): try: @@ -1034,6 +1037,54 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, def create_port_bulk(self, context, ports): return self._create_bulk('port', context, ports) + def _get_dns_domain(self): + if not cfg.CONF.dns_domain: + return '' + if cfg.CONF.dns_domain.endswith('.'): + return cfg.CONF.dns_domain + return '%s.' % cfg.CONF.dns_domain + + def _get_request_dns_name(self, port): + dns_domain = self._get_dns_domain() + if ((dns_domain and dns_domain != DNS_DOMAIN_DEFAULT)): + return port.get('dns_name', '') + return '' + + def _get_dns_names_for_port(self, context, network_id, ips, + request_dns_name): + filter = {'network_id': [network_id]} + subnets = self._get_subnets(context, filters=filter) + v6_subnets = {subnet['id']: subnet for subnet in subnets + if subnet['ip_version'] == 6} + dns_assignment = [] + dns_domain = self._get_dns_domain() + if request_dns_name: + request_fqdn = request_dns_name + if not request_dns_name.endswith('.'): + request_fqdn = '%s.%s' % (request_dns_name, dns_domain) + + for ip in ips: + subnet_id = ip['subnet_id'] + is_auto_address_subnet = ( + subnet_id in v6_subnets and + ipv6_utils.is_auto_address_subnet(v6_subnets[subnet_id])) + if is_auto_address_subnet: + continue + if request_dns_name: + hostname = request_dns_name + fqdn = request_fqdn + else: + hostname = 'host-%s' % ip['ip_address'].replace( + '.', '-').replace(':', '-') + fqdn = hostname + if dns_domain: + fqdn = '%s.%s' % (hostname, dns_domain) + dns_assignment.append({'ip_address': ip['ip_address'], + 'hostname': hostname, + 'fqdn': fqdn}) + + return dns_assignment + def _create_port_with_mac(self, context, network_id, port_data, mac_address): try: @@ -1081,6 +1132,9 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, status=p.get('status', constants.PORT_STATUS_ACTIVE), device_id=p['device_id'], device_owner=p['device_owner']) + if 'dns_name' in p: + request_dns_name = self._get_request_dns_name(p) + port_data['dns_name'] = request_dns_name with context.session.begin(subtransactions=True): # Ensure that the network exists. @@ -1094,8 +1148,16 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, db_port = self._create_port_with_mac( context, network_id, port_data, p['mac_address']) - self.ipam.allocate_ips_for_port_and_store(context, port, port_id) + ips = self.ipam.allocate_ips_for_port_and_store(context, port, + port_id) + if 'dns_name' in p: + dns_assignment = [] + if ips: + dns_assignment = self._get_dns_names_for_port( + context, network_id, ips, request_dns_name) + if 'dns_name' in p: + db_port['dns_assignment'] = dns_assignment return self._make_port_dict(db_port, process_extensions=False) def _validate_port_for_update(self, context, db_port, new_port, new_mac): @@ -1114,20 +1176,45 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, self._check_mac_addr_update(context, db_port, new_mac, current_owner) + def _get_dns_names_for_updated_port(self, context, db_port, + original_ips, original_dns_name, + request_dns_name, changes): + if changes.original or changes.add or changes.remove: + return self._get_dns_names_for_port( + context, db_port['network_id'], changes.original + changes.add, + request_dns_name or original_dns_name) + if original_ips: + return self._get_dns_names_for_port( + context, db_port['network_id'], original_ips, + request_dns_name or original_dns_name) + return [] + def update_port(self, context, id, port): new_port = port['port'] with context.session.begin(subtransactions=True): port = self._get_port(context, id) + if 'dns-integration' in self.supported_extension_aliases: + original_ips = self._make_fixed_ip_dict(port['fixed_ips']) + original_dns_name = port.get('dns_name', '') + request_dns_name = self._get_request_dns_name(new_port) + if not request_dns_name: + new_port['dns_name'] = '' new_mac = new_port.get('mac_address') self._validate_port_for_update(context, port, new_port, new_mac) changes = self.ipam.update_port_with_ips(context, port, new_port, new_mac) + if 'dns-integration' in self.supported_extension_aliases: + dns_assignment = self._get_dns_names_for_updated_port( + context, port, original_ips, original_dns_name, + request_dns_name, changes) result = self._make_port_dict(port) # Keep up with fields that changed if changes.original or changes.add or changes.remove: result['fixed_ips'] = self._make_fixed_ip_dict( changes.original + changes.add) + if 'dns-integration' in self.supported_extension_aliases: + result['dns_assignment'] = dns_assignment return result def delete_port(self, context, id): @@ -1150,8 +1237,19 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, "The port has already been deleted.", port_id) + def _get_dns_name_for_port_get(self, context, port): + if port['fixed_ips']: + return self._get_dns_names_for_port( + context, port['network_id'], port['fixed_ips'], + port['dns_name']) + return [] + def get_port(self, context, id, fields=None): port = self._get_port(context, id) + if (('dns-integration' in self.supported_extension_aliases and + 'dns_name' in port)): + port['dns_assignment'] = self._get_dns_name_for_port_get(context, + port) return self._make_port_dict(port, fields) def _get_ports_query(self, context, filters=None, sorts=None, limit=None, @@ -1189,7 +1287,13 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, sorts=sorts, limit=limit, marker_obj=marker_obj, page_reverse=page_reverse) - items = [self._make_port_dict(c, fields) for c in query] + items = [] + for c in query: + if (('dns-integration' in self.supported_extension_aliases and + 'dns_name' in c)): + c['dns_assignment'] = self._get_dns_name_for_port_get(context, + c) + items.append(self._make_port_dict(c, fields)) if limit and page_reverse: items.reverse() return items diff --git a/neutron/db/ipam_non_pluggable_backend.py b/neutron/db/ipam_non_pluggable_backend.py index 87bf0d188a5..e935ca26c69 100644 --- a/neutron/db/ipam_non_pluggable_backend.py +++ b/neutron/db/ipam_non_pluggable_backend.py @@ -207,6 +207,7 @@ class IpamNonPluggableBackend(ipam_backend_mixin.IpamBackendMixin): subnet_id = ip['subnet_id'] self._store_ip_allocation(context, ip_address, network_id, subnet_id, port_id) + return ips def update_port_with_ips(self, context, db_port, new_port, new_mac): changes = self.Changes(add=[], original=[], remove=[]) diff --git a/neutron/db/ipam_pluggable_backend.py b/neutron/db/ipam_pluggable_backend.py index 17e1371c375..1d6daa8ffcf 100644 --- a/neutron/db/ipam_pluggable_backend.py +++ b/neutron/db/ipam_pluggable_backend.py @@ -160,6 +160,7 @@ class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin): IpamPluggableBackend._store_ip_allocation( context, ip_address, network_id, subnet_id, port_id) + return ips except Exception: with excutils.save_and_reraise_exception(): if ips: diff --git a/neutron/db/migration/alembic_migrations/versions/HEADS b/neutron/db/migration/alembic_migrations/versions/HEADS index 05b6f3520d7..5e424af8a52 100644 --- a/neutron/db/migration/alembic_migrations/versions/HEADS +++ b/neutron/db/migration/alembic_migrations/versions/HEADS @@ -1,2 +1,2 @@ 2e5352a0ad4d -9859ac9c136 +34af2b5c5a59 diff --git a/neutron/db/migration/alembic_migrations/versions/liberty/expand/34af2b5c5a59_add_dns_name_to_port.py b/neutron/db/migration/alembic_migrations/versions/liberty/expand/34af2b5c5a59_add_dns_name_to_port.py new file mode 100644 index 00000000000..ba523ae655b --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/liberty/expand/34af2b5c5a59_add_dns_name_to_port.py @@ -0,0 +1,38 @@ +# Copyright 2015 Rackspace +# +# 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 dns_name to Port + +Revision ID: 34af2b5c5a59 +Revises: 9859ac9c136 +Create Date: 2015-08-23 00:22:47.618593 + +""" + +# revision identifiers, used by Alembic. +revision = '34af2b5c5a59' +down_revision = '9859ac9c136' + +from alembic import op +import sqlalchemy as sa + +from neutron.extensions import dns + + +def upgrade(): + op.add_column('ports', + sa.Column('dns_name', + sa.String(length=dns.FQDN_MAX_LEN), + nullable=True)) diff --git a/neutron/db/models_v2.py b/neutron/db/models_v2.py index 361d172cd62..8bc480e6741 100644 --- a/neutron/db/models_v2.py +++ b/neutron/db/models_v2.py @@ -141,6 +141,7 @@ class Port(model_base.BASEV2, HasId, HasTenant): device_id = sa.Column(sa.String(attr.DEVICE_ID_MAX_LEN), nullable=False) device_owner = sa.Column(sa.String(attr.DEVICE_OWNER_MAX_LEN), nullable=False) + dns_name = sa.Column(sa.String(255), nullable=True) __table_args__ = ( sa.Index( 'ix_ports_network_id_mac_address', 'network_id', 'mac_address'), @@ -154,7 +155,8 @@ class Port(model_base.BASEV2, HasId, HasTenant): def __init__(self, id=None, tenant_id=None, name=None, network_id=None, mac_address=None, admin_state_up=None, status=None, - device_id=None, device_owner=None, fixed_ips=None): + device_id=None, device_owner=None, fixed_ips=None, + dns_name=None): self.id = id self.tenant_id = tenant_id self.name = name @@ -163,6 +165,7 @@ class Port(model_base.BASEV2, HasId, HasTenant): self.admin_state_up = admin_state_up self.device_owner = device_owner self.device_id = device_id + self.dns_name = dns_name # Since this is a relationship only set it if one is passed in. if fixed_ips: self.fixed_ips = fixed_ips diff --git a/neutron/extensions/dns.py b/neutron/extensions/dns.py new file mode 100644 index 00000000000..495e826521a --- /dev/null +++ b/neutron/extensions/dns.py @@ -0,0 +1,177 @@ +# Copyright (c) 2015 Rackspace +# 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 re +import six + +from oslo_config import cfg + +from neutron.api import extensions +from neutron.api.v2 import attributes as attr +from neutron.common import exceptions as n_exc + +DNS_LABEL_MAX_LEN = 63 +DNS_LABEL_REGEX = "[a-z0-9-]{1,%d}$" % DNS_LABEL_MAX_LEN +FQDN_MAX_LEN = 255 +DNS_DOMAIN_DEFAULT = 'openstacklocal.' + + +def _validate_dns_name(data, max_len=FQDN_MAX_LEN): + msg = _validate_dns_format(data, max_len) + if msg: + return msg + request_dns_name = _get_request_dns_name(data) + if request_dns_name: + msg = _validate_dns_name_with_dns_domain(request_dns_name) + if msg: + 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 + # dns_name matches RFC 1123 (section 2.1) and RFC 952. + if not data: + return + try: + # Trailing periods are allowed to indicate that a name is fully + # qualified per RFC 1034 (page 7). + trimmed = data if not data.endswith('.') else data[:-1] + if len(trimmed) > 255: + raise TypeError( + _("'%s' exceeds the 255 character FQDN limit") % trimmed) + names = trimmed.split('.') + for name in names: + if not name: + raise TypeError(_("Encountered an empty component.")) + if name.endswith('-') or name[0] == '-': + raise TypeError( + _("Name '%s' must not start or end with a hyphen.") % name) + if not re.match(DNS_LABEL_REGEX, name): + raise TypeError( + _("Name '%s' must be 1-63 characters long, each of " + "which can only be alphanumeric or a hyphen.") % name) + # RFC 1123 hints that a TLD can't be all numeric. last is a TLD if + # it's an FQDN. + if len(names) > 1 and re.match("^[0-9]+$", names[-1]): + raise TypeError(_("TLD '%s' must not be all numeric") % names[-1]) + except TypeError as e: + msg = _("'%(data)s' not a valid PQDN or FQDN. Reason: %(reason)s") % { + 'data': data, 'reason': e.message} + return msg + + +def _validate_dns_name_with_dns_domain(request_dns_name): + # If a PQDN was passed, make sure the FQDN that will be generated is of + # legal size + dns_domain = _get_dns_domain() + higher_labels = dns_domain + if dns_domain: + higher_labels = '.%s' % dns_domain + higher_labels_len = len(higher_labels) + dns_name_len = len(request_dns_name) + if not request_dns_name.endswith('.'): + if dns_name_len + higher_labels_len > FQDN_MAX_LEN: + msg = _("The dns_name passed is a PQDN and its size is " + "'%(dns_name_len)s'. The dns_domain option in " + "neutron.conf is set to %(dns_domain)s, with a " + "length of '%(higher_labels_len)s'. When the two are " + "concatenated to form a FQDN (with a '.' at the end), " + "the resulting length exceeds the maximum size " + "of '%(fqdn_max_len)s'" + ) % {'dns_name_len': dns_name_len, + 'dns_domain': cfg.CONF.dns_domain, + 'higher_labels_len': higher_labels_len, + 'fqdn_max_len': FQDN_MAX_LEN} + return msg + return + + # A FQDN was passed + if (dns_name_len <= higher_labels_len or not + request_dns_name.endswith(higher_labels)): + msg = _("The dns_name passed is a FQDN. Its higher level labels " + "must be equal to the dns_domain option in neutron.conf, " + "that has been set to '%(dns_domain)s'. It must also " + "include one or more valid DNS labels to the left " + "of '%(dns_domain)s'") % {'dns_domain': + cfg.CONF.dns_domain} + return msg + + +def _get_dns_domain(): + if not cfg.CONF.dns_domain: + return '' + if cfg.CONF.dns_domain.endswith('.'): + return cfg.CONF.dns_domain + return '%s.' % cfg.CONF.dns_domain + + +def _get_request_dns_name(data): + dns_domain = _get_dns_domain() + if ((dns_domain and dns_domain != DNS_DOMAIN_DEFAULT)): + return data + return '' + + +def convert_to_lowercase(data): + if isinstance(data, six.string_types): + return data.lower() + msg = _("'%s' cannot be converted to lowercase string") % data + raise n_exc.InvalidInput(error_message=msg) + + +attr.validators['type:dns_name'] = ( + _validate_dns_name) + + +DNSNAME = 'dns_name' +DNSASSIGNMENT = 'dns_assignment' +EXTENDED_ATTRIBUTES_2_0 = { + 'ports': { + DNSNAME: {'allow_post': True, 'allow_put': True, + 'default': '', + 'convert_to': convert_to_lowercase, + 'validate': {'type:dns_name': FQDN_MAX_LEN}, + 'is_visible': True}, + DNSASSIGNMENT: {'allow_post': False, 'allow_put': False, + 'is_visible': True}, + } +} + + +class Dns(extensions.ExtensionDescriptor): + """Extension class supporting DNS Integration.""" + + @classmethod + def get_name(cls): + return "DNS Integration" + + @classmethod + def get_alias(cls): + return "dns-integration" + + @classmethod + def get_description(cls): + return "Provides integration with internal DNS." + + @classmethod + def get_updated(cls): + return "2015-08-15T18:00:00-00:00" + + def get_extended_resources(self, version): + if version == "2.0": + return EXTENDED_ATTRIBUTES_2_0 + else: + return {} diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index 5d9a2136196..4cdf98a40e7 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -118,7 +118,7 @@ class Ml2Plugin(db_base_plugin_v2.NeutronDbPluginV2, "multi-provider", "allowed-address-pairs", "extra_dhcp_opt", "subnet_allocation", "net-mtu", "vlan-transparent", - "address-scope"] + "address-scope", "dns-integration"] @property def supported_extension_aliases(self): diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 0d8a9227b64..7fadbcf33cc 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -39,6 +39,19 @@ class FakeIPAllocation(object): self.subnet_id = subnet_id +class FakeDNSAssignment(object): + def __init__(self, ip_address, dns_name='', domain='openstacklocal'): + if dns_name: + self.hostname = dns_name + else: + self.hostname = 'host-%s' % ip_address.replace( + '.', '-').replace(':', '-') + self.ip_address = ip_address + self.fqdn = self.hostname + if domain: + self.fqdn = '%s.%s.' % (self.hostname, domain) + + class DhcpOpt(object): def __init__(self, **kwargs): self.__dict__.update(ip_version=4) @@ -90,8 +103,9 @@ class FakePort1(object): mac_address = '00:00:80:aa:bb:cc' device_id = 'fake_port1' - def __init__(self): + def __init__(self, domain='openstacklocal'): self.extra_dhcp_opts = [] + self.dns_assignment = [FakeDNSAssignment('192.168.0.2', domain=domain)] class FakePort2(object): @@ -102,6 +116,7 @@ class FakePort2(object): 'dddddddd-dddd-dddd-dddd-dddddddddddd')] mac_address = '00:00:f3:aa:bb:cc' device_id = 'fake_port2' + dns_assignment = [FakeDNSAssignment('192.168.0.3')] def __init__(self): self.extra_dhcp_opts = [] @@ -115,6 +130,8 @@ class FakePort3(object): 'dddddddd-dddd-dddd-dddd-dddddddddddd'), FakeIPAllocation('192.168.1.2', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee')] + dns_assignment = [FakeDNSAssignment('192.168.0.4'), + FakeDNSAssignment('192.168.1.2')] mac_address = '00:00:0f:aa:bb:cc' device_id = 'fake_port3' @@ -131,6 +148,7 @@ class FakePort4(object): 'dddddddd-dddd-dddd-dddd-dddddddddddd'), FakeIPAllocation('ffda:3ba5:a17a:4ba3:0216:3eff:fec2:771d', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee')] + dns_assignment = [FakeDNSAssignment('192.168.0.4')] mac_address = '00:16:3E:C2:77:1D' device_id = 'fake_port4' @@ -144,6 +162,7 @@ class FakePort5(object): device_owner = 'foo5' fixed_ips = [FakeIPAllocation('192.168.0.5', 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + dns_assignment = [FakeDNSAssignment('192.168.0.5')] mac_address = '00:00:0f:aa:bb:55' device_id = 'fake_port5' @@ -159,6 +178,7 @@ class FakePort6(object): device_owner = 'foo6' fixed_ips = [FakeIPAllocation('192.168.0.6', 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + dns_assignment = [FakeDNSAssignment('192.168.0.6')] mac_address = '00:00:0f:aa:bb:66' device_id = 'fake_port6' @@ -181,8 +201,10 @@ class FakeV6Port(object): mac_address = '00:00:f3:aa:bb:cc' device_id = 'fake_port6' - def __init__(self): + def __init__(self, domain='openstacklocal'): self.extra_dhcp_opts = [] + self.dns_assignment = [FakeDNSAssignment('fdca:3ba5:a17a:4ba3::2', + domain=domain)] class FakeV6PortExtraOpt(object): @@ -191,6 +213,7 @@ class FakeV6PortExtraOpt(object): device_owner = 'foo3' fixed_ips = [FakeIPAllocation('ffea:3ba5:a17a:4ba3:0216:3eff:fec2:771d', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee')] + dns_assignment = [] mac_address = '00:16:3e:c2:77:1d' device_id = 'fake_port6' @@ -209,6 +232,7 @@ class FakeDualPortWithV6ExtraOpt(object): 'dddddddd-dddd-dddd-dddd-dddddddddddd'), FakeIPAllocation('ffea:3ba5:a17a:4ba3:0216:3eff:fec2:771d', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee')] + dns_assignment = [FakeDNSAssignment('192.168.0.3')] mac_address = '00:16:3e:c2:77:1d' device_id = 'fake_port6' @@ -230,8 +254,11 @@ class FakeDualPort(object): mac_address = '00:00:0f:aa:bb:cc' device_id = 'fake_dual_port' - def __init__(self): + def __init__(self, domain='openstacklocal'): self.extra_dhcp_opts = [] + self.dns_assignment = [FakeDNSAssignment('192.168.0.3', domain=domain), + FakeDNSAssignment('fdca:3ba5:a17a:4ba3::3', + domain=domain)] class FakeRouterPort(object): @@ -240,13 +267,16 @@ class FakeRouterPort(object): device_owner = constants.DEVICE_OWNER_ROUTER_INTF mac_address = '00:00:0f:rr:rr:rr' device_id = 'fake_router_port' + dns_assignment = [] def __init__(self, dev_owner=constants.DEVICE_OWNER_ROUTER_INTF, - ip_address='192.168.0.1'): + ip_address='192.168.0.1', domain='openstacklocal'): self.extra_dhcp_opts = [] self.device_owner = dev_owner self.fixed_ips = [FakeIPAllocation( ip_address, 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + self.dns_assignment = [FakeDNSAssignment(ip.ip_address, domain=domain) + for ip in self.fixed_ips] class FakeRouterPort2(object): @@ -255,6 +285,7 @@ class FakeRouterPort2(object): device_owner = constants.DEVICE_OWNER_ROUTER_INTF fixed_ips = [FakeIPAllocation('192.168.1.1', 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + dns_assignment = [FakeDNSAssignment('192.168.1.1')] mac_address = '00:00:0f:rr:rr:r2' device_id = 'fake_router_port2' @@ -268,6 +299,7 @@ class FakePortMultipleAgents1(object): device_owner = constants.DEVICE_OWNER_DHCP fixed_ips = [FakeIPAllocation('192.168.0.5', 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + dns_assignment = [FakeDNSAssignment('192.168.0.5')] mac_address = '00:00:0f:dd:dd:dd' device_id = 'fake_multiple_agents_port' @@ -281,6 +313,7 @@ class FakePortMultipleAgents2(object): device_owner = constants.DEVICE_OWNER_DHCP fixed_ips = [FakeIPAllocation('192.168.0.6', 'dddddddd-dddd-dddd-dddd-dddddddddddd')] + dns_assignment = [FakeDNSAssignment('192.168.0.6')] mac_address = '00:00:0f:ee:ee:ee' device_id = 'fake_multiple_agents_port2' @@ -499,9 +532,14 @@ class FakeV6Network(object): class FakeDualNetwork(object): id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' subnets = [FakeV4Subnet(), FakeV6SubnetDHCPStateful()] - ports = [FakePort1(), FakeV6Port(), FakeDualPort(), FakeRouterPort()] + # ports = [FakePort1(), FakeV6Port(), FakeDualPort(), FakeRouterPort()] namespace = 'qdhcp-ns' + def __init__(self, domain='openstacklocal'): + self.ports = [FakePort1(domain=domain), FakeV6Port(domain=domain), + FakeDualPort(domain=domain), + FakeRouterPort(domain=domain)] + class FakeDeviceManagerNetwork(object): id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' @@ -1079,7 +1117,8 @@ class TestDnsmasq(TestBase): (exp_host_name, exp_host_data, exp_addn_name, exp_addn_data) = self._test_no_dhcp_domain_alloc_data self.conf.set_override('dhcp_domain', '') - self._test_spawn(['--conf-file=']) + network = FakeDualNetwork(domain=self.conf.dhcp_domain) + self._test_spawn(['--conf-file='], network=network) self.safe.assert_has_calls([mock.call(exp_host_name, exp_host_data), mock.call(exp_addn_name, exp_addn_data)]) @@ -1475,30 +1514,30 @@ class TestDnsmasq(TestBase): @property def _test_reload_allocation_data(self): exp_host_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host' - exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal,' + exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal.,' '192.168.0.2\n' '00:00:f3:aa:bb:cc,host-fdca-3ba5-a17a-4ba3--2.' - 'openstacklocal,[fdca:3ba5:a17a:4ba3::2]\n' - '00:00:0f:aa:bb:cc,host-192-168-0-3.openstacklocal,' + 'openstacklocal.,[fdca:3ba5:a17a:4ba3::2]\n' + '00:00:0f:aa:bb:cc,host-192-168-0-3.openstacklocal.,' '192.168.0.3\n' '00:00:0f:aa:bb:cc,host-fdca-3ba5-a17a-4ba3--3.' - 'openstacklocal,[fdca:3ba5:a17a:4ba3::3]\n' - '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal,' + 'openstacklocal.,[fdca:3ba5:a17a:4ba3::3]\n' + '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal.,' '192.168.0.1\n').lstrip() exp_addn_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/addn_hosts' exp_addn_data = ( '192.168.0.2\t' - 'host-192-168-0-2.openstacklocal host-192-168-0-2\n' + 'host-192-168-0-2.openstacklocal. host-192-168-0-2\n' 'fdca:3ba5:a17a:4ba3::2\t' - 'host-fdca-3ba5-a17a-4ba3--2.openstacklocal ' + 'host-fdca-3ba5-a17a-4ba3--2.openstacklocal. ' 'host-fdca-3ba5-a17a-4ba3--2\n' - '192.168.0.3\thost-192-168-0-3.openstacklocal ' + '192.168.0.3\thost-192-168-0-3.openstacklocal. ' 'host-192-168-0-3\n' 'fdca:3ba5:a17a:4ba3::3\t' - 'host-fdca-3ba5-a17a-4ba3--3.openstacklocal ' + 'host-fdca-3ba5-a17a-4ba3--3.openstacklocal. ' 'host-fdca-3ba5-a17a-4ba3--3\n' '192.168.0.1\t' - 'host-192-168-0-1.openstacklocal ' + 'host-192-168-0-1.openstacklocal. ' 'host-192-168-0-1\n' ).lstrip() exp_opt_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/opts' @@ -1774,11 +1813,11 @@ class TestDnsmasq(TestBase): def test_only_populates_dhcp_enabled_subnets(self): exp_host_name = '/dhcp/eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee/host' - exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal,' + exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal.,' '192.168.0.2\n' - '00:16:3E:C2:77:1D,host-192-168-0-4.openstacklocal,' + '00:16:3E:C2:77:1D,host-192-168-0-4.openstacklocal.,' '192.168.0.4\n' - '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal,' + '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal.,' '192.168.0.1\n').lstrip() dm = self._get_dnsmasq(FakeDualStackNetworkSingleDHCP()) dm._output_hosts_file() @@ -1787,13 +1826,13 @@ class TestDnsmasq(TestBase): def test_only_populates_dhcp_client_id(self): exp_host_name = '/dhcp/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/host' - exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal,' + exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal.,' '192.168.0.2\n' '00:00:0f:aa:bb:55,id:test5,' - 'host-192-168-0-5.openstacklocal,' + 'host-192-168-0-5.openstacklocal.,' '192.168.0.5\n' '00:00:0f:aa:bb:66,id:test6,' - 'host-192-168-0-6.openstacklocal,192.168.0.6,' + 'host-192-168-0-6.openstacklocal.,192.168.0.6,' 'set:ccccccccc-cccc-cccc-cccc-ccccccccc\n').lstrip() dm = self._get_dnsmasq(FakeV4NetworkClientId) @@ -1803,13 +1842,13 @@ class TestDnsmasq(TestBase): def test_only_populates_dhcp_enabled_subnet_on_a_network(self): exp_host_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host' - exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal,' + exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal.,' '192.168.0.2\n' - '00:00:f3:aa:bb:cc,host-192-168-0-3.openstacklocal,' + '00:00:f3:aa:bb:cc,host-192-168-0-3.openstacklocal.,' '192.168.0.3\n' - '00:00:0f:aa:bb:cc,host-192-168-0-4.openstacklocal,' + '00:00:0f:aa:bb:cc,host-192-168-0-4.openstacklocal.,' '192.168.0.4\n' - '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal,' + '00:00:0f:rr:rr:rr,host-192-168-0-1.openstacklocal.,' '192.168.0.1\n').lstrip() dm = self._get_dnsmasq(FakeDualNetworkSingleDHCP()) dm._output_hosts_file() @@ -1835,10 +1874,10 @@ class TestDnsmasq(TestBase): exp_host_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/host' exp_host_data = ( '00:16:3e:c2:77:1d,set:hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh\n' - '00:16:3e:c2:77:1d,host-192-168-0-3.openstacklocal,' + '00:16:3e:c2:77:1d,host-192-168-0-3.openstacklocal.,' '192.168.0.3,set:hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh\n' '00:00:0f:rr:rr:rr,' - 'host-192-168-0-1.openstacklocal,192.168.0.1\n').lstrip() + 'host-192-168-0-1.openstacklocal.,192.168.0.1\n').lstrip() exp_opt_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/opts' exp_opt_data = ( 'tag:tag0,option6:domain-search,openstacklocal\n' diff --git a/neutron/tests/unit/extensions/test_dns.py b/neutron/tests/unit/extensions/test_dns.py new file mode 100644 index 00000000000..797da83af57 --- /dev/null +++ b/neutron/tests/unit/extensions/test_dns.py @@ -0,0 +1,469 @@ +# Copyright 2015 Rackspace +# +# 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 math +import netaddr + +from oslo_config import cfg + +from neutron.common import constants +from neutron.common import utils +from neutron import context +from neutron.db import db_base_plugin_v2 +from neutron.extensions import dns +from neutron.tests.unit.db import test_db_base_plugin_v2 + + +class DnsExtensionManager(object): + + def get_resources(self): + return [] + + def get_actions(self): + return [] + + def get_request_extensions(self): + return [] + + def get_extended_resources(self, version): + return dns.get_extended_resources(version) + + +class DnsExtensionTestPlugin(db_base_plugin_v2.NeutronDbPluginV2): + """Test plugin to mixin the DNS Integration extensions. + """ + + supported_extension_aliases = ["dns-integration"] + + +class DnsExtensionTestCase(test_db_base_plugin_v2.TestNetworksV2): + """Test API extension dns attributes. + """ + + def setUp(self): + plugin = ('neutron.tests.unit.extensions.test_dns.' + + 'DnsExtensionTestPlugin') + ext_mgr = DnsExtensionManager() + super(DnsExtensionTestCase, self).setUp(plugin=plugin, ext_mgr=ext_mgr) + + def _create_port(self, fmt, net_id, expected_res_status=None, + arg_list=None, **kwargs): + data = {'port': {'network_id': net_id, + 'tenant_id': self._tenant_id}} + + for arg in (('admin_state_up', 'device_id', + 'mac_address', 'name', 'fixed_ips', + 'tenant_id', 'device_owner', 'security_groups', + 'dns_name') + (arg_list or ())): + # Arg must be present + if arg in kwargs: + data['port'][arg] = kwargs[arg] + # create a dhcp port device id if one hasn't been supplied + if ('device_owner' in kwargs and + kwargs['device_owner'] == constants.DEVICE_OWNER_DHCP and + 'host' in kwargs and + 'device_id' not in kwargs): + device_id = utils.get_dhcp_agent_device_id(net_id, kwargs['host']) + data['port']['device_id'] = device_id + port_req = self.new_create_request('ports', data, fmt) + if (kwargs.get('set_context') and 'tenant_id' in kwargs): + # create a specific auth context for this request + port_req.environ['neutron.context'] = context.Context( + '', kwargs['tenant_id']) + + port_res = port_req.get_response(self.api) + if expected_res_status: + self.assertEqual(port_res.status_int, expected_res_status) + return port_res + + def _test_list_resources(self, resource, items, neutron_context=None, + query_params=None): + res = self._list('%ss' % resource, + neutron_context=neutron_context, + query_params=query_params) + resource = resource.replace('-', '_') + self.assertItemsEqual([i['id'] for i in res['%ss' % resource]], + [i[resource]['id'] for i in items]) + return res + + def test_create_port_json(self): + keys = [('admin_state_up', True), ('status', self.port_create_status)] + with self.port(name='myname') as port: + for k, v in keys: + self.assertEqual(port['port'][k], v) + self.assertIn('mac_address', port['port']) + ips = port['port']['fixed_ips'] + self.assertEqual(len(ips), 1) + self.assertEqual(ips[0]['ip_address'], '10.0.0.2') + self.assertEqual('myname', port['port']['name']) + self._verify_dns_assigment(port['port'], + ips_list=['10.0.0.2']) + + def test_list_ports(self): + # for this test we need to enable overlapping ips + cfg.CONF.set_default('allow_overlapping_ips', True) + with self.port() as v1, self.port() as v2, self.port() as v3: + ports = (v1, v2, v3) + res = self._test_list_resources('port', ports) + for port in res['ports']: + self._verify_dns_assigment( + port, ips_list=[port['fixed_ips'][0]['ip_address']]) + + def test_show_port(self): + with self.port() as port: + req = self.new_show_request('ports', port['port']['id'], self.fmt) + sport = self.deserialize(self.fmt, req.get_response(self.api)) + self.assertEqual(port['port']['id'], sport['port']['id']) + self._verify_dns_assigment( + sport['port'], + ips_list=[sport['port']['fixed_ips'][0]['ip_address']]) + + def test_update_port_non_default_dns_domain_with_dns_name(self): + with self.port() as port: + cfg.CONF.set_override('dns_domain', 'example.com') + data = {'port': {'admin_state_up': False, 'dns_name': 'vm1'}} + req = self.new_update_request('ports', data, port['port']['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + self.assertEqual(res['port']['admin_state_up'], + data['port']['admin_state_up']) + self._verify_dns_assigment(res['port'], + ips_list=['10.0.0.2'], + dns_name='vm1') + + def test_update_port_default_dns_domain_with_dns_name(self): + with self.port() as port: + data = {'port': {'admin_state_up': False, 'dns_name': 'vm1'}} + req = self.new_update_request('ports', data, port['port']['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + self.assertEqual(res['port']['admin_state_up'], + data['port']['admin_state_up']) + self._verify_dns_assigment(res['port'], + ips_list=['10.0.0.2']) + + def _verify_dns_assigment(self, port, ips_list=[], exp_ips_ipv4=0, + exp_ips_ipv6=0, ipv4_cidrs=[], ipv6_cidrs=[], + dns_name=''): + self.assertEqual(port['dns_name'], dns_name) + dns_assignment = port['dns_assignment'] + if ips_list: + self.assertEqual(len(dns_assignment), len(ips_list)) + ips_set = set(ips_list) + else: + self.assertEqual(len(dns_assignment), exp_ips_ipv4 + exp_ips_ipv6) + ipv4_count = 0 + ipv6_count = 0 + subnets_v4 = [netaddr.IPNetwork(cidr) for cidr in ipv4_cidrs] + subnets_v6 = [netaddr.IPNetwork(cidr) for cidr in ipv6_cidrs] + + request_dns_name, request_fqdn = self._get_request_hostname_and_fqdn( + dns_name) + for assignment in dns_assignment: + if ips_list: + self.assertIn(assignment['ip_address'], ips_set) + ips_set.remove(assignment['ip_address']) + else: + ip = netaddr.IPAddress(assignment['ip_address']) + if ip.version == 4: + self.assertTrue(self._verify_ip_in_subnet(ip, subnets_v4)) + ipv4_count += 1 + else: + self.assertTrue(self._verify_ip_in_subnet(ip, subnets_v6)) + ipv6_count += 1 + hostname, fqdn = self._get_hostname_and_fqdn(request_dns_name, + request_fqdn, + assignment) + self.assertEqual(assignment['hostname'], hostname) + self.assertEqual(assignment['fqdn'], fqdn) + if ips_list: + self.assertFalse(ips_set) + else: + self.assertEqual(ipv4_count, exp_ips_ipv4) + self.assertEqual(ipv6_count, exp_ips_ipv6) + + def _get_dns_domain(self): + if not cfg.CONF.dns_domain: + return '' + if cfg.CONF.dns_domain.endswith('.'): + return cfg.CONF.dns_domain + return '%s.' % cfg.CONF.dns_domain + + def _get_request_hostname_and_fqdn(self, dns_name): + request_dns_name = '' + request_fqdn = '' + dns_domain = self._get_dns_domain() + if dns_name and dns_domain and dns_domain != 'openstacklocal.': + request_dns_name = dns_name + request_fqdn = request_dns_name + if not request_dns_name.endswith('.'): + request_fqdn = '%s.%s' % (dns_name, dns_domain) + return request_dns_name, request_fqdn + + def _get_hostname_and_fqdn(self, request_dns_name, request_fqdn, + assignment): + dns_domain = self._get_dns_domain() + if request_dns_name: + hostname = request_dns_name + fqdn = request_fqdn + else: + hostname = 'host-%s' % assignment['ip_address'].replace( + '.', '-').replace(':', '-') + fqdn = hostname + if dns_domain: + fqdn = '%s.%s' % (hostname, dns_domain) + return hostname, fqdn + + def _verify_ip_in_subnet(self, ip, subnets_list): + for subnet in subnets_list: + if ip in subnet: + return True + return False + + def test_update_port_update_ip(self): + """Test update of port IP. + + Check that a configured IP 10.0.0.2 is replaced by 10.0.0.10. + """ + with self.subnet() as subnet: + with self.port(subnet=subnet) as port: + ips = port['port']['fixed_ips'] + self.assertEqual(len(ips), 1) + self.assertEqual(ips[0]['ip_address'], '10.0.0.2') + self.assertEqual(ips[0]['subnet_id'], subnet['subnet']['id']) + data = {'port': {'fixed_ips': [{'subnet_id': + subnet['subnet']['id'], + 'ip_address': "10.0.0.10"}]}} + req = self.new_update_request('ports', data, + port['port']['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + ips = res['port']['fixed_ips'] + self.assertEqual(len(ips), 1) + self.assertEqual(ips[0]['ip_address'], '10.0.0.10') + self.assertEqual(ips[0]['subnet_id'], subnet['subnet']['id']) + self._verify_dns_assigment(res['port'], ips_list=['10.0.0.10']) + + def test_update_port_update_ip_address_only(self): + with self.subnet() as subnet: + with self.port(subnet=subnet) as port: + ips = port['port']['fixed_ips'] + self.assertEqual(len(ips), 1) + self.assertEqual(ips[0]['ip_address'], '10.0.0.2') + self.assertEqual(ips[0]['subnet_id'], subnet['subnet']['id']) + data = {'port': {'fixed_ips': [{'subnet_id': + subnet['subnet']['id'], + 'ip_address': "10.0.0.10"}, + {'ip_address': "10.0.0.2"}]}} + req = self.new_update_request('ports', data, + port['port']['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + ips = res['port']['fixed_ips'] + self.assertEqual(len(ips), 2) + self.assertIn({'ip_address': '10.0.0.2', + 'subnet_id': subnet['subnet']['id']}, ips) + self.assertIn({'ip_address': '10.0.0.10', + 'subnet_id': subnet['subnet']['id']}, ips) + self._verify_dns_assigment(res['port'], + ips_list=['10.0.0.10', + '10.0.0.2']) + + def test_create_port_with_multiple_ipv4_and_ipv6_subnets(self): + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets() + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_pqdn_and_dns_domain_no_period( + self): + cfg.CONF.set_override('dns_domain', 'example.com') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name='vm1') + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_pqdn_and_dns_domain_period( + self): + cfg.CONF.set_override('dns_domain', 'example.com.') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name='vm1') + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_pqdn_and_no_dns_domain( + self): + cfg.CONF.set_override('dns_domain', '') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets() + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_fqdn_and_dns_domain_no_period( + self): + cfg.CONF.set_override('dns_domain', 'example.com') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name='vm1.example.com.') + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_fqdn_and_dns_domain_period( + self): + cfg.CONF.set_override('dns_domain', 'example.com.') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name='vm1.example.com.') + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_fqdn_default_domain_period( + self): + cfg.CONF.set_override('dns_domain', 'openstacklocal.') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets() + self.assertEqual(res.status_code, 201) + + def test_create_port_multiple_v4_v6_subnets_bad_fqdn_and_dns_domain( + self): + cfg.CONF.set_override('dns_domain', 'example.com') + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name='vm1.bad-domain.com.') + self.assertEqual(res.status_code, 400) + expected_error = ('The dns_name passed is a FQDN. Its higher level ' + 'labels must be equal to the dns_domain option in ' + 'neutron.conf') + self.assertIn(expected_error, res.text) + + def test_create_port_multiple_v4_v6_subnets_bad_pqdn_and_dns_domain( + self): + cfg.CONF.set_override('dns_domain', 'example.com') + num_labels = int( + math.floor(dns.FQDN_MAX_LEN / dns.DNS_LABEL_MAX_LEN)) + filler_len = int( + math.floor(dns.FQDN_MAX_LEN % dns.DNS_LABEL_MAX_LEN)) + dns_name = (('a' * (dns.DNS_LABEL_MAX_LEN - 1) + '.') * + num_labels + 'a' * filler_len) + res = self._test_create_port_with_multiple_ipv4_and_ipv6_subnets( + dns_name=dns_name) + self.assertEqual(res.status_code, 400) + expected_error = ("When the two are concatenated to form a FQDN " + "(with a '.' at the end), the resulting length " + "exceeds the maximum size") + self.assertIn(expected_error, res.text) + + def _test_create_port_with_multiple_ipv4_and_ipv6_subnets(self, + dns_name=''): + """Test port create with multiple IPv4, IPv6 DHCP/SLAAC subnets.""" + res = self._create_network(fmt=self.fmt, name='net', + admin_state_up=True) + network = self.deserialize(self.fmt, res) + sub_dicts = [ + {'gateway': '10.0.0.1', 'cidr': '10.0.0.0/24', + 'ip_version': 4, 'ra_addr_mode': None}, + {'gateway': '10.0.1.1', 'cidr': '10.0.1.0/24', + 'ip_version': 4, 'ra_addr_mode': None}, + {'gateway': 'fe80::1', 'cidr': 'fe80::/64', + 'ip_version': 6, 'ra_addr_mode': constants.IPV6_SLAAC}, + {'gateway': 'fe81::1', 'cidr': 'fe81::/64', + 'ip_version': 6, 'ra_addr_mode': constants.IPV6_SLAAC}, + {'gateway': 'fe82::1', 'cidr': 'fe82::/64', + 'ip_version': 6, 'ra_addr_mode': constants.DHCPV6_STATEFUL}, + {'gateway': 'fe83::1', 'cidr': 'fe83::/64', + 'ip_version': 6, 'ra_addr_mode': constants.DHCPV6_STATEFUL}] + subnets = {} + for sub_dict in sub_dicts: + subnet = self._make_subnet( + self.fmt, network, + gateway=sub_dict['gateway'], + cidr=sub_dict['cidr'], + ip_version=sub_dict['ip_version'], + ipv6_ra_mode=sub_dict['ra_addr_mode'], + ipv6_address_mode=sub_dict['ra_addr_mode']) + subnets[subnet['subnet']['id']] = sub_dict + res = self._create_port(self.fmt, net_id=network['network']['id'], + dns_name=dns_name) + if res.status_code != 201: + return res + port = self.deserialize(self.fmt, res) + # Since the create port request was made without a list of fixed IPs, + # the port should be associated with addresses for one of the + # IPv4 subnets, one of the DHCPv6 subnets, and both of the IPv6 + # SLAAC subnets. + self.assertEqual(4, len(port['port']['fixed_ips'])) + addr_mode_count = {None: 0, constants.DHCPV6_STATEFUL: 0, + constants.IPV6_SLAAC: 0} + for fixed_ip in port['port']['fixed_ips']: + subnet_id = fixed_ip['subnet_id'] + if subnet_id in subnets: + addr_mode_count[subnets[subnet_id]['ra_addr_mode']] += 1 + self.assertEqual(1, addr_mode_count[None]) + self.assertEqual(1, addr_mode_count[constants.DHCPV6_STATEFUL]) + self.assertEqual(2, addr_mode_count[constants.IPV6_SLAAC]) + self._verify_dns_assigment(port['port'], exp_ips_ipv4=1, + exp_ips_ipv6=1, + ipv4_cidrs=[sub_dicts[0]['cidr'], + sub_dicts[1]['cidr']], + ipv6_cidrs=[sub_dicts[4]['cidr'], + sub_dicts[5]['cidr']], + dns_name=dns_name) + return res + + def test_api_extension_validation_with_bad_dns_names(self): + num_labels = int( + math.floor(dns.FQDN_MAX_LEN / dns.DNS_LABEL_MAX_LEN)) + filler_len = int( + math.floor(dns.FQDN_MAX_LEN % dns.DNS_LABEL_MAX_LEN)) + dns_names = [555, '\f\n\r', '.', '-vm01', '_vm01', 'vm01-', + '-vm01.test1', 'vm01.-test1', 'vm01._test1', + 'vm01.test1-', 'vm01.te$t1', 'vm0#1.test1.', + 'vm01.123.', '-' + 'a' * dns.DNS_LABEL_MAX_LEN, + 'a' * (dns.DNS_LABEL_MAX_LEN + 1), + ('a' * (dns.DNS_LABEL_MAX_LEN - 1) + '.') * + num_labels + 'a' * (filler_len + 1)] + res = self._create_network(fmt=self.fmt, name='net', + admin_state_up=True) + network = self.deserialize(self.fmt, res) + sub_dict = {'gateway': '10.0.0.1', 'cidr': '10.0.0.0/24', + 'ip_version': 4, 'ra_addr_mode': None} + self._make_subnet(self.fmt, network, gateway=sub_dict['gateway'], + cidr=sub_dict['cidr'], + ip_version=sub_dict['ip_version'], + ipv6_ra_mode=sub_dict['ra_addr_mode'], + ipv6_address_mode=sub_dict['ra_addr_mode']) + for dns_name in dns_names: + res = self._create_port(self.fmt, net_id=network['network']['id'], + dns_name=dns_name) + self.assertEqual(res.status_code, 400) + is_expected_message = ( + 'cannot be converted to lowercase string' in res.text or + 'not a valid PQDN or FQDN. Reason:' in res.text) + self.assertTrue(is_expected_message) + + def test_api_extension_validation_with_good_dns_names(self): + cfg.CONF.set_override('dns_domain', 'example.com') + higher_labels_len = len('example.com.') + num_labels = int( + math.floor((dns.FQDN_MAX_LEN - higher_labels_len) / + dns.DNS_LABEL_MAX_LEN)) + filler_len = int( + math.floor((dns.FQDN_MAX_LEN - higher_labels_len) % + dns.DNS_LABEL_MAX_LEN)) + dns_names = ['', 'www.1000.com', 'vM01', 'vm01.example.com.', + '8vm01', 'vm-01.example.com.', 'vm01.test', + 'vm01.test.example.com.', 'vm01.test-100', + 'vm01.test-100.example.com.', + 'a' * dns.DNS_LABEL_MAX_LEN, + ('a' * dns.DNS_LABEL_MAX_LEN) + '.example.com.', + ('a' * (dns.DNS_LABEL_MAX_LEN - 1) + '.') * + num_labels + 'a' * (filler_len - 1)] + res = self._create_network(fmt=self.fmt, name='net', + admin_state_up=True) + network = self.deserialize(self.fmt, res) + sub_dict = {'gateway': '10.0.0.1', 'cidr': '10.0.0.0/24', + 'ip_version': 4, 'ra_addr_mode': None} + self._make_subnet(self.fmt, network, gateway=sub_dict['gateway'], + cidr=sub_dict['cidr'], + ip_version=sub_dict['ip_version'], + ipv6_ra_mode=sub_dict['ra_addr_mode'], + ipv6_address_mode=sub_dict['ra_addr_mode']) + for dns_name in dns_names: + res = self._create_port(self.fmt, net_id=network['network']['id'], + dns_name=dns_name) + self.assertEqual(res.status_code, 201)