Add support code for multicast DNS service discovery

To simplify standalone configuration, we need an ability for IPA
to discovery ironic and ironic-inspector on the local network.
This change adds the support code for using multicast DNS (RFC 6762)
with DNS service discovery (RFC 6763) to publish and discover
OpenStack services as proposed in the API SIG guideline
https://review.opendev.org/651222.

Change-Id: Iaf3422331238884412ce608c0667de7891945f98
Story: #2005393
Task: #30432
This commit is contained in:
Dmitry Tantsur 2019-04-24 18:06:59 +02:00
parent 8a798680e1
commit c90d008a5b
6 changed files with 486 additions and 0 deletions

View File

@ -103,3 +103,11 @@ class FileSystemNotSupported(IronicException):
class InvalidMetricConfig(IronicException):
message = _("Invalid value for metrics config option: %(reason)s")
class ServiceLookupFailure(IronicException):
message = _("Cannot find %(service)s service through multicast")
class ServiceRegistrationFailure(IronicException):
message = _("Cannot register %(service)s service: %(error)s")

254
ironic_lib/mdns.py Normal file
View File

@ -0,0 +1,254 @@
# 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.
"""Multicast DNS implementation for API discovery.
This implementation follows RFC 6763 as clarified by the API SIG guideline
https://review.opendev.org/651222.
"""
import collections
import socket
import time
from oslo_config import cfg
from oslo_log import log as logging
from six.moves.urllib import parse
import zeroconf
from ironic_lib.common.i18n import _
from ironic_lib import exception
opts = [
cfg.IntOpt('registration_attempts',
min=1, default=5,
help='Number of attempts to register a service. Currently '
'has to be larger than 1 because of race conditions '
'in the zeroconf library.'),
cfg.IntOpt('lookup_attempts',
min=1, default=3,
help='Number of attempts to lookup a service.'),
cfg.DictOpt('params',
default={},
help='Additional parameters to pass for the registered '
'service.'),
]
CONF = cfg.CONF
opt_group = cfg.OptGroup(name='mdns', title='Options for multicast DNS')
CONF.register_group(opt_group)
CONF.register_opts(opts, opt_group)
LOG = logging.getLogger(__name__)
_MDNS_DOMAIN = '_openstack._tcp.local.'
_endpoint = collections.namedtuple('Endpoint',
['ip', 'hostname', 'port', 'params'])
class Zeroconf(object):
"""Multicast DNS implementation client and server.
Uses threading internally, so there is no start method. It starts
automatically on creation.
.. warning::
The underlying library does not yet support IPv6.
"""
def __init__(self):
"""Initialize and start the mDNS server."""
self._zc = zeroconf.Zeroconf()
self._registered = []
def register_service(self, service_type, endpoint, params=None):
"""Register a service.
This call announces the new services via multicast and instructs the
built-in server to respond to queries about it.
:param service_type: OpenStack service type, e.g. "baremetal".
:param endpoint: full endpoint to reach the service.
:param params: optional properties as a dictionary.
:raises: :exc:`.ServiceRegistrationFailure` if the service cannot be
registered, e.g. because of conflicts.
"""
try:
parsed = _parse_endpoint(endpoint)
except socket.error as ex:
msg = (_("Cannot resolve the host name of %(endpoint)s: "
"%(error)s. Hint: only IPv4 is supported for now.") %
{'endpoint': endpoint, 'error': ex})
raise exception.ServiceRegistrationFailure(
service=service_type, error=msg)
all_params = CONF.mdns.params.copy()
if params:
all_params.update(params)
all_params.update(parsed.params)
# TODO(dtantsur): allow overriding TTL values via configuration when
# https://github.com/jstasiak/python-zeroconf/commit/ecc021b7a3cec863eed5a3f71a1f28e3026c25b0
# is released.
info = zeroconf.ServiceInfo(_MDNS_DOMAIN,
'%s.%s' % (service_type, _MDNS_DOMAIN),
parsed.ip, parsed.port,
properties=all_params,
server=parsed.hostname)
LOG.debug('Registering %s via mDNS', info)
# Work around a potential race condition in the registration code:
# https://github.com/jstasiak/python-zeroconf/issues/163
delay = 0.1
try:
for attempt in range(CONF.mdns.registration_attempts):
try:
self._zc.register_service(info)
except zeroconf.NonUniqueNameException:
LOG.debug('Could not register %s - conflict', info)
if attempt == CONF.mdns.registration_attempts - 1:
raise
# reset the cache to purge learned records and retry
self._zc.cache = zeroconf.DNSCache()
time.sleep(delay)
delay *= 2
else:
break
except zeroconf.Error as exc:
raise exception.ServiceRegistrationFailure(
service=service_type, error=exc)
self._registered.append(info)
def get_endpoint(self, service_type):
"""Get an endpoint and its properties from mDNS.
If the requested endpoint is already in the built-in server cache, and
its TTL is not exceeded, the cached value is returned.
:param service_type: OpenStack service type.
:returns: tuple (endpoint URL, properties as a dict).
:raises: :exc:`.ServiceLookupFailure` if the service cannot be found.
"""
delay = 0.1
for attempt in range(CONF.mdns.lookup_attempts):
name = '%s.%s' % (service_type, _MDNS_DOMAIN)
info = self._zc.get_service_info(name, name)
if info is not None:
break
elif attempt == CONF.mdns.lookup_attempts - 1:
raise exception.ServiceLookupFailure(service=service_type)
else:
time.sleep(delay)
delay *= 2
# TODO(dtantsur): IPv6 support
address = socket.inet_ntoa(info.address)
properties = info.properties.copy()
path = properties.pop('path', '')
protocol = properties.pop('protocol', None)
if not protocol:
if info.port == 80:
protocol = 'http'
else:
protocol = 'https'
if info.server.endswith('.local.'):
# Local hostname means that the catalog lists an IP address,
# so use it
host = address
else:
# Otherwise use the provided hostname.
host = info.server.rstrip('.')
return ('{proto}://{host}:{port}{path}'.format(proto=protocol,
host=host,
port=info.port,
path=path),
properties)
def close(self):
"""Shut down mDNS and unregister services.
.. note::
If another server is running for the same services, it will
re-register them immediately.
"""
for info in self._registered:
try:
self._zc.unregister_service(info)
except Exception:
LOG.exception('Cound not unregister mDNS service %s', info)
self._zc.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def get_endpoint(service_type):
"""Get an endpoint and its properties from mDNS.
If the requested endpoint is already in the built-in server cache, and
its TTL is not exceeded, the cached value is returned.
:param service_type: OpenStack service type.
:returns: tuple (endpoint URL, properties as a dict).
:raises: :exc:`.ServiceLookupFailure` if the service cannot be found.
"""
with Zeroconf() as zc:
return zc.get_endpoint(service_type)
def _parse_endpoint(endpoint):
params = {}
url = parse.urlparse(endpoint)
port = url.port
if port is None:
if url.scheme == 'https':
port = 443
else:
port = 80
hostname = url.hostname
# FIXME(dtantsur): the zeroconf library does not support IPv6, use IPv4
# only resolving for now.
ip = socket.gethostbyname(hostname)
if ip == hostname:
# we need a host name for the service record. if what we have in
# the catalog is an IP address, use the local hostname instead
hostname = None
# zeroconf requires addresses in network format (and see above re IPv6)
ip = socket.inet_aton(ip)
# avoid storing information that can be derived from existing data
if url.path not in ('', '/'):
params['path'] = url.path
if (not (port == 80 and url.scheme == 'http')
and not (port == 443 and url.scheme == 'https')):
params['protocol'] = url.scheme
# zeroconf is pretty picky about having the trailing dot
if hostname is not None and not hostname.endswith('.'):
hostname += '.'
return _endpoint(ip, hostname, port, params)
def list_opts():
"""Entry point for oslo-config-generator."""
return [('mdns', opts)]

