From e19fd1d050cbabfdafa40c4743f82cd4c7649f82 Mon Sep 17 00:00:00 2001 From: Julia Kreger Date: Tue, 14 Nov 2023 16:15:42 -0800 Subject: [PATCH] Add HTTP versions of network boot interfaces This change adds two network boot interfaces, ``http`` and ``http-ipxe``. These interfaces are based upon the underlying PXE boot interface code in ironic, and where this differs is it signals to Ironic that we must do the boot loader needful in terms of telling DHCP to send a URL instead of a filename and IP address for PXE as a starting point. The naming of the interfaces focuses more on the transport mechanism and then specific style. Very similar to existing ``pxe`` and ``ipxe`` interface modeling, except in the ``ipxe`` case, it is more a specific loader and mechanism to be utilized. Related-Bug: #2032380 Change-Id: Ie7ace88b62b9179f640ef2a732dd228e12bd320d --- doc/source/admin/interfaces/boot.rst | 21 ++ doc/source/admin/ovn-networking.rst | 7 + doc/source/install/configure-networking.rst | 8 +- doc/source/install/configure-pxe.rst | 69 ++++++- doc/source/install/refarch/common.rst | 6 +- ironic/common/pxe_utils.py | 106 +++++++--- ironic/drivers/generic.py | 2 +- ironic/drivers/modules/ipxe.py | 13 ++ ironic/drivers/modules/pxe.py | 11 + ironic/drivers/modules/pxe_base.py | 91 ++++++-- ironic/tests/unit/common/test_pxe_utils.py | 100 ++++++++- .../tests/unit/drivers/modules/test_ipxe.py | 195 ++++++++++++++++++ ironic/tests/unit/drivers/modules/test_pxe.py | 158 ++++++++++++++ ...dd-http-boot-support-a5a90e87a91a87d5.yaml | 21 ++ setup.cfg | 2 + 15 files changed, 740 insertions(+), 70 deletions(-) create mode 100644 releasenotes/notes/add-http-boot-support-a5a90e87a91a87d5.yaml diff --git a/doc/source/admin/interfaces/boot.rst b/doc/source/admin/interfaces/boot.rst index a4b4cf2a35..d5b51e2ea4 100644 --- a/doc/source/admin/interfaces/boot.rst +++ b/doc/source/admin/interfaces/boot.rst @@ -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 ~~~~~~~~~~~~~~~~~ diff --git a/doc/source/admin/ovn-networking.rst b/doc/source/admin/ovn-networking.rst index c937da53cc..27b4ec50b0 100644 --- a/doc/source/admin/ovn-networking.rst +++ b/doc/source/admin/ovn-networking.rst @@ -48,6 +48,13 @@ is being worked in Neutron `change 890683 `_ and `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 -------------------------- diff --git a/doc/source/install/configure-networking.rst b/doc/source/install/configure-networking.rst index ff653a96ee..5727b8328f 100644 --- a/doc/source/install/configure-networking.rst +++ b/doc/source/install/configure-networking.rst @@ -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 diff --git a/doc/source/install/configure-pxe.rst b/doc/source/install/configure-pxe.rst index 76808f4b71..ab02f83ff5 100644 --- a/doc/source/install/configure-pxe.rst +++ b/doc/source/install/configure-pxe.rst @@ -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 `_. + +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 diff --git a/doc/source/install/refarch/common.rst b/doc/source/install/refarch/common.rst index ce0dedfb12..0fd5660411 100644 --- a/doc/source/install/refarch/common.rst +++ b/doc/source/install/refarch/common.rst @@ -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) diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index a6efb026b7..78c81b94f7 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -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, - boot_file=boot_file) + 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') - 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) - - 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) + 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) diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index eb1c886416..a75b837c37 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -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): diff --git a/ironic/drivers/modules/ipxe.py b/ironic/drivers/modules/ipxe.py index 5fe9116524..2380f0bb86 100644 --- a/ironic/drivers/modules/ipxe.py +++ b/ironic/drivers/modules/ipxe.py @@ -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) diff --git a/ironic/drivers/modules/pxe.py b/ironic/drivers/modules/pxe.py index a55f5b9fd7..c3ea7093e9 100644 --- a/ironic/drivers/modules/pxe.py +++ b/ironic/drivers/modules/pxe.py @@ -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): diff --git a/ironic/drivers/modules/pxe_base.py b/ironic/drivers/modules/pxe_base.py index 5053ce0f6a..380403d9a3 100644 --- a/ironic/drivers/modules/pxe_base.py +++ b/ironic/drivers/modules/pxe_base.py @@ -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,8 +329,14 @@ class PXEBaseMixin(object): # during takeover if boot_device and (task.node.provision_state not in (states.ACTIVE, states.ADOPTING)): - manager_utils.node_set_boot_device(task, boot_device, - persistent=True) + 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) def _validate_common(self, task): node = task.node @@ -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) diff --git a/ironic/tests/unit/common/test_pxe_utils.py b/ironic/tests/unit/common/test_pxe_utils.py index f1295cb2cf..4fd135ec68 100644 --- a/ironic/tests/unit/common/test_pxe_utils.py +++ b/ironic/tests/unit/common/test_pxe_utils.py @@ -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() diff --git a/ironic/tests/unit/drivers/modules/test_ipxe.py b/ironic/tests/unit/drivers/modules/test_ipxe.py index 02d254b5a3..04c9858f79 100644 --- a/ironic/tests/unit/drivers/modules/test_ipxe.py +++ b/ironic/tests/unit/drivers/modules/test_ipxe.py @@ -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) + ]) diff --git a/ironic/tests/unit/drivers/modules/test_pxe.py b/ironic/tests/unit/drivers/modules/test_pxe.py index 33a3f43f61..cc48587bce 100644 --- a/ironic/tests/unit/drivers/modules/test_pxe.py +++ b/ironic/tests/unit/drivers/modules/test_pxe.py @@ -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) diff --git a/releasenotes/notes/add-http-boot-support-a5a90e87a91a87d5.yaml b/releasenotes/notes/add-http-boot-support-a5a90e87a91a87d5.yaml new file mode 100644 index 0000000000..b8c0fef39e --- /dev/null +++ b/releasenotes/notes/add-http-boot-support-a5a90e87a91a87d5.yaml @@ -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. diff --git a/setup.cfg b/setup.cfg index fb786ea12c..48c094b891 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,6 +80,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