"dual stack" support for PXE/iPXE

Adds functionality for dual stack capabilities and automatic
population to neutron with the correct response based upon the
IP version of the provisioning/cleaning/rescue or tenant ports.

This was origianlly intended to be separated from removing the
need for [pxe]ip_version, however the resulting code changes
from doing both this and making ironic support dual stacks
touched the same tests and some of the same code, so combined
is simpler.

Change-Id: If7a296001e204ae0c9a49495731052ab33379628
This commit is contained in:
Julia Kreger 2020-03-20 12:57:47 -07:00
parent 1dee25f554
commit cf412bc81e
9 changed files with 216 additions and 30 deletions

View File

@ -373,13 +373,16 @@ def clean_up_pxe_config(task, ipxe_enabled=False):
task.node.uuid))
def _dhcp_option_file_or_url(task, urlboot=False):
def _dhcp_option_file_or_url(task, urlboot=False, ip_version=None):
"""Returns the appropriate file or URL.
:param task: A TaskManager object.
:param url_boot: Boolean value default False to indicate if a
URL should be returned to the file as opposed
to a file.
:param ip_version: Integer representing the version of IP of
to return options for DHCP. Possible options
are 4, and 6.
"""
boot_file = deploy_utils.get_pxe_boot_file(task.node)
# NOTE(TheJulia): There are additional cases as we add new
@ -387,12 +390,16 @@ def _dhcp_option_file_or_url(task, urlboot=False):
if not urlboot:
return boot_file
elif urlboot:
host = utils.wrap_ipv6(CONF.pxe.tftp_server)
if CONF.my_ipv6 and ip_version == 6:
host = utils.wrap_ipv6(CONF.my_ipv6)
else:
host = utils.wrap_ipv6(CONF.pxe.tftp_server)
return "tftp://{host}/{boot_file}".format(host=host,
boot_file=boot_file)
def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False,
ip_version=None):
"""Retrieves the DHCP PXE boot options.
:param task: A TaskManager instance.
@ -404,13 +411,19 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
If [pxe]ip_version is set to `6`, then this option
has no effect as url_boot form is required by DHCPv6
standards.
:param ip_version: The IP version of options to return as values
differ by IP version. Default to [pxe]ip_version.
Possible options are integers 4 or 6.
:returns: Dictionary to be sent to the networking service describing
the DHCP options to be set.
"""
if ip_version:
use_ip_version = ip_version
else:
use_ip_version = int(CONF.pxe.ip_version)
dhcp_opts = []
ip_version = int(CONF.pxe.ip_version)
dhcp_provider_name = CONF.dhcp.dhcp_provider
if ip_version == 4:
if use_ip_version == 4:
boot_file_param = DHCP_BOOTFILE_NAME
else:
# NOTE(TheJulia): Booting with v6 means it is always
@ -421,7 +434,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
# guarded in the configuration, so there is no real sense in having
# anything else here in the event the value is something aside from
# 4 or 6, as there are no other possible values.
boot_file = _dhcp_option_file_or_url(task, url_boot)
boot_file = _dhcp_option_file_or_url(task, url_boot, use_ip_version)
if ipxe_enabled:
# TODO(TheJulia): DHCPv6 through dnsmasq + ipxe matching simply
@ -444,7 +457,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
# added in the Stein cycle which identifies the iPXE User-Class
# directly and is only sent in DHCPv6.
if ip_version != 6:
if use_ip_version != 6:
dhcp_opts.append(
{'opt_name': "tag:!ipxe,%s" % boot_file_param,
'opt_value': boot_file}
@ -463,7 +476,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
else:
# !175 == non-iPXE.
# http://ipxe.org/howto/dhcpd#ipxe-specific_options
if ip_version == 6:
if use_ip_version == 6:
LOG.warning('IPv6 is enabled and the DHCP driver appears set '
'to a plugin aside from "neutron". Node %(name)s '
'may not receive proper DHCPv6 provided '
@ -512,7 +525,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False):
# Append the IP version for all the configuration options
for opt in dhcp_opts:
opt.update({'ip_version': ip_version})
opt.update({'ip_version': use_ip_version})
return dhcp_opts
@ -906,7 +919,16 @@ def prepare_instance_pxe_config(task, image_info,
"""
node = task.node
dhcp_opts = dhcp_options_for_instance(task, ipxe_enabled)
# Generate options for both IPv4 and IPv6, and they can be
# filtered down later based upon the port options.
# TODO(TheJulia): This should be re-tooled during the Victoria
# development cycle so that we call a single method and return
# combined options. The method we currently call is relied upon
# by two eternal projects, to changing the behavior is not ideal.
dhcp_opts = dhcp_options_for_instance(task, ipxe_enabled,
ip_version=4)
dhcp_opts += dhcp_options_for_instance(task, ipxe_enabled,
ip_version=6)
provider = dhcp_factory.DHCPFactory()
provider.update_dhcp(task, dhcp_opts)
pxe_config_path = get_pxe_config_file_path(

View File

@ -239,9 +239,20 @@ netconf_opts = [
cfg.StrOpt('my_ip',
default=netutils.get_my_ipv4(),
sample_default='127.0.0.1',
help=_('IP address of this host. If unset, will determine the '
'IP programmatically. If unable to do so, will use '
'"127.0.0.1".')),
help=_('IPv4 address of this host. If unset, will determine '
'the IP programmatically. If unable to do so, will use '
'"127.0.0.1". NOTE: This field does accept an IPv6 '
'address as an override for templates and URLs, '
'however it is recommended that [DEFAULT]my_ipv6 '
'is used along with DNS names for service URLs for '
'dual-stack environments.')),
cfg.StrOpt('my_ipv6',
default=None,
sample_default='2001:db8::1',
help=_('IP address of this host using IPv6. This value must '
'be supplied via the configuration and cannot be '
'adequately programmatically determined like the '
'[DEFAULT]my_ip parameter for IPv4.')),
]
notification_opts = [

View File

@ -39,9 +39,11 @@ class BaseDHCP(object, metaclass=abc.ABCMeta):
::
[{'opt_name': '67',
'opt_value': 'pxelinux.0'},
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': '66',
'opt_value': '123.123.123.456'}]
'opt_value': '123.123.123.456',
'ip_version': 4}]
:param token: An optional authentication token. Deprecated, use context
:param context: request context
:type context: ironic.common.context.RequestContext
@ -63,9 +65,11 @@ class BaseDHCP(object, metaclass=abc.ABCMeta):
::
[{'opt_name': '67',
'opt_value': 'pxelinux.0'},
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': '66',
'opt_value': '123.123.123.456'}]
'opt_value': '123.123.123.456',
'ip_version': 4}]
:param vifs: A dict with keys 'ports' and 'portgroups' and
dicts as values. Each dict has key/value pairs of the form

View File

@ -14,6 +14,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import ipaddress
import time
from neutronclient.common import exceptions as neutron_client_exc
@ -49,9 +50,11 @@ class NeutronDHCPApi(base.BaseDHCP):
::
[{'opt_name': '67',
'opt_value': 'pxelinux.0'},
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': '66',
'opt_value': '123.123.123.456'}]
'opt_value': '123.123.123.456'},
'ip_version': 4}]
:param token: optional auth token. Deprecated, use context.
:param context: request context
:type context: ironic.common.context.RequestContext
@ -59,8 +62,36 @@ class NeutronDHCPApi(base.BaseDHCP):
"""
super(NeutronDHCPApi, self).update_port_dhcp_opts(
port_id, dhcp_options, token=token, context=context)
port_req_body = {'port': {'extra_dhcp_opts': dhcp_options}}
try:
neutron_client = neutron.get_client(token=token,
context=context)
fip = None
port = neutron_client.show_port(port_id).get('port')
try:
if port:
# TODO(TheJulia): We need to retool this down the
# road so that we handle ports and allow preferences
# for multi-address ports with different IP versions
# and enable operators to possibly select preferences
# for provisionioning operations.
# This is compounded by v6 mainly only being available
# with UEFI machines, so the support matrix also gets
# a little "weird".
# Ideally, we should work on this in Victoria.
fip = port.get('fixed_ips')[0]
except (TypeError, IndexError):
fip = None
update_opts = []
if fip:
ip_version = ipaddress.ip_address(fip['ip_address']).version
for option in dhcp_options:
if option.get('ip_version', 4) == ip_version:
update_opts.append(option)
else:
LOG.error('Requested to update port for port %s, '
'however port lacks an IP address.', port_id)
port_req_body = {'port': {'extra_dhcp_opts': update_opts}}
neutron.update_neutron_port(context, port_id, port_req_body)
except neutron_client_exc.NeutronClientException:
LOG.exception("Failed to update Neutron port %s.", port_id)
@ -75,9 +106,11 @@ class NeutronDHCPApi(base.BaseDHCP):
::
[{'opt_name': '67',
'opt_value': 'pxelinux.0'},
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': '66',
'opt_value': '123.123.123.456'}]
'opt_value': '123.123.123.456',
'ip_version': 4}]
:param vifs: a dict of Neutron port/portgroup dicts
to update DHCP options on. The port/portgroup dict
key should be Ironic port UUIDs, and the values