View File

@ -0,0 +1,220 @@
# 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 socket
import mock
from oslo_config import cfg
import zeroconf
from ironic_lib import exception
from ironic_lib import mdns
from ironic_lib.tests import base
CONF = cfg.CONF
@mock.patch.object(zeroconf, 'Zeroconf', autospec=True)
class RegisterServiceTestCase(base.IronicLibTestCase):
def test_ok(self, mock_zc):
zc = mdns.Zeroconf()
zc.register_service('baremetal', 'https://127.0.0.1/baremetal')
mock_zc.return_value.register_service.assert_called_once_with(mock.ANY)
info = mock_zc.return_value.register_service.call_args[0][0]
self.assertEqual('_openstack._tcp.local.', info.type)
self.assertEqual('baremetal._openstack._tcp.local.', info.name)
self.assertEqual('127.0.0.1', socket.inet_ntoa(info.address))
self.assertEqual({'path': '/baremetal'}, info.properties)
def test_with_params(self, mock_zc):
CONF.set_override('params', {'answer': 'none', 'foo': 'bar'},
group='mdns')
zc = mdns.Zeroconf()
zc.register_service('baremetal', 'https://127.0.0.1/baremetal',
params={'answer': 42})
mock_zc.return_value.register_service.assert_called_once_with(mock.ANY)
info = mock_zc.return_value.register_service.call_args[0][0]
self.assertEqual('_openstack._tcp.local.', info.type)
self.assertEqual('baremetal._openstack._tcp.local.', info.name)
self.assertEqual('127.0.0.1', socket.inet_ntoa(info.address))
self.assertEqual({'path': '/baremetal',
'answer': 42,
'foo': 'bar'},
info.properties)
@mock.patch.object(mdns.time, 'sleep', autospec=True)
def test_with_race(self, mock_sleep, mock_zc):
mock_zc.return_value.register_service.side_effect = [
zeroconf.NonUniqueNameException,
zeroconf.NonUniqueNameException,
zeroconf.NonUniqueNameException,
None
]
zc = mdns.Zeroconf()
zc.register_service('baremetal', 'https://127.0.0.1/baremetal')
mock_zc.return_value.register_service.assert_called_with(mock.ANY)
self.assertEqual(4, mock_zc.return_value.register_service.call_count)
mock_sleep.assert_has_calls([mock.call(i) for i in (0.1, 0.2, 0.4)])
@mock.patch.object(mdns.time, 'sleep', autospec=True)
def test_failure(self, mock_sleep, mock_zc):
mock_zc.return_value.register_service.side_effect = (
zeroconf.NonUniqueNameException
)
zc = mdns.Zeroconf()
self.assertRaises(exception.ServiceRegistrationFailure,
zc.register_service,
'baremetal', 'https://127.0.0.1/baremetal')
mock_zc.return_value.register_service.assert_called_with(mock.ANY)
self.assertEqual(CONF.mdns.registration_attempts,
mock_zc.return_value.register_service.call_count)
self.assertEqual(CONF.mdns.registration_attempts - 1,
mock_sleep.call_count)
class ParseEndpointTestCase(base.IronicLibTestCase):
def test_simple(self):
endpoint = mdns._parse_endpoint('http://127.0.0.1')
self.assertEqual('127.0.0.1', socket.inet_ntoa(endpoint.ip))
self.assertEqual(80, endpoint.port)
self.assertEqual({}, endpoint.params)
self.assertIsNone(endpoint.hostname)
def test_simple_https(self):
endpoint = mdns._parse_endpoint('https://127.0.0.1')
self.assertEqual('127.0.0.1', socket.inet_ntoa(endpoint.ip))
self.assertEqual(443, endpoint.port)
self.assertEqual({}, endpoint.params)
self.assertIsNone(endpoint.hostname)
def test_with_path_and_port(self):
endpoint = mdns._parse_endpoint('http://127.0.0.1:8080/bm')
self.assertEqual('127.0.0.1', socket.inet_ntoa(endpoint.ip))
self.assertEqual(8080, endpoint.port)
self.assertEqual({'path': '/bm', 'protocol': 'http'}, endpoint.params)
self.assertIsNone(endpoint.hostname)
@mock.patch.object(socket, 'gethostbyname', autospec=True)
def test_resolve(self, mock_resolve):
mock_resolve.return_value = '1.2.3.4'
endpoint = mdns._parse_endpoint('http://example.com')
self.assertEqual('1.2.3.4', socket.inet_ntoa(endpoint.ip))
self.assertEqual(80, endpoint.port)
self.assertEqual({}, endpoint.params)
self.assertEqual('example.com.', endpoint.hostname)
mock_resolve.assert_called_once_with('example.com')
@mock.patch('zeroconf.Zeroconf', autospec=True)
class GetEndpointTestCase(base.IronicLibTestCase):
def test_simple(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=80,
properties={}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('http://192.168.1.1:80', endp)
self.assertEqual({}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
mock_zc.return_value.close.assert_called_once_with()
def test_https(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=443,
properties={}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('https://192.168.1.1:443', endp)
self.assertEqual({}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
def test_with_custom_port_and_path(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=8080,
properties={'path': '/baremetal'}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('https://192.168.1.1:8080/baremetal', endp)
self.assertEqual({}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
def test_with_custom_port_path_and_protocol(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=8080,
properties={'path': '/baremetal', 'protocol': 'http'}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('http://192.168.1.1:8080/baremetal', endp)
self.assertEqual({}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
def test_with_params(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=80,
properties={'ipa_debug': True}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('http://192.168.1.1:80', endp)
self.assertEqual({'ipa_debug': True}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
def test_with_server(self, mock_zc):
mock_zc.return_value.get_service_info.return_value = mock.Mock(
address=socket.inet_aton('192.168.1.1'),
port=443,
server='openstack.example.com.',
properties={}
)
endp, params = mdns.get_endpoint('baremetal')
self.assertEqual('https://openstack.example.com:443', endp)
self.assertEqual({}, params)
mock_zc.return_value.get_service_info.assert_called_once_with(
'baremetal._openstack._tcp.local.',
'baremetal._openstack._tcp.local.'
)
@mock.patch('time.sleep', autospec=True)
def test_not_found(self, mock_sleep, mock_zc):
mock_zc.return_value.get_service_info.return_value = None
self.assertRaisesRegex(exception.ServiceLookupFailure,
'baremetal service',
mdns.get_endpoint, 'baremetal')
self.assertEqual(CONF.mdns.lookup_attempts - 1, mock_sleep.call_count)

View File

@ -73,3 +73,4 @@ traceback2==1.4.0
unittest2==1.1.0
WebOb==1.7.1
wrapt==1.7.0
zeroconf==0.19.1

View File

@ -12,3 +12,5 @@ oslo.utils>=3.33.0 # Apache-2.0
requests>=2.14.2 # Apache-2.0
six>=1.10.0 # MIT
oslo.log>=3.36.0 # Apache-2.0
zeroconf>=0.19.1,<0.20;python_version=='2.7' # LGPL
zeroconf>=0.19.1;python_version>='3.0' # LGPL

View File

@ -26,6 +26,7 @@ packages =
oslo.config.opts =
ironic_lib.disk_partitioner = ironic_lib.disk_partitioner:list_opts
ironic_lib.disk_utils = ironic_lib.disk_utils:list_opts
ironic_lib.mdns = ironic_lib.mdns:list_opts
ironic_lib.utils = ironic_lib.utils:list_opts
ironic_lib.metrics = ironic_lib.metrics_utils:list_opts
ironic_lib.metrics_statsd = ironic_lib.metrics_statsd:list_opts