undercloud: resolve undercloud_*_host

The problem we're solving here is that our operators using SSL + FQDN
based endpoints will have failures during the deployment because we
don't lookup the FQDN into IP addresses, needed later in the deployment
for proper binding.

This patch transforms undercloud_*_host parameters into IP addresses:

- We raise if lookup returns nothing.
- We raise if lookup returns more than one IP.
- We support both IPv4 and IPv6.
- We raise if the IP is a loopback.
- We raise if the returned IP is invalid.

Utils changes:

* Introduce utils.is_valid_ip.
  Return True if the IP is either v4 or v6. Return False otherwise.

* Introduce utils.is_loopback.
  Return True if the given host is a loopback. Return False otherwise.

* Introduce utils.get_host_ips.
  Returns a list of IPs for a host to lookup.

* Introduce utils.get_single_ip.
  Translate an hostname or FQDN into an IP address if it is valid IP.
  Return it unchanged if it is an IPv4 or IPv6 address.
  If the host is not reachable, it'll raise an exception.
  By default it excludes the loopbacks but it can be allowed by setting
  allow_loopback = True.

* Use utils.get_single_ip to translate undercloud_admin_host and
  undercloud_public_host to IP addresses.

Related-Bug: #1763776
Change-Id: Ic008cc758493aa95e8aa237d23c2f66c0a930509
This commit is contained in:
Emilien Macchi 2019-04-25 15:38:41 -04:00
parent 51a9880702
commit c925c3b93b
4 changed files with 163 additions and 13 deletions

View File

@ -41,6 +41,10 @@ class NotFound(Base):
"""Resource not found"""
class LookupError(Base):
"""Lookup Error"""
class DeploymentError(Base):
"""Deployment failed"""

View File

@ -956,6 +956,70 @@ class TestBracketIPV6(TestCase):
self.assertEqual('[::1]', result)
class TestIsValidIP(TestCase):
def test_with_valid_ipv4(self):
result = utils.is_valid_ip('192.168.0.1')
self.assertEqual(True, result)
def test_with_valid_ipv6(self):
result = utils.is_valid_ip('::1')
self.assertEqual(True, result)
def test_with_invalid_ip(self):
result = utils.is_valid_ip('192.168.1%bad')
self.assertEqual(False, result)
class TestIsLoopback(TestCase):
def test_with_loopback(self):
result = utils.is_loopback('127.0.0.1')
self.assertEqual(True, result)
def test_with_no_loopback(self):
result = utils.is_loopback('10.0.0.1')
self.assertEqual(False, result)
class TestGetHostIps(TestCase):
def test_get_host_ips(self):
with mock.patch.object(socket, 'getaddrinfo') as mock_addrinfo:
mock_addrinfo.return_value = [('', '', 6, '', ('127.0.0.1', 0))]
result = utils.get_host_ips('myhost.domain')
self.assertEqual(['127.0.0.1'], result)
class TestGetSingleIp(TestCase):
def test_with_fqdn_and_valid_ip(self):
with mock.patch.object(utils, 'get_host_ips') as mock_gethostips:
mock_gethostips.return_value = ['192.168.0.1']
result = utils.get_single_ip('myhost.domain')
self.assertEqual('192.168.0.1', result)
def test_with_fqdn_and_loopback(self):
with mock.patch.object(utils, 'get_host_ips') as mock_gethostips:
mock_gethostips.return_value = ['127.0.0.1']
self.assertRaises(exceptions.LookupError,
utils.get_single_ip, 'myhost.domain')
def test_with_too_much_ips(self):
with mock.patch.object(utils, 'get_host_ips') as mock_gethostips:
mock_gethostips.return_value = ['192.168.0.1', '192.168.0.2']
self.assertRaises(exceptions.LookupError,
utils.get_single_ip, 'myhost.domain')
def test_without_ip(self):
with mock.patch.object(utils, 'get_host_ips') as mock_gethostips:
mock_gethostips.return_value = []
self.assertRaises(exceptions.LookupError,
utils.get_single_ip, 'myhost.domain')
def test_with_invalid_ip(self):
with mock.patch.object(utils, 'get_host_ips') as mock_gethostips:
mock_gethostips.return_value = ['192.168.23.x']
self.assertRaises(exceptions.LookupError,
utils.get_single_ip, 'myhost.domain')
class TestStoreCliParam(TestCase):
def setUp(self):

View File