View File

@ -169,8 +169,16 @@ class PXEBaseMixin(object):
# or was deleted.
pxe_utils.create_ipxe_boot_script()
# Generate options for both IPv4 and IPv6, and they can be
# filtered down later based upon the port options.
# TODO(TheJulia): This should be re-tooled during the Victoria
# development cycle so that we call a single method and return
# combined options. The method we currently call is relied upon
# by two eternal projects, to changing the behavior is not ideal.
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=self.ipxe_enabled)
task, ipxe_enabled=self.ipxe_enabled, ip_version=4)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=self.ipxe_enabled, ip_version=6)
provider = dhcp_factory.DHCPFactory()
provider.update_dhcp(task, dhcp_opts)
@ -259,7 +267,9 @@ class PXEBaseMixin(object):
# If it's going to PXE boot we need to update the DHCP server
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=self.ipxe_enabled)
task, ipxe_enabled=self.ipxe_enabled, ip_version=4)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=self.ipxe_enabled, ip_version=6)
provider = dhcp_factory.DHCPFactory()
provider.update_dhcp(task, dhcp_opts)

View File

@ -48,8 +48,9 @@ class TestNeutron(db_base.DbTestCase):
dhcp_factory.DHCPFactory._dhcp_provider = None
@mock.patch('ironic.common.neutron.get_client', autospec=True)
@mock.patch('ironic.common.neutron.update_neutron_port', autospec=True)
def test_update_port_dhcp_opts(self, update_mock):
def test_update_port_dhcp_opts(self, update_mock, client_mock):
opts = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0'},
{'opt_name': 'tftp-server',
@ -58,6 +59,56 @@ class TestNeutron(db_base.DbTestCase):
'opt_value': '1.1.1.1'}]
port_id = 'fake-port-id'
expected = {'port': {'extra_dhcp_opts': opts}}
port_data = {
"id": port_id,
"fixed_ips": [
{
"ip_address": "192.168.1.3",
}
],
}
client_mock.return_value.show_port.return_value = {'port': port_data}
api = dhcp_factory.DHCPFactory()
with task_manager.acquire(self.context, self.node.uuid) as task:
api.provider.update_port_dhcp_opts(port_id, opts,
context=task.context)
update_mock.assert_called_once_with(
self.context, port_id, expected)
@mock.patch('ironic.common.neutron.get_client', autospec=True)
@mock.patch('ironic.common.neutron.update_neutron_port', autospec=True)
def test_update_port_dhcp_opts_v6(self, update_mock, client_mock):
opts = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': 'tftp-server',
'opt_value': '1.1.1.1',
'ip_version': 4},
{'opt_name': 'server-ip-address',
'opt_value': '1.1.1.1',
'ip_version': 4},
{'opt_name': 'bootfile-url',
'opt_value': 'tftp://::1/file.name',
'ip_version': 6}]
port_id = 'fake-port-id'
expected = {
'port': {
'extra_dhcp_opts': [{
'opt_name': 'bootfile-url',
'opt_value': 'tftp://::1/file.name',
'ip_version': 6}]
}
}
port_data = {
"id": port_id,
"fixed_ips": [
{
"ip_address": "2001:db8::201",
}
],
}
client_mock.return_value.show_port.return_value = {'port': port_data}
api = dhcp_factory.DHCPFactory()
with task_manager.acquire(self.context, self.node.uuid) as task:
@ -66,10 +117,21 @@ class TestNeutron(db_base.DbTestCase):
update_mock.assert_called_once_with(
task.context, port_id, expected)
@mock.patch('ironic.common.neutron.get_client', autospec=True)
@mock.patch('ironic.common.neutron.update_neutron_port', autospec=True)
def test_update_port_dhcp_opts_with_exception(self, update_mock):
def test_update_port_dhcp_opts_with_exception(self, update_mock,
client_mock):
opts = [{}]
port_id = 'fake-port-id'
port_data = {
"id": port_id,
"fixed_ips": [
{
"ip_address": "192.168.1.3",
}
],
}
client_mock.return_value.show_port.return_value = {'port': port_data}
update_mock.side_effect = (
neutron_client_exc.NeutronClientException())

