diff --git a/neutron_lib/api/definitions/__init__.py b/neutron_lib/api/definitions/__init__.py index 247c5bfa0..141bd20ae 100644 --- a/neutron_lib/api/definitions/__init__.py +++ b/neutron_lib/api/definitions/__init__.py @@ -13,6 +13,7 @@ from neutron_lib.api.definitions import bgpvpn from neutron_lib.api.definitions import bgpvpn_routes_control from neutron_lib.api.definitions import data_plane_status +from neutron_lib.api.definitions import dns from neutron_lib.api.definitions import extra_dhcp_opt from neutron_lib.api.definitions import fip64 from neutron_lib.api.definitions import firewall @@ -36,6 +37,7 @@ _ALL_API_DEFINITIONS = { bgpvpn, bgpvpn_routes_control, data_plane_status, + dns, extra_dhcp_opt, fip64, firewall, diff --git a/neutron_lib/api/definitions/dns.py b/neutron_lib/api/definitions/dns.py new file mode 100644 index 000000000..170e0815b --- /dev/null +++ b/neutron_lib/api/definitions/dns.py @@ -0,0 +1,116 @@ +# 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 neutron_lib.api import converters as convert +from neutron_lib.api.definitions import l3 +from neutron_lib.api.definitions import network +from neutron_lib.api.definitions import port +from neutron_lib.api import validators +from neutron_lib.api.validators import dns as dns_validator +from neutron_lib.db import constants + +# The alias of the extension. +ALIAS = 'dns-integration' + +# Whether or not this extension is simply signaling behavior to the user +# or it actively modifies the attribute map (mandatory). +IS_SHIM_EXTENSION = False + +# Whether the extension is marking the adoption of standardattr model for +# legacy resources, or introducing new standardattr attributes. False or +# None if the standardattr model is adopted since the introduction of +# resource extension (mandatory). +# If this is True, the alias for the extension should be prefixed with +# 'standard-attr-'. +IS_STANDARD_ATTR_EXTENSION = False + +# The name of the extension (mandatory). +NAME = 'DNS Integration' + +# A prefix for API resources. An empty prefix means that the API is going +# to be exposed at the v2/ level as any other core resource (mandatory). +API_PREFIX = '' + +# The description of the extension (mandatory). +DESCRIPTION = "Provides integration with DNS." + +# A timestamp of when the extension was introduced (mandatory). +UPDATED_TIMESTAMP = "2015-08-15T18:00:00-00:00" + +DNSNAME = 'dns_name' +DNSDOMAIN = 'dns_domain' +DNSASSIGNMENT = 'dns_assignment' + +validators.add_validator('dns_host_name', dns_validator.validate_dns_name) +validators.add_validator('fip_dns_host_name', + dns_validator.validate_fip_dns_name) +validators.add_validator('dns_domain_name', + dns_validator.validate_dns_domain) + +# The resource attribute map for the extension. It is effectively the +# bulk of the API contract alongside ACTION_MAP (mandatory). +RESOURCE_ATTRIBUTE_MAP = { + port.COLLECTION_NAME: { + DNSNAME: {'allow_post': True, 'allow_put': True, + 'default': '', + 'convert_to': convert.convert_string_to_case_insensitive, + 'validate': {'type:dns_host_name': + constants.FQDN_FIELD_SIZE}, + '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.convert_string_to_case_insensitive, + 'validate': {'type:fip_dns_host_name': + constants.FQDN_FIELD_SIZE}, + 'is_visible': True}, + DNSDOMAIN: {'allow_post': True, 'allow_put': False, + 'default': '', + 'convert_to': convert.convert_string_to_case_insensitive, + 'validate': {'type:dns_domain_name': + constants.FQDN_FIELD_SIZE}, + 'is_visible': True}, + }, + network.COLLECTION_NAME: { + DNSDOMAIN: {'allow_post': True, 'allow_put': True, + 'default': '', + 'convert_to': convert.convert_string_to_case_insensitive, + 'validate': {'type:dns_domain_name': + constants.FQDN_FIELD_SIZE}, + 'is_visible': True}, + }, +} + +# The subresource attribute map for the extension. It adds child resources +# to main extension's resource. The subresource map must have a parent and +# a parameters entry. If an extension does not need such a map, None can +# be specified (mandatory). For example: +SUB_RESOURCE_ATTRIBUTE_MAP = {} + +# The action map: it associates verbs with methods to be performed on +# the API resource (mandatory). +ACTION_MAP = {} + +# The action status: it associates response statuses with methods to be +# performed on the API resource (mandatory). +ACTION_STATUS = {} + +# The list of required extensions (mandatory). +REQUIRED_EXTENSIONS = [l3.ALIAS] + +# The list of optional extensions (mandatory). +OPTIONAL_EXTENSIONS = [] diff --git a/neutron_lib/api/validators/dns.py b/neutron_lib/api/validators/dns.py new file mode 100644 index 000000000..a2b16fdfc --- /dev/null +++ b/neutron_lib/api/validators/dns.py @@ -0,0 +1,190 @@ +# 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 + +from oslo_config import cfg + +from neutron_lib._i18n import _ +from neutron_lib.api import validators +from neutron_lib import constants +from neutron_lib.db import constants as db_constants + + +def _validate_dns_format(data, max_len=db_constants.FQDN_FIELD_SIZE): + # 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: + # A trailing period is allowed to indicate that a name is fully + # qualified per RFC 1034 (page 7). + trimmed = data[:-1] if data.endswith('.') else data + if len(trimmed) > max_len: + raise TypeError( + _("'%(trimmed)s' exceeds the %(maxlen)s character FQDN " + "limit") % {'trimmed': trimmed, 'maxlen': max_len}) + labels = trimmed.split('.') + for label in labels: + if not label: + raise TypeError(_("Encountered an empty component")) + if label.endswith('-') or label.startswith('-'): + raise TypeError( + _("Name '%s' must not start or end with a hyphen") % label) + if not re.match(constants.DNS_LABEL_REGEX, label): + raise TypeError( + _("Name '%s' must be 1-63 characters long, each of " + "which can only be alphanumeric or a hyphen") % label) + # RFC 1123 hints that a TLD can't be all numeric. last is a TLD if + # it's an FQDN. + if len(labels) > 1 and re.match("^[0-9]+$", labels[-1]): + raise TypeError( + _("TLD '%s' must not be all numeric") % labels[-1]) + except TypeError as e: + msg = _("'%(data)s' not a valid PQDN or FQDN. Reason: %(reason)s") % { + 'data': data, 'reason': e} + return msg + + +def _validate_dns_name_with_dns_domain(request_dns_name, dns_domain): + # If a PQDN was passed, make sure the FQDN that will be generated is of + # legal size + 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 > db_constants.FQDN_FIELD_SIZE: + 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': db_constants.FQDN_FIELD_SIZE} + 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_config(): + 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(dns_name): + dns_domain = _get_dns_domain_config() + if (dns_domain and dns_domain != constants.DNS_DOMAIN_DEFAULT): + # If CONF.dns_domain is the default value 'openstacklocal', + # neutron don't let the user to assign dns_name to ports + return dns_name + return '' + + +def validate_dns_name(data, max_len=db_constants.FQDN_FIELD_SIZE): + """Validate DNS name. + + This method validates dns name and also needs to have dns_domain in config + because this may call a method which uses the config. + + :param data: The data to validate. + :param max_len: An optional cap on the length of the string. + :returns: None if data is valid, otherwise a human readable message + indicating why validation failed. + """ + msg = _validate_dns_format(data, max_len) + if msg: + return msg + + request_dns_name = _get_request_dns_name(data) + if request_dns_name: + dns_domain = _get_dns_domain_config() + msg = _validate_dns_name_with_dns_domain(request_dns_name, dns_domain) + if msg: + return msg + + +def validate_fip_dns_name(data, max_len=db_constants.FQDN_FIELD_SIZE): + """Validate DNS name for floating IP. + + :param data: The data to validate. + :param max_len: An optional cap on the length of the string. + :returns: None if data is valid, otherwise a human readable message + indicating why validation failed. + """ + msg = validators.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=db_constants.FQDN_FIELD_SIZE): + """Validate DNS domain. + + :param data: The data to validate. + :param max_len: An optional cap on the length of the string. + :returns: None if data is valid, otherwise a human readable message + indicating why validation failed. + """ + msg = validators.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 diff --git a/neutron_lib/constants.py b/neutron_lib/constants.py index 2728511a0..f0627ad9b 100644 --- a/neutron_lib/constants.py +++ b/neutron_lib/constants.py @@ -305,6 +305,11 @@ GENEVE_ENCAP_MIN_OVERHEAD = 30 GRE_ENCAP_OVERHEAD = 22 VXLAN_ENCAP_OVERHEAD = 30 +# For DNS extension +DNS_DOMAIN_DEFAULT = 'openstacklocal.' +DNS_LABEL_MAX_LEN = 63 +DNS_LABEL_REGEX = "^[a-z0-9-]{1,%d}$" % DNS_LABEL_MAX_LEN + class Sentinel(object): """A constant object that does not change even when copied.""" diff --git a/neutron_lib/exceptions/dns.py b/neutron_lib/exceptions/dns.py new file mode 100644 index 000000000..cdcfae438 --- /dev/null +++ b/neutron_lib/exceptions/dns.py @@ -0,0 +1,34 @@ +# 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 neutron_lib._i18n import _ +from neutron_lib import exceptions + + +class DNSDomainNotFound(exceptions.NotFound): + message = _("Domain %(dns_domain)s not found in the external DNS service") + + +class DuplicateRecordSet(exceptions.Conflict): + message = _("Name %(dns_name)s is duplicated in the external DNS service") + + +class ExternalDNSDriverNotFound(exceptions.NotFound): + message = _("External DNS driver %(driver)s could not be found.") + + +class InvalidPTRZoneConfiguration(exceptions.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") diff --git a/neutron_lib/tests/unit/api/definitions/test_dns.py b/neutron_lib/tests/unit/api/definitions/test_dns.py new file mode 100644 index 000000000..bb236356c --- /dev/null +++ b/neutron_lib/tests/unit/api/definitions/test_dns.py @@ -0,0 +1,21 @@ +# 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 neutron_lib.api.definitions import dns +from neutron_lib.api.definitions import l3 +from neutron_lib.tests.unit.api.definitions import base + + +class DnsDefinitionTestCase(base.DefinitionBaseTestCase): + extension_module = dns + extension_resources = (l3.FLOATINGIPS,) + extension_attributes = (dns.DNSNAME, dns.DNSDOMAIN, dns.DNSASSIGNMENT,) diff --git a/neutron_lib/tests/unit/api/validators/test_dns.py b/neutron_lib/tests/unit/api/validators/test_dns.py new file mode 100644 index 000000000..bd0cfb22f --- /dev/null +++ b/neutron_lib/tests/unit/api/validators/test_dns.py @@ -0,0 +1,158 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock + +from neutron_lib.api.validators import dns +from neutron_lib.db import constants as db_constants +from neutron_lib.tests import _base as base + + +class TestDnsValidators(base.BaseTestCase): + + @mock.patch('oslo_config.cfg.CONF') + def test_validate_dns_name(self, CONF): + CONF.dns_domain = '' + msg = dns.validate_dns_name('') + self.assertIsNone(msg) + + CONF.dns_domain = 'example.org.' + dns_name = 'host' + msg = dns.validate_dns_name(dns_name) + self.assertIsNone(msg) + + invalid_data = 'A' * 256 + max_len = 255 + expected = ("'%(data)s' not a valid PQDN or FQDN. Reason: " + "'%(data)s' exceeds the %(maxlen)s character FQDN " + "limit") % {'data': invalid_data, 'maxlen': max_len} + msg = dns.validate_dns_name(invalid_data, max_len) + self.assertEqual(expected, msg) + + invalid_data = '.hostname' + expected = ("'%(data)s' not a valid PQDN or FQDN. Reason: " + "Encountered an empty component") % {'data': invalid_data} + msg = dns.validate_dns_name(invalid_data) + self.assertEqual(expected, msg) + + invalid_data = 'hostname-' + expected = ("'%(data)s' not a valid PQDN or FQDN. Reason: " + "Name '%(data)s' must not start or end with a " + "hyphen") % {'data': invalid_data} + msg = dns.validate_dns_name(invalid_data) + self.assertEqual(expected, msg) + + invalid_data = 'hostname@host' + expected = ("'%(data)s' not a valid PQDN or FQDN. Reason: " + "Name '%(data)s' must be 1-63 characters long, each of " + "which can only be alphanumeric or a " + "hyphen") % {'data': invalid_data} + msg = dns.validate_dns_name(invalid_data) + self.assertEqual(expected, msg) + invalid_suffix = '1234' + invalid_data = 'hostname.' + invalid_suffix + expected = ("'%(data)s' not a valid PQDN or FQDN. Reason: " + "TLD '%(suffix)s' must not be all " + "numeric") % {'data': invalid_data, + 'suffix': invalid_suffix} + msg = dns.validate_dns_name(invalid_data) + self.assertEqual(expected, msg) + + # len(dns_name + dns_domain) > 255 + invalid_domain = 'A' * 250 + '.org.' + CONF.dns_domain = invalid_domain + dns_name = 'hostname' + expected = ("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': len(dns_name), + 'dns_domain': invalid_domain, + 'higher_labels_len': len(invalid_domain) + 1, + 'fqdn_max_len': db_constants.FQDN_FIELD_SIZE} + msg = dns.validate_dns_name(dns_name) + self.assertEqual(expected, msg) + + dns_name = 'host.' + dns_domain = 'example.com.' + CONF.dns_domain = dns_domain + expected = ("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': dns_domain} + msg = dns.validate_dns_name(dns_name) + self.assertEqual(expected, msg) + + def test_validate_fip_dns_name(self): + # Don't run tests duplicated to validate_dns_name() + + msg = dns.validate_fip_dns_name('') + self.assertIsNone(msg) + + msg = dns.validate_fip_dns_name('host') + self.assertIsNone(msg) + + invalid_data = 1234 + expected = "'%s' is not a valid string" % invalid_data + msg = dns.validate_fip_dns_name(invalid_data) + self.assertEqual(expected, msg) + + invalid_data = 'host.' + expected = ("'%s' is a FQDN. It should be a relative domain " + "name") % invalid_data + msg = dns.validate_fip_dns_name(invalid_data) + self.assertEqual(expected, msg) + + length = 10 + invalid_data = 'a' * length + max_len = 12 + expected = ("'%(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": invalid_data, + "length": length, + "max_len": max_len} + msg = dns.validate_fip_dns_name(invalid_data, max_len) + self.assertEqual(expected, msg) + + def test_validate_dns_domain(self): + # Don't run tests duplicated to validate_dns_name() + + msg = dns.validate_dns_domain('') + self.assertIsNone(msg) + + msg = dns.validate_dns_domain('example.com.') + self.assertIsNone(msg) + + invalid_data = 1234 + expected = "'%s' is not a valid string" % invalid_data + msg = dns.validate_dns_domain(invalid_data) + self.assertEqual(expected, msg) + + invalid_data = 'example.com' + expected = "'%s' is not a FQDN" % invalid_data + msg = dns.validate_dns_domain(invalid_data) + self.assertEqual(expected, msg) + + length = 9 + invalid_data = 'a' * length + '.' + max_len = 11 + expected = ("'%(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": invalid_data, + "length": length + 1, + "max_len": max_len} + msg = dns.validate_dns_domain(invalid_data, max_len) + self.assertEqual(expected, msg) diff --git a/releasenotes/notes/dns-api-def-bc24a58f56c5fbfb.yaml b/releasenotes/notes/dns-api-def-bc24a58f56c5fbfb.yaml new file mode 100644 index 000000000..0eea4af28 --- /dev/null +++ b/releasenotes/notes/dns-api-def-bc24a58f56c5fbfb.yaml @@ -0,0 +1,6 @@ +--- +features: + - The ``DNS Integration`` extension API definition has been added as + ``neutron_lib.api.definitions.dns``. + - The ``validate_dns_name``, ``validate_fip_dns_name``, and + ``validate_dns_domain`` are now available as neutron-lib validators. \ No newline at end of file