Merge "Add HTTP versions of network boot interfaces"

This commit is contained in:
Zuul 2024-02-12 18:52:24 +00:00 committed by Gerrit Code Review
commit b0e443f77f
15 changed files with 740 additions and 70 deletions

View File

@ -39,6 +39,27 @@ their specific implementations of the PXE boot interface.
Additional configuration is required for this boot interface - see
:doc:`/install/configure-pxe` for details.
HTTP Boot
---------
The ``http`` and ``http-ipxe`` boot interfaces are based upon the Ironic
implementation of the ``pxe`` and ``ipxe`` boot interfaces, respectively,
and utilize HTTP in the transmission of the location to start the
boot sequence from. These interfaces are specific to UEFI as they are rooted
in the UEFI standard v2.5's support for booting from an HTTP URL.
One caveat to keep in mind is that these interfaces require hardware support
and the ability to signal to the remote BMC that the node should boot
utilizing ``UEFIHTTP``. If a hardware type does not support that as an option,
we will fallback and request ``PXE`` boot, but that realistically may only
work if the firmware on the machine is smart enough to check and evaluate
for an HTTP Boot URL instead of a PXE boot server and file name.
It should be noted, that these boot interfaces are available for the vendor
independent, generic hardware types of ``ipmi`` and ``redfish``. Hardware
vendors typically only include additional interfaces after they have performed
their own verification and qualification testing.
Kernel parameters
~~~~~~~~~~~~~~~~~

View File

@ -48,6 +48,13 @@ is being worked in Neutron
`change 890683 <https://review.opendev.org/c/openstack/neutron/+/890683>`_ and
`bug 20305201 <https://bugs.launchpad.net/neutron/+bug/20305201>`_.
.. warning::
Use of OVN with HTTPBoot interfaces has not been explicitly tested by the
Ironic project, and is unlikely to take place until after integrated IPv6
support with Neutron is ready for use. The project does not expect any
specific issues, but the OVN DHCP server is an entirely different server
than the interfaces were tested upon.
Maxmium Transmission Units
--------------------------

View File

@ -4,9 +4,13 @@ Configure the Networking service for bare metal provisioning
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You need to configure Networking so that the bare metal server can communicate
with the Networking service for DHCP, PXE boot and other requirements.
with the Networking service for DHCP, PXE/HTTP boot and other requirements.
This section covers configuring Networking for a single flat network for bare
metal provisioning.
metal provisioning. In more advanced configurations, we typically refer to
the network upon which nodes undergo deployment as the provisioning network,
as the underlying resources to provision the node must be available for
successful operations.
.. Warning:: This docuemntation is geared for use of OVS with Neutron along
with the ``neutron-dhcp-agent``. It *is* possible to use OVN

View File

@ -1,10 +1,28 @@
Configuring PXE and iPXE
Configuring Network Boot
========================
Ironic's primary means of booting hardware to perform actions or work on a
baremetal node is to perform network booting. Traditionally, this has meant
the use of Preboot Execution Environment, or PXE. This support and
and functionality has evolve as time has gone on to include support for not
just the ``pxe`` ``boot_interface`` in concert with hardware vendor specific
variations, but also a distinct ``ipxe`` setting for ``boot_interface`` with
default values to enable use of `iPXE <https://ipxe.org/>`_.
As time passed, ``http`` and ``http-ipxe`` values were also added as valid
``boot_interface`` options which may be used, which are functionally identical
in behavior to ``pxe`` and ``ipxe``, except HTTP is used as the transport
mechanism. Not all hardware supports HTTPBoot, as it is often referred.
.. note::
Support for HTTPBoot interfaces was added during the 2024.1 development
cycle. Prior versions of Ironic does not contain the ``http`` and
``http-ipxe`` boot interfaces.
DHCP server setup
-----------------
A DHCP server is required by PXE/iPXE client. You need to follow steps below.
A DHCP server is required for network boot clients. You need to follow steps below.
#. Set the ``[dhcp]/dhcp_provider`` to ``neutron`` in the Bare Metal Service's
configuration file (``/etc/ironic/ironic.conf``):
@ -15,7 +33,8 @@ A DHCP server is required by PXE/iPXE client. You need to follow steps below.
defaults, and when you create subnet, DHCP is also enabled if you do not add
any dhcp options at "openstack subnet create" command.
#. Enable DHCP in the subnet of PXE network.
#. Enable DHCP in the subnet of provisioning network to be used for network
boot (PXE, iPXE, HTTPBoot) operations.
#. Set the ip address range in the subnet for DHCP.
@ -591,3 +610,47 @@ an up-to-date iPXE firmware, you need to bootstrap it from TFTP. The
Finally, put ``ironic-python-agent.kernel`` and
``ironic-python-agent.initramfs`` to ``/httpboot``.
HTTPBoot
--------
HTTPBoot interfaces in Ironic are built upon the underlying network boot
substrate. This means much of the configuration in the ``[pxe]`` and
``[deploy]`` impacts the use of HTTPBoot, except when Ironic is setting
DHCP parameters, it populates a HTTP(S) URL to the DHCP server, which is
then transmitted to the client attempting to Network Boot. In large part,
this is because HTTPBoot is an evolution of PXE Boot technique and
technology.
This means a TFTP server is *not* required, but the HTTP server is
required as if your utilizing iPXE. This is largely because iPXE
has traditionally been leveraged by Operators to limit the TFTP
packets being transmitted via UDP across a network.
One aspect to keep in mind, is HTTPBoot is relatively new when compared
to PXE boot, and not all bootloaders may support HTTPBoot, as the underlying
UEFI standard upon which it was largely based, UEFI v2.5, was published in
2015.
Ironic contains two distinct flavors of HTTPBoot, largely based
upon what configuration defaults are used in terms of boot loader, templates,
and overall mechanism style.
* ``http`` is the boot interface based upon the ``pxe`` boot interface.
This is the interface you would want to use if you had, for example, a
signed GRUB2 bootloader chain to utilize. In this case it is up to the
boot loader to understand how to extract and run with the URL, and then
retrieves any additional configuration loader files and configuration
templates created on disk.
* ``http-ipxe`` is the boot interface based upon the ``ipxe`` boot interface.
This interface signals to the client to utilize the configured iPXE loader
binary over HTTP, and then the boot sequence proceeds with the pattern and
capabilities of iPXE.
To enable the boot interfaces, you will need to add them to your
``[DEFAULT]enabled_boot_interfaces`` configuration entry.
.. code-block:: ini
[DEFAULT]
enabled_boot_interfaces=ipxe,http-ipxe,pxe,http