View File

@ -270,11 +270,14 @@ class iPXEBootTestCase(db_base.DbTestCase):
self.node.save()
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True)
task, ipxe_enabled=True, ip_version=4)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
task.driver.boot.prepare_ramdisk(task, {'foo': 'bar'})
mock_deploy_img_info.assert_called_once_with(task.node, mode=mode,
ipxe_enabled=True)
provider_mock.update_dhcp.assert_called_once_with(task, dhcp_opts)
provider_mock.update_dhcp.assert_called_once_with(
task, dhcp_opts)
if self.node.provision_state == states.DEPLOYING:
get_boot_mode_mock.assert_called_once_with(task)
set_boot_device_mock.assert_called_once_with(task,
@ -630,6 +633,8 @@ class iPXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid, ipxe_enabled=True)
task.node.properties['capabilities'] = 'boot_mode:bios'
@ -672,6 +677,8 @@ class iPXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid, ipxe_enabled=True)
task.node.properties['capabilities'] = 'boot_mode:bios'
@ -710,7 +717,9 @@ class iPXEBootTestCase(db_base.DbTestCase):
get_image_info_mock.return_value = image_info
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True)
task, ipxe_enabled=True, ip_version=4)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
task.node.properties['capabilities'] = 'boot_mode:bios'
task.node.driver_internal_info['is_whole_disk_image'] = False
@ -742,6 +751,8 @@ class iPXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
task.node.properties['capabilities'] = 'boot_mode:bios'
task.node.driver_internal_info['is_whole_disk_image'] = True
task.driver.boot.prepare_instance(task)
@ -786,6 +797,8 @@ class iPXEBootTestCase(db_base.DbTestCase):
'boot_from_volume': vol_id}
dhcp_opts = pxe_utils.dhcp_options_for_instance(task,
ipxe_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid, ipxe_enabled=True)
task.node.properties['capabilities'] = 'boot_mode:bios'