@ -24,6 +24,7 @@ import shutil
from six.moves.configparser import ConfigParser
import json
import netaddr
import os
import os.path
import simplejson
@ -171,6 +172,84 @@ def bracket_ipv6(address):
return address
def is_valid_ip(ip):
"""Return True if the IP is either v4 or v6
Return False if invalid.
"""
return netaddr.valid_ipv4(ip) or netaddr.valid_ipv6(ip)
def is_loopback(host):
"""Return True of the IP or the host is a loopback
Return False if not.
"""
loopbacks = ['127', '::1']
for l in loopbacks:
if host.startswith(l):
return True
return False
def get_host_ips(host, type=None):
"""Lookup an host to return a list of IPs.
:param host: Host to lookup
:type host: string
:param type: Type of socket (e.g. socket.AF_INET, socket.AF_INET6)
:type type: string
"""
ips = set()
if type:
types = (type,)
else:
types = (socket.AF_INET, socket.AF_INET6)
for t in types:
try:
res = socket.getaddrinfo(host, None, t, socket.SOCK_STREAM)
except socket.error:
continue
nips = set([x[4][0] for x in res])
ips.update(nips)
return list(ips)
def get_single_ip(host, allow_loopback=False):
"""Translate an hostname into a single IP address if it is a valid IP.
:param host: IP or hostname or FQDN to lookup
:type host: string
:param allow_loopback: Whether or not a loopback IP can be returned.
Defaults is False.
:type allow_loopback: boolean
Return the host unchanged if it is already an IPv4 or IPv6 address.
"""
ip = host
if not is_valid_ip(host):
ips = get_host_ips(host)
if not ips:
raise exceptions.LookupError('No IP was found for the host: '
'%s' % host)
else:
ip = ips[0]
if len(ips) > 1:
raise exceptions.LookupError('More than one IP was found for the '
'host %s: %s' % (host, ips))
if not allow_loopback and is_loopback(ip):
raise exceptions.LookupError('IP address for host %s is a loopback'
' IP: %s' % (host, ip))
if not is_valid_ip(ip):
raise exceptions.LookupError('IP address for host %s is not a '
'valid IP: %s' % (host, ip))
return ip
def write_env_file(env_data, env_file, registry_overwrites):
"""Write the tht env file as yaml"""

View File

@ -306,8 +306,10 @@ def _calculate_allocation_pools(subnet):
# Remove gateway, local_ip, admin_host and public_host addresses
ip_set.remove(netaddr.IPAddress(subnet.get('gateway')))
ip_set.remove(netaddr.IPNetwork(CONF.local_ip).ip)
ip_set.remove(netaddr.IPNetwork(CONF.undercloud_admin_host))
ip_set.remove(netaddr.IPNetwork(CONF.undercloud_public_host))
ip_set.remove(netaddr.IPNetwork(utils.get_single_ip(
CONF.undercloud_admin_host)))
ip_set.remove(netaddr.IPNetwork(utils.get_single_ip(
CONF.undercloud_public_host)))
# Remove addresses in the inspection_iprange
inspect_start, inspect_end = subnet.get('inspection_iprange').split(',')
ip_set.remove(netaddr.IPRange(inspect_start, inspect_end))
@ -581,18 +583,19 @@ def prepare_undercloud_deploy(upgrade=False, no_validations=False,
CONF.get('undercloud_service_certificate')):
endpoint_environment = _get_tls_endpoint_environment(
CONF.get('undercloud_public_host'), tht_templates)
try:
public_host = CONF.get('undercloud_public_host')
public_host = utils.get_single_ip(CONF.get('undercloud_public_host'))
netaddr.IPAddress(public_host)
deploy_args += ['--public-virtual-ip', public_host]
admin_host = CONF.get('undercloud_admin_host')
# To make sure the resolved host is set to the right IP in /etc/hosts
if not utils.is_valid_ip(CONF.get('undercloud_public_host')):
extra_host = public_host + ' ' + CONF.get('undercloud_public_host')
env_data['ExtraHostFileEntries'] = extra_host
admin_host = utils.get_single_ip(CONF.get('undercloud_admin_host'))
netaddr.IPAddress(admin_host)
deploy_args += ['--control-virtual-ip', admin_host]
except netaddr.core.AddrFormatError:
# TODO(jaosorior): We could do a reverse lookup for the hostnames
# if the *_host variables are DNS names and not IPs.
pass
deploy_args += [
'-e', endpoint_environment,