View File

@ -31,9 +31,9 @@ components.
* The conductor needs access to the `management controller`_ of each node
it manages.
* The conductor co-exists with TFTP (for PXE) and/or HTTP (for iPXE) services
that provide the kernel and ramdisk to boot the nodes. The conductor
manages them by writing files to their root directories.
* The conductor co-exists with TFTP (for PXE) and/or HTTP (for HTTPBoot and
iPXE) services that provide the kernel and ramdisk to boot the nodes.
The conductor manages them by writing files to their root directories.
* If serial console is used, the conductor launches console processes
locally. If the ``nova-serialproxy`` service (part of the Compute service)

View File

@ -48,6 +48,7 @@ LOG = logging.getLogger(__name__)
PXE_CFG_DIR_NAME = CONF.pxe.pxe_config_subdir
DHCP_VENDOR_CLASS_ID = '60' # rfc2132
DHCP_CLIENT_ID = '61' # rfc2132
DHCP_TFTP_SERVER_NAME = '66' # rfc2132
DHCP_BOOTFILE_NAME = '67' # rfc2132
@ -66,8 +67,8 @@ KERNEL_RAMDISK_LABELS = {'deploy': DEPLOY_KERNEL_RAMDISK_LABELS,
'rescue': RESCUE_KERNEL_RAMDISK_LABELS}
def _get_root_dir(ipxe_enabled):
if ipxe_enabled:
def _get_root_dir(use_http_root):
if use_http_root:
return CONF.deploy.http_root
else:
return CONF.pxe.tftp_root
@ -239,7 +240,8 @@ def get_kernel_ramdisk_info(node_uuid, driver_info, mode='deploy',
return image_info
def get_pxe_config_file_path(node_uuid, ipxe_enabled=False):
def get_pxe_config_file_path(node_uuid, ipxe_enabled=False,
http_boot_enabled=False):
"""Generate the path for the node's PXE configuration file.
:param node_uuid: the UUID of the node.
@ -248,7 +250,8 @@ def get_pxe_config_file_path(node_uuid, ipxe_enabled=False):
:returns: The path to the node's PXE configuration file.
"""
return os.path.join(_get_root_dir(ipxe_enabled), node_uuid, 'config')
return os.path.join(
_get_root_dir(ipxe_enabled or http_boot_enabled), node_uuid, 'config')
def get_file_path_from_label(node_uuid, root_dir, label):
@ -433,7 +436,8 @@ def clean_up_pxe_config(task, ipxe_enabled=False):
task.node.uuid))
def _dhcp_option_file_or_url(task, urlboot=False, ip_version=None):
def _dhcp_option_file_or_url(task, urlboot=False, ip_version=None,
http_boot_enabled=False):
"""Returns the appropriate file or URL.
:param task: A TaskManager object.
@ -443,7 +447,11 @@ def _dhcp_option_file_or_url(task, urlboot=False, ip_version=None):
:param ip_version: Integer representing the version of IP of
to return options for DHCP. Possible options
are 4, and 6.
:param http_boot_enabled: If HTTPBoot is utilized, default False.
:raises: InvalidParameterValue if the resulting property length
exceeds Neutron limitations.
"""
result = None
try:
if task.driver.boot.ipxe_enabled:
boot_file = deploy_utils.get_ipxe_boot_file(task.node)
@ -457,18 +465,30 @@ def _dhcp_option_file_or_url(task, urlboot=False, ip_version=None):
# NOTE(TheJulia): There are additional cases as we add new
# features, so the logic below is in the form of if/elif/elif
if not urlboot:
return boot_file
elif urlboot:
result = boot_file
elif urlboot and not http_boot_enabled:
if CONF.my_ipv6 and ip_version == 6:
host = utils.wrap_ipv6(CONF.my_ipv6)
else:
elif not http_boot_enabled:
host = utils.wrap_ipv6(CONF.pxe.tftp_server)
return "tftp://{host}/{boot_file}".format(host=host,
result = "tftp://{host}/{boot_file}".format(host=host,
boot_file=boot_file)
elif http_boot_enabled:
result = "{url}/{boot_file}".format(url=CONF.deploy.http_url,
boot_file=boot_file)
if len(result) > 64:
# This is an internal limitation for Neutron. We cannot send it
# a value longer than 64 characters.
raise exception.InvalidParameterValue('The resulting boot file or '
'URL length exceeds Neutron '
'limitations. Please explore '
'shorter file names or '
'hostnames.')
return result
def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False,
ip_version=None):
ip_version=None, http_boot_enabled=False):
"""Retrieves the DHCP PXE boot options.
:param task: A TaskManager instance.
@ -485,8 +505,16 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False,
Possible options are integers 4 or 6.
:returns: Dictionary to be sent to the networking service describing
the DHCP options to be set.
:raises: InvalidParameterValue if the underlying configuration cannot
be conveyed to Neutron due to resulting value length.
"""
# FIXME(TheJulia): Presently, we determine if we should generate ipxe
# enabled configuration *via* the argument, and that presently gets set
# via the driver's interface, but we ought to double check because
# otherwise it is easy to miss, like when writing tests if you've touched
# this area of the code.
if ip_version:
# IP version defines *which* parameter is used for file name.
use_ip_version = ip_version
else:
use_ip_version = int(CONF.pxe.ip_version)
@ -499,11 +527,15 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False,
# a URL reply.
boot_file_param = DHCPV6_BOOTFILE_NAME
url_boot = True
if http_boot_enabled:
# IF this is for httpboot, just always mark url boot as enabled.
url_boot = True
# NOTE(TheJulia): The ip_version value config from the PXE config is
# 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, use_ip_version)
boot_file = _dhcp_option_file_or_url(task, url_boot, use_ip_version,
http_boot_enabled=http_boot_enabled)
if ipxe_enabled:
# TODO(TheJulia): DHCPv6 through dnsmasq + ipxe matching simply
@ -561,6 +593,11 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False,
else:
dhcp_opts.append({'opt_name': boot_file_param,
'opt_value': boot_file})
if http_boot_enabled and use_ip_version == 4:
# So unlike v6 PXE's use of URLs above, we explicitly need
# to send a vendor class back (option 60, vendor-class in dnsmasq)
dhcp_opts.append({'opt_name': DHCP_VENDOR_CLASS_ID,
'opt_value': 'HTTPClient'})
if not url_boot:
dhcp_opts.append({'opt_name': DHCP_TFTP_SERVER_NAME,
@ -1168,7 +1205,8 @@ def prepare_instance_pxe_config(task, image_info,
iscsi_boot=False,
ramdisk_boot=False,
ipxe_enabled=False,
anaconda_boot=False):
anaconda_boot=False,
http_boot_enabled=False):
"""Prepares the config file for PXE boot
:param task: a task from TaskManager.
@ -1179,6 +1217,8 @@ def prepare_instance_pxe_config(task, image_info,
:param ipxe_enabled: Default false boolean to indicate if ipxe
is in use by the caller.
:param anaconda_boot: if the boot is to a anaconda ramdisk configuration.
:param http_boot_enabled: If httpboot models of use are to be used
with the underlying boot loaders.
:returns: None
"""
node = task.node
@ -1188,14 +1228,19 @@ def prepare_instance_pxe_config(task, image_info,
# 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)
dhcp_opts = dhcp_options_for_instance(
task, ipxe_enabled,
ip_version=4,
http_boot_enabled=http_boot_enabled)
dhcp_opts += dhcp_options_for_instance(
task, ipxe_enabled,
ip_version=6,
http_boot_enabled=http_boot_enabled)
provider = dhcp_factory.DHCPFactory()
provider.update_dhcp(task, dhcp_opts)
pxe_config_path = get_pxe_config_file_path(
node.uuid, ipxe_enabled=ipxe_enabled)
node.uuid, ipxe_enabled=ipxe_enabled,
http_boot_enabled=http_boot_enabled)
if not os.path.isfile(pxe_config_path):
pxe_options = build_pxe_config_options(
task, image_info, service=ramdisk_boot or anaconda_boot,
@ -1354,15 +1399,16 @@ def place_common_config():
if not CONF.pxe.initial_grub_template:
return
grub_dir_path = os.path.join(_get_root_dir(False), 'grub')
for use_http in [False, True]:
# Create paths
grub_dir_path = os.path.join(_get_root_dir(use_http), 'grub')
if not os.path.isdir(grub_dir_path):
fileutils.ensure_tree(grub_dir_path)
if CONF.pxe.dir_permission:
os.chmod(grub_dir_path, CONF.pxe.dir_permission)
# Write templates
initial_grub = utils.render_template(
CONF.pxe.initial_grub_template,
{'tftp_root': _get_root_dir(False)})
initial_grub_path = os.path.join(grub_dir_path, 'grub.cfg')
utils.write_to_file(initial_grub_path, initial_grub)

View File

@ -44,7 +44,7 @@ class GenericHardware(hardware_type.AbstractHardwareType):
@property
def supported_boot_interfaces(self):
"""List of supported boot interfaces."""
return [ipxe.iPXEBoot, pxe.PXEBoot]
return [ipxe.iPXEBoot, pxe.PXEBoot, ipxe.iPXEHttpBoot, pxe.HttpBoot]
@property
def supported_deploy_interfaces(self):

View File

@ -32,3 +32,16 @@ class iPXEBoot(pxe_base.PXEBaseMixin, base.BootInterface):
pxe_utils.place_loaders_for_boot(CONF.deploy.http_root)
# This is required to serve the iPXE binary via tftp
pxe_utils.place_loaders_for_boot(CONF.pxe.tftp_root)
class iPXEHttpBoot(pxe_base.PXEBaseMixin, base.BootInterface):
ipxe_enabled = True
http_boot_enabled = True
capabilities = ['iscsi_volume_boot', 'ramdisk_boot', 'ipxe_boot']
def __init__(self):
pxe_utils.create_ipxe_boot_script()
pxe_utils.place_loaders_for_boot(CONF.deploy.http_root)

View File

@ -45,6 +45,17 @@ class PXEBoot(pxe_base.PXEBaseMixin, base.BootInterface):
pxe_utils.place_loaders_for_boot(CONF.pxe.tftp_root)
class HttpBoot(pxe_base.PXEBaseMixin, base.BootInterface):
http_boot_enabled = True
capabilities = ['ramdisk_boot', 'pxe_boot']
def __init__(self):
pxe_utils.place_common_config()
pxe_utils.place_loaders_for_boot(CONF.deploy.http_root)
class PXEAnacondaDeploy(agent_base.AgentBaseMixin, agent_base.HeartbeatMixin,
base.DeployInterface):

View File

@ -63,6 +63,8 @@ class PXEBaseMixin(object):
ipxe_enabled = False
http_boot_enabled = False
def get_properties(self):
"""Return the properties of the interface.
@ -70,6 +72,13 @@ class PXEBaseMixin(object):
"""
return COMMON_PROPERTIES
def _use_http_folder(self):
if self.ipxe_enabled:
return True
if self.http_boot_enabled:
return True
return False
@METRICS.timer('PXEBaseMixin.clean_up_ramdisk')
def clean_up_ramdisk(self, task):
"""Cleans up the boot of ironic ramdisk.
@ -90,14 +99,14 @@ class PXEBaseMixin(object):
mode = deploy_utils.rescue_or_deploy_mode(node)
try:
images_info = pxe_utils.get_image_info(
node, mode=mode, ipxe_enabled=self.ipxe_enabled)
node, mode=mode, ipxe_enabled=self._use_http_folder())
except exception.MissingParameterValue as e:
LOG.warning('Could not get %(mode)s image info '
'to clean up images for node %(node)s: %(err)s',
{'mode': mode, 'node': node.uuid, 'err': e})
else:
pxe_utils.clean_up_pxe_env(
task, images_info, ipxe_enabled=self.ipxe_enabled)
task, images_info, ipxe_enabled=self._use_http_folder())
@METRICS.timer('PXEBaseMixin.clean_up_instance')
def clean_up_instance(self, task):
@ -114,14 +123,14 @@ class PXEBaseMixin(object):
try:
images_info = pxe_utils.get_instance_image_info(
task, ipxe_enabled=self.ipxe_enabled)
task, ipxe_enabled=self._use_http_folder())
except exception.MissingParameterValue as e:
LOG.warning('Could not get instance image info '
'to clean up images for node %(node)s: %(err)s',
{'node': node.uuid, 'err': e})
else:
pxe_utils.clean_up_pxe_env(task, images_info,
ipxe_enabled=self.ipxe_enabled)
ipxe_enabled=self._use_http_folder())
boot_mode_utils.deconfigure_secure_boot_if_needed(task)
@ -146,7 +155,6 @@ class PXEBaseMixin(object):
operation failed on the node.
"""
node = task.node
# Label indicating a deploy or rescue operation being carried out on
# the node, 'deploy' or 'rescue'. Unless the node is in a rescue like
# state, the mode is set to 'deploy', indicating deploy operation is
@ -168,14 +176,19 @@ class PXEBaseMixin(object):
# 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, ip_version=4)
task, ipxe_enabled=self.ipxe_enabled, ip_version=4,
http_boot_enabled=self.http_boot_enabled)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=self.ipxe_enabled, ip_version=6)
task, ipxe_enabled=self.ipxe_enabled, ip_version=6,
http_boot_enabled=self.http_boot_enabled)
provider = dhcp_factory.DHCPFactory()
provider.update_dhcp(task, dhcp_opts)
pxe_info = pxe_utils.get_image_info(node, mode=mode,
ipxe_enabled=self.ipxe_enabled)
# TODO(TheJulia): We need to change the parameter name for
# ipxe_enabled in pxe_utils at some point since here it is
# an indicator of where to put the files on the filesystem.
pxe_info = pxe_utils.get_image_info(
node, mode=mode,
ipxe_enabled=self._use_http_folder())
# NODE: Try to validate and fetch instance images only
# if we are in DEPLOYING state.
@ -201,8 +214,7 @@ class PXEBaseMixin(object):
pxe_utils.create_pxe_config(task, pxe_options,
pxe_config_template,
ipxe_enabled=self.ipxe_enabled)
manager_utils.node_set_boot_device(task, boot_devices.PXE,
persistent=False)
self._node_set_boot_device_for_network_boot(task)
if self.ipxe_enabled and CONF.pxe.ipxe_use_swift:
kernel_label = '%s_kernel' % mode
@ -211,13 +223,43 @@ class PXEBaseMixin(object):
pxe_info.pop(ramdisk_label, None)
if pxe_info:
pxe_utils.cache_ramdisk_kernel(task, pxe_info,
ipxe_enabled=self.ipxe_enabled)
pxe_utils.cache_ramdisk_kernel(
task, pxe_info,
ipxe_enabled=self._use_http_folder())
LOG.debug('Ramdisk (i)PXE boot for node %(node)s has been prepared '
'with kernel params %(params)s',
{'node': node.uuid, 'params': pxe_options})
def _node_set_boot_device_for_network_boot(self, task, persistent=False):
"""Helper to handle httpboot aware network booting.
Basic challenge: IPMI doesn't have a field reserved for "httpboot" as
httpboot pre-dates IPMI. It is also entirely possible that all logic
to support httpboot is coming from OPROM code on network cards, so to
sort of handle this, and the nature of PXE being percieved as
"network boot", if we are http boot enabled, we attempt to explicitly
request as such, but if the driver errors, then we fall back to PXE.
:params task: a TaskManager object.
:params persistent: Default False, if the network boot request is
persistent.
"""
if self.http_boot_enabled:
try:
manager_utils.node_set_boot_device(task,
boot_devices.UEFIHTTP,
persistent=persistent)
except exception.InvalidParameterValue:
LOG.warning('Attempted to set HTTPBOOT for node %s, but it is '
'not supported by the driver. Falling back to '
'PXE to trigger network boot.', task.node.uuid)
manager_utils.node_set_boot_device(task, boot_devices.PXE,
persistent=persistent)
else:
manager_utils.node_set_boot_device(task, boot_devices.PXE,
persistent=persistent)
@METRICS.timer('PXEBaseMixin.prepare_instance')
def prepare_instance(self, task):
"""Prepares the boot of instance.
@ -241,8 +283,9 @@ class PXEBaseMixin(object):
if boot_option == "ramdisk" or boot_option == "kickstart":
instance_image_info = pxe_utils.get_instance_image_info(
task, ipxe_enabled=self.ipxe_enabled)
pxe_utils.cache_ramdisk_kernel(task, instance_image_info,
ipxe_enabled=self.ipxe_enabled)
pxe_utils.cache_ramdisk_kernel(
task, instance_image_info,
ipxe_enabled=self._use_http_folder())
if 'ks_template' in instance_image_info:
ks_cfg = pxe_utils.validate_kickstart_template(
instance_image_info['ks_template'][1]
@ -256,7 +299,8 @@ class PXEBaseMixin(object):
iscsi_boot=deploy_utils.is_iscsi_boot(task),
ramdisk_boot=(boot_option == "ramdisk"),
anaconda_boot=(boot_option == "kickstart"),
ipxe_enabled=self.ipxe_enabled)
ipxe_enabled=self.ipxe_enabled,
http_boot_enabled=self.http_boot_enabled)
pxe_utils.prepare_instance_kickstart_config(
task, instance_image_info,
anaconda_boot=(boot_option == "kickstart"))
@ -285,6 +329,12 @@ class PXEBaseMixin(object):
# during takeover
if boot_device and (task.node.provision_state not in
(states.ACTIVE, states.ADOPTING)):
if boot_device == boot_devices.PXE:
# Implying network booting, we need to handle the case
# it might be HTTPBoot instead of PXEBoot.
self._node_set_boot_device_for_network_boot(task,
persistent=True)
else:
manager_utils.node_set_boot_device(task, boot_device,
persistent=True)
@ -423,8 +473,7 @@ class PXEBaseMixin(object):
'timeout': CONF.pxe.boot_retry_timeout})
manager_utils.node_power_action(task, states.POWER_OFF)
manager_utils.node_set_boot_device(task, boot_devices.PXE,
persistent=False)
self._node_set_boot_device_for_network_boot(task)
manager_utils.node_power_action(task, states.POWER_ON)

View File

@ -858,7 +858,8 @@ class TestPXEUtils(db_base.DbTestCase):
'config'),
pxe_utils.get_pxe_config_file_path(self.node.uuid))
def _dhcp_options_for_instance(self, ip_version=4, ipxe=False):
def _dhcp_options_for_instance(self, ip_version=4, ipxe=False,
http_boot=False):
self.config(ip_version=ip_version, group='pxe')
if ip_version == 4:
self.config(tftp_server='192.0.2.1', group='pxe')
@ -878,12 +879,14 @@ class TestPXEUtils(db_base.DbTestCase):
else:
self.config(pxe_bootfile_name='fake-bootfile', group='pxe')
self.config(tftp_root='/tftp-path', group='pxe')
if http_boot:
self.config(http_url='https://foo.bar', group='deploy')
if ipxe:
bootfile = 'fake-bootfile-ipxe'
else:
bootfile = 'fake-bootfile'
if ip_version == 6:
if ip_version == 6 and not http_boot:
# NOTE(TheJulia): DHCPv6 RFCs seem to indicate that the prior
# options are not imported, although they may be supported
# by vendors. The apparent proper option is to return a
@ -891,7 +894,22 @@ class TestPXEUtils(db_base.DbTestCase):
expected_info = [{'opt_name': '59',
'opt_value': 'tftp://[ff80::1]/%s' % bootfile,
'ip_version': ip_version}]
elif ip_version == 4:
elif ip_version == 6 and http_boot:
if not ipxe:
expected_info = [
{'ip_version': 6,
'opt_name': '59',
'opt_value': 'https://foo.bar/%s' % bootfile}]
else:
expected_info = [
{'ip_version': 6,
'opt_name': 'tag:!ipxe6,59',
'opt_value': 'https://foo.bar/%s' % bootfile},
{'ip_version': 6,
'opt_name': 'tag:ipxe6,59',
'opt_value': 'https://foo.bar/boot.ipxe'},
]
elif ip_version == 4 and not http_boot:
expected_info = [{'opt_name': '67',
'opt_value': bootfile,
'ip_version': ip_version},
@ -905,9 +923,41 @@ class TestPXEUtils(db_base.DbTestCase):
'opt_value': '192.0.2.1',
'ip_version': ip_version}
]
elif ip_version == 4 and http_boot:
if not ipxe:
expected_info = [
{'ip_version': 4,
'opt_name': '67',
'opt_value': 'https://foo.bar/%s' % bootfile},
{'ip_version': 4,
'opt_name': '60',
'opt_value': 'HTTPClient'}
]
else:
expected_info = [
{'ip_version': 4,
'opt_name': 'tag:!ipxe,67',
'opt_value': 'https://foo.bar/%s' % bootfile},
{'ip_version': 4,
'opt_name': 'tag:ipxe,67',
'opt_value': 'https://foo.bar/boot.ipxe'},
{'ip_version': 4,
'opt_name': '60',
'opt_value': 'HTTPClient'}
]
with task_manager.acquire(self.context, self.node.uuid) as task:
if ipxe:
# Since we are using fake, we need to somehow assert it
# with simplicity :\
task.driver.boot.ipxe_enabled = True
# NOTE(TheJulia): If we *are* testing ipxe, *always* call the
# this method with ipxe_enabled set, because it informed via
# the call, not via the task.
self.assertEqual(expected_info,
pxe_utils.dhcp_options_for_instance(task))
pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=ipxe,
http_boot_enabled=http_boot))
def test_dhcp_options_for_instance(self):
self.config(default_boot_mode='uefi', group='deploy')
@ -926,6 +976,24 @@ class TestPXEUtils(db_base.DbTestCase):
self.config(default_boot_mode='bios', group='deploy')
self._dhcp_options_for_instance(ip_version=6)
def test_dhcp_options_for_instance_http_ipv4(self):
self.config(default_boot_mode='uefi', group='deploy')
self._dhcp_options_for_instance(ip_version=4, http_boot=True)
def test_dhcp_options_for_instance_http_ipv6(self):
self.config(default_boot_mode='uefi', group='deploy')
self._dhcp_options_for_instance(ip_version=6, http_boot=True)
def test_dhcp_options_for_instance_http_ipxe_ipv4(self):
self.config(default_boot_mode='uefi', group='deploy')
self._dhcp_options_for_instance(ip_version=4, ipxe=True,
http_boot=True)
def test_dhcp_options_for_instance_http_ipxe_ipv6(self):
self.config(default_boot_mode='uefi', group='deploy')
self._dhcp_options_for_instance(ip_version=6, ipxe=True,
http_boot=True)
def _test_get_kernel_ramdisk_info(self, expected_dir, mode='deploy',
ipxe_enabled=False):
node_uuid = 'fake-node'
@ -1105,7 +1173,7 @@ class TestPXEUtils(db_base.DbTestCase):
self.config(group='pxe', dir_permission=0o777)
def write_to_file(path, contents):
self.assertEqual('/tftpboot/grub/grub.cfg', path)
self.assertIn('/grub/grub.cfg', path)
self.assertIn(
'configfile /tftpboot/$net_default_mac.conf',
contents
@ -1115,9 +1183,18 @@ class TestPXEUtils(db_base.DbTestCase):
wraps=write_to_file):
pxe_utils.place_common_config()
mock_isdir.assert_called_once_with('/tftpboot/grub')
mock_makedirs.assert_called_once_with('/tftpboot/grub', 511)
mock_chmod.assert_called_once_with('/tftpboot/grub', 0o777)
mock_isdir.assert_has_calls([
mock.call('/tftpboot/grub'),
mock.call('/httpboot/grub')
])
mock_makedirs.assert_has_calls([
mock.call('/tftpboot/grub', 511),
mock.call('/httpboot/grub', 511)
])
mock_chmod.assert_has_calls([
mock.call('/tftpboot/grub', 0o777),
mock.call('/httpboot/grub', 0o777)
])
@mock.patch.object(os, 'makedirs', autospec=True)
@mock.patch.object(os.path, 'isdir', autospec=True)
@ -1133,9 +1210,12 @@ class TestPXEUtils(db_base.DbTestCase):
with mock.patch('ironic.common.utils.write_to_file',
autospec=True) as mock_write:
pxe_utils.place_common_config()
mock_write.assert_called_once()
self.assertEqual(2, mock_write.call_count)
mock_isdir.assert_called_once_with('/tftpboot/grub')
mock_isdir.assert_has_calls([
mock.call('/tftpboot/grub'),
mock.call('/httpboot/grub')
])
mock_makedirs.assert_not_called()
mock_chmod.assert_not_called()

View File

@ -35,6 +35,7 @@ from ironic.drivers import base as drivers_base
from ironic.drivers.modules import agent_base
from ironic.drivers.modules import boot_mode_utils
from ironic.drivers.modules import deploy_utils
from ironic.drivers.modules import fake
from ironic.drivers.modules import ipxe
from ironic.drivers.modules import pxe_base
from ironic.drivers.modules.storage import noop as noop_storage
@ -84,6 +85,11 @@ class iPXEBootTestCase(db_base.DbTestCase):
self.port = obj_utils.create_test_port(self.context,
node_id=self.node.id)
def test_ensure_boot_interface_is_not_http_enabled(self):
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
self.assertFalse(task.driver.boot.http_boot_enabled)
def test_get_properties(self):
expected = pxe_base.COMMON_PROPERTIES
expected.update(agent_base.VENDOR_PROPERTIES)
@ -964,3 +970,192 @@ class iPXEValidateRescueTestCase(db_base.DbTestCase):
self.assertRaisesRegex(exception.MissingParameterValue,
'Missing.*rescue_kernel',
task.driver.boot.validate_rescue, task)
@mock.patch.object(ipxe.iPXEHttpBoot, '__init__', lambda self: None)
class iPXEHttpBootTestCase(db_base.DbTestCase):
driver = 'fake-hardware'
boot_interface = 'http-ipxe'
driver_info = DRV_INFO_DICT
driver_internal_info = DRV_INTERNAL_INFO_DICT
def setUp(self):
super(iPXEHttpBootTestCase, self).setUp()
self.context.auth_token = 'fake'
self.config_temp_dir('tftp_root', group='pxe')
self.config_temp_dir('images_path', group='pxe')
self.config_temp_dir('http_root', group='deploy')
self.config(group='deploy', http_url='http://myserver')
instance_info = INST_INFO_DICT
self.config(enabled_boot_interfaces=[self.boot_interface,
'http-ipxe', 'fake'])
self.node = obj_utils.create_test_node(
self.context,
driver=self.driver,
boot_interface=self.boot_interface,
# Avoid fake properties in get_properties() output
vendor_interface='no-vendor',
instance_info=instance_info,
driver_info=self.driver_info,
driver_internal_info=self.driver_internal_info)
self.port = obj_utils.create_test_port(self.context,
node_id=self.node.id)
def test_http_boot_enabled(self):
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
self.assertTrue(task.driver.boot.http_boot_enabled)
# TODO(TheJulia): Many of the interfaces mocked below are private PXE
# interface methods. As time progresses, these will need to be migrated
# and refactored as we begin to separate PXE and iPXE interfaces.
@mock.patch.object(manager_utils, 'node_get_boot_mode', autospec=True)
@mock.patch.object(manager_utils, 'node_set_boot_device', autospec=True)
@mock.patch.object(dhcp_factory, 'DHCPFactory', autospec=True)
@mock.patch.object(pxe_utils, 'get_instance_image_info', autospec=True)
@mock.patch.object(pxe_utils, 'get_image_info', autospec=True)
@mock.patch.object(pxe_utils, 'cache_ramdisk_kernel', autospec=True)
@mock.patch.object(pxe_utils, 'build_pxe_config_options', autospec=True)
@mock.patch.object(pxe_utils, 'create_pxe_config', autospec=True)
def _test_prepare_ramdisk(self, mock_pxe_config,
mock_build_pxe, mock_cache_r_k,
mock_deploy_img_info,
mock_instance_img_info,
dhcp_factory_mock,
set_boot_device_mock,
get_boot_mode_mock,
uefi=False,
cleaning=False,
ipxe_use_swift=False,
whole_disk_image=False,
mode='deploy',
node_boot_mode=None,
persistent=False):
mock_build_pxe.return_value = {}
kernel_label = '%s_kernel' % mode
ramdisk_label = '%s_ramdisk' % mode
mock_deploy_img_info.return_value = {kernel_label: 'a',
ramdisk_label: 'r'}
if whole_disk_image:
mock_instance_img_info.return_value = {}
else:
mock_instance_img_info.return_value = {'kernel': 'b'}
mock_pxe_config.return_value = None
mock_cache_r_k.return_value = None
provider_mock = mock.MagicMock()
dhcp_factory_mock.return_value = provider_mock
get_boot_mode_mock.return_value = node_boot_mode
driver_internal_info = self.node.driver_internal_info
driver_internal_info['is_whole_disk_image'] = whole_disk_image
self.node.driver_internal_info = driver_internal_info
if mode == 'rescue':
mock_deploy_img_info.return_value = {
'rescue_kernel': 'a',
'rescue_ramdisk': 'r'}
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, ip_version=4, http_boot_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=True, ip_version=6, http_boot_enabled=True)
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)
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,
boot_devices.UEFIHTTP,
persistent=persistent)
if ipxe_use_swift:
if whole_disk_image:
self.assertFalse(mock_cache_r_k.called)
else:
mock_cache_r_k.assert_called_once_with(
task, {'kernel': 'b'},
ipxe_enabled=True)
mock_instance_img_info.assert_called_once_with(
task, ipxe_enabled=True)
elif not cleaning and mode == 'deploy':
mock_cache_r_k.assert_called_once_with(
task,
{'deploy_kernel': 'a', 'deploy_ramdisk': 'r',
'kernel': 'b'},
ipxe_enabled=True)
mock_instance_img_info.assert_called_once_with(
task, ipxe_enabled=True)
elif mode == 'deploy':
mock_cache_r_k.assert_called_once_with(
task, {'deploy_kernel': 'a', 'deploy_ramdisk': 'r'},
ipxe_enabled=True)
elif mode == 'rescue':
mock_cache_r_k.assert_called_once_with(
task, {'rescue_kernel': 'a', 'rescue_ramdisk': 'r'},
ipxe_enabled=True)
mock_pxe_config.assert_called_once_with(
task, {}, CONF.pxe.ipxe_config_template,
ipxe_enabled=True)
def test_prepare_ramdisk(self):
self.node.provision_state = states.DEPLOYING
self.node.save()
self._test_prepare_ramdisk()
def test_prepare_ramdisk_rescue(self):
self.node.provision_state = states.RESCUING
self.node.save()
self._test_prepare_ramdisk(mode='rescue')
def test_prepare_ramdisk_uefi(self):
self.node.provision_state = states.DEPLOYING
self.node.save()
properties = self.node.properties
properties['capabilities'] = 'boot_mode:uefi'
self.node.properties = properties
self.node.save()
self._test_prepare_ramdisk(uefi=True)
@mock.patch.object(ipxe.iPXEHttpBoot, '__init__', lambda self: None)
class iPXEBootBaseUtils(db_base.DbTestCase):
driver = 'fake-hardware'
boot_interface = 'http-ipxe'
driver_info = DRV_INFO_DICT
driver_internal_info = DRV_INTERNAL_INFO_DICT
def setUp(self):
super(iPXEBootBaseUtils, self).setUp()
self.context.auth_token = 'fake'
instance_info = INST_INFO_DICT
self.config(enabled_boot_interfaces=[self.boot_interface,
'http-ipxe'])
self.node = obj_utils.create_test_node(
self.context,
driver=self.driver,
boot_interface=self.boot_interface,
# Avoid fake properties in get_properties() output
vendor_interface='no-vendor',
instance_info=instance_info,
driver_info=self.driver_info,
driver_internal_info=self.driver_internal_info)
@mock.patch.object(fake.FakeManagement, 'set_boot_device', autospec=True)
def test__node_set_boot_device_for_network_boot(self, mock_set_boot_dev):
mock_set_boot_dev.side_effect = [
exception.InvalidParameterValue('Invalid boot device'),
None
]
with task_manager.acquire(self.context, self.node.uuid) as task:
task.driver.boot._node_set_boot_device_for_network_boot(
task, persistent=True)
mock_set_boot_dev.assert_has_calls([
mock.call(mock.ANY, task, boot_devices.UEFIHTTP,
persistent=True),
mock.call(mock.ANY, task, boot_devices.PXE,
persistent=True)
])

View File

@ -934,6 +934,11 @@ class PXEValidateRescueTestCase(db_base.DbTestCase):
'Missing.*rescue_kernel',
task.driver.boot.validate_rescue, task)
def test_http_boot_not_enabled(self):
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
self.assertFalse(task.driver.boot.http_boot_enabled)
@mock.patch.object(ipxe.iPXEBoot, '__init__', lambda self: None)
@mock.patch.object(pxe.PXEBoot, '__init__', lambda self: None)
@ -1039,3 +1044,156 @@ class iPXEBootRetryTestCase(PXEBootRetryTestCase):
boot_interface = 'ipxe'
boot_interface_class = ipxe.iPXEBoot
@mock.patch.object(pxe.HttpBoot, '__init__', lambda self: None)
class HttpBootTestCase(db_base.DbTestCase):
driver = 'fake-hardware'
boot_interface = 'http'
driver_info = DRV_INFO_DICT
driver_internal_info = DRV_INTERNAL_INFO_DICT
def setUp(self):
super(HttpBootTestCase, self).setUp()
self.context.auth_token = 'fake'
self.config_temp_dir('tftp_root', group='pxe')
self.config_temp_dir('images_path', group='pxe')
self.config_temp_dir('http_root', group='deploy')
self.config(group='deploy', http_url='http://myserver')
instance_info = INST_INFO_DICT
self.config(enabled_boot_interfaces=[self.boot_interface,
'http', 'fake'])
self.node = obj_utils.create_test_node(
self.context,
driver=self.driver,
boot_interface=self.boot_interface,
# Avoid fake properties in get_properties() output
vendor_interface='no-vendor',
instance_info=instance_info,
driver_info=self.driver_info,
driver_internal_info=self.driver_internal_info)
self.port = obj_utils.create_test_port(self.context,
node_id=self.node.id)
def test_http_boot_enabled(self):
with task_manager.acquire(self.context, self.node.uuid,
shared=True) as task:
self.assertTrue(task.driver.boot.http_boot_enabled)
# TODO(TheJulia): Many of the interfaces mocked below are private PXE
# interface methods. As time progresses, these will need to be migrated
# and refactored as we begin to separate PXE and iPXE interfaces.
@mock.patch.object(manager_utils, 'node_get_boot_mode', autospec=True)
@mock.patch.object(manager_utils, 'node_set_boot_device', autospec=True)
@mock.patch.object(dhcp_factory, 'DHCPFactory', autospec=True)
@mock.patch.object(pxe_utils, 'get_instance_image_info', autospec=True)
@mock.patch.object(pxe_utils, 'get_image_info', autospec=True)
@mock.patch.object(pxe_utils, 'cache_ramdisk_kernel', autospec=True)
@mock.patch.object(pxe_utils, 'build_pxe_config_options', autospec=True)
@mock.patch.object(pxe_utils, 'create_pxe_config', autospec=True)
def _test_prepare_ramdisk(self, mock_pxe_config,
mock_build_pxe, mock_cache_r_k,
mock_deploy_img_info,
mock_instance_img_info,
dhcp_factory_mock,
set_boot_device_mock,
get_boot_mode_mock,
uefi=False,
cleaning=False,
ipxe_use_swift=False,
whole_disk_image=False,
mode='deploy',
node_boot_mode=None,
persistent=False):
mock_build_pxe.return_value = {}
kernel_label = '%s_kernel' % mode
ramdisk_label = '%s_ramdisk' % mode
mock_deploy_img_info.return_value = {kernel_label: 'a',
ramdisk_label: 'r'}
if whole_disk_image:
mock_instance_img_info.return_value = {}
else:
mock_instance_img_info.return_value = {'kernel': 'b'}
mock_pxe_config.return_value = None
mock_cache_r_k.return_value = None
provider_mock = mock.MagicMock()
dhcp_factory_mock.return_value = provider_mock
get_boot_mode_mock.return_value = node_boot_mode
driver_internal_info = self.node.driver_internal_info
driver_internal_info['is_whole_disk_image'] = whole_disk_image
self.node.driver_internal_info = driver_internal_info
if mode == 'rescue':
mock_deploy_img_info.return_value = {
'rescue_kernel': 'a',
'rescue_ramdisk': 'r'}
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=False, ip_version=4, http_boot_enabled=True)
dhcp_opts += pxe_utils.dhcp_options_for_instance(
task, ipxe_enabled=False, ip_version=6, http_boot_enabled=True)
task.driver.boot.prepare_ramdisk(task, {'foo': 'bar'})
if task.driver.boot.http_boot_enabled:
# FIXME(TheJulia): We need to change the parameter
# name on some of the pxe internal calls because
# they boil down to "use the http folder" or
# "use the tftp folder"
use_http_folder = True
else:
use_http_folder = False
mock_deploy_img_info.assert_called_once_with(
task.node, mode=mode, ipxe_enabled=use_http_folder)
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,
boot_devices.UEFIHTTP,
persistent=persistent)
if ipxe_use_swift:
if whole_disk_image:
self.assertFalse(mock_cache_r_k.called)
else:
mock_cache_r_k.assert_called_once_with(
task, {'kernel': 'b'},
ipxe_enabled=use_http_folder)
mock_instance_img_info.assert_called_once_with(
task, ipxe_enabled=False)
elif not cleaning and mode == 'deploy':
mock_cache_r_k.assert_called_once_with(
task,
{'deploy_kernel': 'a', 'deploy_ramdisk': 'r',
'kernel': 'b'},
ipxe_enabled=use_http_folder)
mock_instance_img_info.assert_called_once_with(
task, ipxe_enabled=False)
elif mode == 'deploy':
mock_cache_r_k.assert_called_once_with(
task, {'deploy_kernel': 'a', 'deploy_ramdisk': 'r'},
ipxe_enabled=use_http_folder)
elif mode == 'rescue':
mock_cache_r_k.assert_called_once_with(
task, {'rescue_kernel': 'a', 'rescue_ramdisk': 'r'},
ipxe_enabled=use_http_folder)
mock_pxe_config.assert_called_once_with(
task, {}, CONF.pxe.uefi_pxe_config_template,
ipxe_enabled=False)
def test_prepare_ramdisk(self):
self.node.provision_state = states.DEPLOYING
self.node.save()
self._test_prepare_ramdisk()
def test_prepare_ramdisk_rescue(self):
self.node.provision_state = states.RESCUING
self.node.save()
self._test_prepare_ramdisk(mode='rescue')
def test_prepare_ramdisk_uefi(self):
self.node.provision_state = states.DEPLOYING
self.node.save()
properties = self.node.properties
properties['capabilities'] = 'boot_mode:uefi'
self.node.properties = properties
self.node.save()
self._test_prepare_ramdisk(uefi=True)

View File

@ -0,0 +1,21 @@
---
features:
- |
Adds a ``http`` boot interface, based upon the ``pxe`` boot interface
which informs the DHCP server of an HTTP URL to boot the machine from,
and then requests the BMC boot the machine in UEFI HTTP mode.
- |
Adds a ``http-ipxe`` boot interface, based upon the ``ipxe`` boot interface
which informs the DHCP server of an HTTP URL to boot the machine from,
and then requests the BMC boot the machine in UEFI HTTP mode.
issues:
- |
Testing of the ``http`` boot interface with Ubuntu 22.04 provided Grub2
yielded some intermittent failures which appear to be more environmental
in nature as the signed Shim loader would start, then load the GRUB
loader, and then some of the expected files might be attempted to be
accessed, and then fail due to an apparent transfer timeout. Consultation
with some grub developers concur this is likely environmental, meaning
the specific grub build or CI performance related. If you encounter any
issues, please do not hestitate to reach out to the Ironic developer
community.

View File

@ -81,6 +81,8 @@ ironic.hardware.interfaces.boot =
pxe = ironic.drivers.modules.pxe:PXEBoot
redfish-virtual-media = ironic.drivers.modules.redfish.boot:RedfishVirtualMediaBoot
redfish-https = ironic.drivers.modules.redfish.boot:RedfishHttpsBoot
http = ironic.drivers.modules.pxe:HttpBoot
http-ipxe = ironic.drivers.modules.ipxe:iPXEHttpBoot
ironic.hardware.interfaces.console =
fake = ironic.drivers.modules.fake:FakeConsole