View File

@ -86,6 +86,7 @@ class PXEBootTestCase(db_base.DbTestCase):
self.port = obj_utils.create_test_port(self.context,
node_id=self.node.id)
self.config(group='conductor', api_url='http://127.0.0.1:1234/')
self.config(my_ipv6='2001:db8::1')
def test_get_properties(self):
expected = pxe_base.COMMON_PROPERTIES
@ -267,6 +268,8 @@ class PXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
task.driver.boot.prepare_ramdisk(task, {'foo': 'bar'})
mock_deploy_img_info.assert_called_once_with(task.node,
mode=mode,
@ -552,7 +555,9 @@ class PXEBootTestCase(db_base.DbTestCase):
get_image_info_mock.return_value = image_info
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
task, ipxe_enabled=False, ip_version=4)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid)
task.node.properties['capabilities'] = 'boot_mode:bios'
@ -595,6 +600,8 @@ class PXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid)
task.node.properties['capabilities'] = 'boot_mode:bios'
@ -634,6 +641,8 @@ class PXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
task.node.properties['capabilities'] = 'boot_mode:bios'
task.node.driver_internal_info['is_whole_disk_image'] = False
@ -663,6 +672,8 @@ class PXEBootTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
task.node.properties['capabilities'] = 'boot_mode:bios'
task.node.driver_internal_info['is_whole_disk_image'] = True
task.driver.boot.prepare_instance(task)
@ -734,6 +745,8 @@ class PXEBootTestCase(db_base.DbTestCase):
task.node.save()
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid)
task.driver.boot.prepare_instance(task)
@ -830,6 +843,8 @@ class PXERamdiskDeployTestCase(db_base.DbTestCase):
with task_manager.acquire(self.context, self.node.uuid) as task:
dhcp_opts = pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6)
pxe_config_path = pxe_utils.get_pxe_config_file_path(
task.node.uuid)
task.node.properties['capabilities'] = 'boot_option:netboot'

View File

@ -0,0 +1,16 @@
---
features:
- |
Adds functionality with neutron integration to support dual-stack
(IPv4 and IPv6 environment configurations). This enables ironic to
look up the attached port(s) and supply DHCP options in alignment
with the protocol version allocated on the port.
upgrade:
- |
The ``[pxe]ip_version`` setting may no longer be required depending on
neutron integration.
- |
Operators that used the ``[DEFAULT]my_ip`` setting with an IPv6 address
may wish to explore migrating to the ``[DEFAULT]my_ipv6`` setting. Setting
both values enables the appropriate IP addresses based on protocol version
for PXE/iPXE.