diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..a914011ae8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @stackhpc/openstack diff --git a/.github/workflows/tag-and-release.yml b/.github/workflows/tag-and-release.yml new file mode 100644 index 0000000000..18a2320b97 --- /dev/null +++ b/.github/workflows/tag-and-release.yml @@ -0,0 +1,12 @@ +--- +name: Tag & Release +'on': + push: + branches: + - stackhpc/2025.1 +permissions: + actions: read + contents: write +jobs: + tag-and-release: + uses: stackhpc/.github/.github/workflows/tag-and-release.yml@main diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 0000000000..8713f0e02d --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,7 @@ +--- +name: Tox Continuous Integration +'on': + pull_request: +jobs: + tox: + uses: stackhpc/.github/.github/workflows/tox.yml@main diff --git a/doc/source/admin/interfaces/deploy.rst b/doc/source/admin/interfaces/deploy.rst index 4d3127573e..938db4f52b 100644 --- a/doc/source/admin/interfaces/deploy.rst +++ b/doc/source/admin/interfaces/deploy.rst @@ -121,6 +121,66 @@ To disable the conductor-side conversion completely, set [DEFAULT] force_raw_images = False +Autodetect support +------------------ + +The ``direct`` deploy interface supports being auto-detected by the +``autodetect`` deploy interface (see :ref:`autodetect-deploy` for details). +The ``direct`` interface will always claim to support switching to it for +deployment regardless of the supplied node information. This makes it +appropriate to use as the last fallback value in the +``autodetect_deploy_interfaces`` configuration option. + +.. _autodetect-deploy: + +Autodetect deploy +================= + +The ``autodetect`` deploy interface automatically selects the most appropriate +concrete deploy interface based on image metadata and node configuration. This +simplifies node configuration by eliminating the need to manually specify +different deploy interfaces for different image types. + +When a deployment begins, the autodetect interface inspects the image and node +configuration, then switches to the most appropriate deploy interface from the +configured list. The detection logic checks interfaces in priority order using +each interface's ``supports_deploy()`` method. The first interface that +returns ``True`` is selected. If no interfaces are detected as supported, the +last interface in the configured list is used as a fallback. + +You can specify this deploy interface when creating or updating a node:: + + baremetal node create --driver ipmi --deploy-interface autodetect + baremetal node set --deploy-interface autodetect + +Configuration +------------- + +The autodetect interface uses the ``autodetect_deploy_interfaces`` +configuration option to control which interfaces can be auto-selected and +their priority order. The default configuration is: + +.. code-block:: ini + + [DEFAULT] + autodetect_deploy_interfaces = ramdisk,bootc,direct + +This example configuration checks :doc:`Ramdisk deploy ` first +(for nodes with ``kernel``/``ramdisk`` or ``boot_iso`` in ``instance_info``), +then :ref:`Bootc deploy ` (for OCI container images), and falls +back to :ref:`Direct deploy ` for standard disk images. + +.. note:: + The order of interfaces in the configuration list matters. Interfaces are + checked in the order listed, and the first supported interface is selected. + The last interface in the list serves as the fallback if no other interface + is detected as supported. + +.. note:: + After deployment teardown, the node's deploy interface is automatically + restored to ``autodetect`` from whatever concrete interface was used during + deployment. + .. _ansible-deploy: Ansible deploy @@ -178,6 +238,16 @@ The ramdisk interface is intended to provide a mechanism to "deploy" an instance where the item to be deployed is in reality a ramdisk. It is documented separately, see :doc:`/admin/ramdisk-boot`. +Autodetect support +------------------ + +The ``ramdisk`` deploy interface supports being auto-detected by the +``autodetect`` deploy interface (see :ref:`autodetect-deploy` for details). +It checks whether the node's ``instance_info`` contains ``kernel`` and +``ramdisk`` (without an ``image_source``), or whether ``boot_iso`` is set. +When either condition is met, autodetect will select the ``ramdisk`` deploy +interface. + .. _custom-agent-deploy: Custom agent deploy @@ -254,6 +324,16 @@ to enable download from the remote image registry, which is part of the support for retrieval of artifacts from OCI Container registires. This parameter is ``image_pull_secret``. +Autodetect support +------------------ + +The ``bootc`` deploy interface supports being auto-detected by the +``autodetect`` deploy interface (see :ref:`autodetect-deploy` for details). It +checks whether the image source URL is an OCI container image (``oci://`` +URL), then introspects the image registry to confirm the artifact is a +container image (OCI artifacts are handled by the ``direct`` deploy +interface). + Caveats ------- diff --git a/doc/source/admin/ramdisk-boot.rst b/doc/source/admin/ramdisk-boot.rst index 5beff555e3..20319a3618 100644 --- a/doc/source/admin/ramdisk-boot.rst +++ b/doc/source/admin/ramdisk-boot.rst @@ -40,6 +40,13 @@ or update an existing node: You can also use it with :ref:`redfish virtual media ` instead of iPXE. +Alternatively, if the node uses the ``autodetect`` deploy interface, the +ramdisk interface is selected automatically when ``kernel`` and ``ramdisk`` +are set in ``instance_info`` (without an ``image_source``), or when +``boot_iso`` is set. To enable this, add ``ramdisk`` to the +``autodetect_deploy_interfaces`` configuration option. See +:ref:`autodetect-deploy` for details. + Creating a ramdisk ------------------ @@ -125,6 +132,98 @@ ISO images are also cached across deployments, similarly to how it is done for normal instance images. The URL together with the last modified response header are used to determine if an image needs updating. +Using with Nova (Glance images) +------------------------------- + +When deploying ramdisk nodes through Nova, the user creates a Glance image with +``kernel_id`` and ``ramdisk_id`` properties — the same AMI-style pattern used +for partition images. Nova sets ``image_source`` in the node's ``instance_info`` +and Ironic resolves the kernel and ramdisk from Glance automatically. + +#. Upload the kernel to Glance: + + .. code-block:: shell + + openstack image create my-ramdisk-kernel --public \ + --disk-format raw --container-format bare \ + --file my-ramdisk.kernel + + Store the image UUID as ``MY_KERNEL_UUID``. + +#. Upload the ramdisk (initramfs) to Glance: + + .. code-block:: shell + + openstack image create my-ramdisk-initrd --public \ + --disk-format raw --container-format bare \ + --file my-ramdisk.initramfs + + Store the image UUID as ``MY_RAMDISK_UUID``. + +#. Create the Glance image with ``kernel_id`` and ``ramdisk_id`` properties. + Since the ramdisk deploy interface does not write to disk, the disk image + file is not used — an empty placeholder file is sufficient. + + Include ``ironic_ramdisk_deploy=True`` so that the deploy interface + autodetect mechanism can distinguish this image from a partition image + (which also carries ``kernel_id`` / ``ramdisk_id``): + + .. code-block:: shell + + touch /tmp/placeholder + openstack image create my-ramdisk-image --public \ + --disk-format raw --container-format bare \ + --property kernel_id=$MY_KERNEL_UUID \ + --property ramdisk_id=$MY_RAMDISK_UUID \ + --property ironic_ramdisk_deploy=True \ + --file /tmp/placeholder + +#. Boot a Nova instance using this image: + + .. code-block:: shell + + openstack server create --image my-ramdisk-image \ + --flavor my-baremetal-flavor my-ramdisk-instance + +Alternative: Using a boot ISO in Glance +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of separate kernel and ramdisk images, you can reference a single boot +ISO stored in Glance via the ``boot_iso_id`` property: + +#. Upload the boot ISO to Glance: + + .. code-block:: shell + + openstack image create my-ramdisk-boot-iso --public \ + --disk-format iso --container-format bare \ + --file my-ramdisk.iso + + Store the image UUID as ``MY_BOOT_ISO_UUID``. + +#. Create the Glance image with the ``boot_iso_id`` property: + + .. code-block:: shell + + touch /tmp/placeholder + openstack image create my-ramdisk-image --public \ + --disk-format raw --container-format bare \ + --property boot_iso_id=$MY_BOOT_ISO_UUID \ + --file /tmp/placeholder + +#. Boot a Nova instance as above: + + .. code-block:: shell + + openstack server create --image my-ramdisk-image \ + --flavor my-baremetal-flavor my-ramdisk-instance + +.. note:: + The disk image file attached to the Glance image is **not** used by the + ramdisk deploy interface. Only the kernel and ramdisk referenced by + ``kernel_id`` / ``ramdisk_id``, or the ISO referenced by ``boot_iso_id``, + are booted. + Limitations ----------- diff --git a/doc/source/install/configure-glance-images.rst b/doc/source/install/configure-glance-images.rst index 7268d02c8b..aec4c90447 100644 --- a/doc/source/install/configure-glance-images.rst +++ b/doc/source/install/configure-glance-images.rst @@ -84,6 +84,12 @@ the Image service for each one as it is generated. kernel_id=$MY_VMLINUZ_UUID --property \ ramdisk_id=$MY_INITRD_UUID --file my-image.qcow2 +- For images used with the *ramdisk deploy interface* (e.g. when deploying + ramdisk nodes through Nova), you can use either ``kernel_id`` / + ``ramdisk_id`` properties (with ``ironic_ramdisk_deploy=True`` to + distinguish from partition images) or a single ``boot_iso_id`` property + pointing to an ISO in Glance. See :doc:`/admin/ramdisk-boot` for details. + Deploy ramdisk images ~~~~~~~~~~~~~~~~~~~~~ diff --git a/ironic/conductor/cleaning.py b/ironic/conductor/cleaning.py index 9c8b052805..0fe7974cb7 100644 --- a/ironic/conductor/cleaning.py +++ b/ironic/conductor/cleaning.py @@ -50,6 +50,9 @@ def do_node_clean(task, clean_steps=None, disable_ramdisk=False): node.clean_step = None node.save() + # Switch back to original interface, if necessary + task.driver.deploy.restore_interface(task) + task.process_event('done') how = ('API' if node.automated_clean is False else 'configuration') LOG.info('Automated cleaning is disabled via %(how)s, node %(node)s ' diff --git a/ironic/conductor/deployments.py b/ironic/conductor/deployments.py index 6a4dbcf074..badab7970d 100644 --- a/ironic/conductor/deployments.py +++ b/ironic/conductor/deployments.py @@ -161,6 +161,8 @@ def start_deploy(task, manager, configdrive=None, event='deploy', instance_info = node.instance_info instance_info.pop('kernel', None) instance_info.pop('ramdisk', None) + instance_info.pop('boot_iso', None) + instance_info.pop('original_image_source', None) node.instance_info = instance_info else: # NOTE(JayF): Don't apply lessee when rebuilding @@ -173,6 +175,9 @@ def start_deploy(task, manager, configdrive=None, event='deploy', node.save() try: + # Give the deploy interface the opportunity to switch interfaces + task.driver.deploy.switch_interface(task) + task.driver.power.validate(task) task.driver.deploy.validate(task) utils.validate_instance_info_traits(task.node) diff --git a/ironic/conductor/utils.py b/ironic/conductor/utils.py index 0c7164e9fd..da2bb0e807 100644 --- a/ironic/conductor/utils.py +++ b/ironic/conductor/utils.py @@ -600,6 +600,9 @@ def cleaning_error_handler(task, logmsg, errmsg=None, traceback=False, node.save() + # Switch back to original interface, if necessary + task.driver.deploy.restore_interface(task) + if set_fail_state and node.provision_state != states.CLEANFAIL: target_state = states.MANAGEABLE if manual_clean else None task.process_event('fail', target_state=target_state) diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 9ed9e7fed6..e64325cea5 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -129,10 +129,23 @@ cfg.StrOpt('default_console_interface', help=_DEFAULT_IFACE_HELP.format('console')), cfg.ListOpt('enabled_deploy_interfaces', - default=['direct', 'ramdisk'], + default=['autodetect', 'direct', 'ramdisk'], help=_ENABLED_IFACE_HELP.format('deploy')), cfg.StrOpt('default_deploy_interface', help=_DEFAULT_IFACE_HELP.format('deploy')), + cfg.ListOpt('autodetect_deploy_interfaces', + default=['ramdisk', 'direct'], + help=_('List of deploy interfaces that the ' + 'autodetect deploy interface is allowed to ' + 'switch to. The order of interfaces in the list ' + 'indicates the order support will be checked for,' + 'with the first interface having the highest ' + 'priority. The last interface in the list will be ' + 'chosen if no interfaces are detected as supported. ' + '"ramdisk" and "bootc" have detection support ' + 'and "direct" is an appropriate fallback for ' + 'most cases. All interfaces listed here must ' + 'also be in "enabled_deploy_interfaces".')), cfg.ListOpt('enabled_firmware_interfaces', default=['no-firmware'], help=_ENABLED_IFACE_HELP.format('firmware')), diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index bebbbe8253..b5ee7afeb0 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -621,6 +621,40 @@ def prepare_service(self, task): """ pass + def switch_interface(self, task): + """Optionally switch the interface to use for deployment. + + This method is called at the beginning of deployment before validation + to accommodate interfaces which auto-detect another interface to use + for deployment. + + :param task: A TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if the interface is not enabled. + """ + pass + + def restore_interface(self, task): + """Restore the original deploy interface for the node. + + This restores the deploy interface to the original interface + in case it was changed by switch_interface. + + :param task: a TaskManager instance containing the node to act on. + """ + pass + + def supports_deploy(self, task): + """Check if deploy is supported for the given node by this interface. + + By default interfaces will claim to support deploy. Interfaces + which can infer actual support using (for example) node instance_info + should override this method. + + :param task: A TaskManager instance containing the node to act on. + :returns: boolean, whether deploy is supported. + """ + return True + class BootInterface(BaseInterface): """Interface for boot-related actions.""" diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index 880f760541..7c80c234f2 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -20,6 +20,7 @@ from ironic.drivers.modules import agent from ironic.drivers.modules import agent_power from ironic.drivers.modules.ansible import deploy as ansible_deploy +from ironic.drivers.modules import autodetect from ironic.drivers.modules import fake from ironic.drivers.modules import inspector from ironic.drivers.modules import ipxe @@ -49,9 +50,10 @@ def supported_boot_interfaces(self): @property def supported_deploy_interfaces(self): """List of supported deploy interfaces.""" - return [agent.AgentDeploy, ansible_deploy.AnsibleDeploy, - ramdisk.RamdiskDeploy, pxe.PXEAnacondaDeploy, - agent.BootcAgentDeploy, agent.CustomAgentDeploy] + return [autodetect.AutodetectDeploy, agent.AgentDeploy, + ansible_deploy.AnsibleDeploy, ramdisk.RamdiskDeploy, + pxe.PXEAnacondaDeploy, agent.BootcAgentDeploy, + agent.CustomAgentDeploy] @property def supported_inspect_interfaces(self): diff --git a/ironic/drivers/modules/agent.py b/ironic/drivers/modules/agent.py index 39f25bbe84..7f76bcfc17 100644 --- a/ironic/drivers/modules/agent.py +++ b/ironic/drivers/modules/agent.py @@ -1050,6 +1050,38 @@ def set_boot_to_disk(self, task): # Call the helper to de-duplicate code. set_boot_to_disk(task) + def supports_deploy(self, task): + """Check if deploy is supported for the given node by this interface. + + :param task: A TaskManager instance containing the node to act on. + :returns: True if the image_source has an oci:// URL scheme + and repository introspection shows it is a bootc image. + """ + node = task.node + image_source = node.instance_info.get('image_source') + if image_source and not image_source.startswith('oci://'): + return False + + # Detect if the container image is actually a bootc image, which + # requires changing the deploy interface + try: + oci = image_service.OciImageService() + image_auth = image_service.get_image_service_auth_override( + node) + oci.set_image_auth(image_source, image_auth) + + image_download_source = deploy_utils.get_image_download_source( + node) + image_info = oci.identify_specific_image( + image_source, image_download_source, + node.properties.get('cpu_arch') + ) + return image_info.get('image_disk_format') == 'bootc' + except Exception as e: + LOG.error('Failed to detect bootc image for node %(node)s: ' + '%(err)s', {'node': node.uuid, 'err': e}) + return False + class AgentRAID(base.RAIDInterface): """Implementation of RAIDInterface which uses agent ramdisk.""" diff --git a/ironic/drivers/modules/agent_base.py b/ironic/drivers/modules/agent_base.py index 3d15cdfb3b..f609e77007 100644 --- a/ironic/drivers/modules/agent_base.py +++ b/ironic/drivers/modules/agent_base.py @@ -771,6 +771,25 @@ def tear_down(self, task): task.driver.network.remove_provisioning_network(task) return states.DELETED + def restore_interface(self, task): + """Restore the original deploy interface for the node. + + This restores the deploy interface to the original interface + in case it was changed by switch_interface. + + :param task: a TaskManager instance containing the node to act on. + """ + original_deploy_interface = task.node.driver_internal_info.get( + 'original_deploy_interface') + if original_deploy_interface: + LOG.info('Restoring deploy interface from "%s" to "%s" ' + 'for node %s', + task.node.deploy_interface, original_deploy_interface, + task.node.uuid) + task.node.deploy_interface = original_deploy_interface + task.node.del_driver_internal_info('original_deploy_interface') + task.node.save() + @METRICS.timer('AgentBaseMixin.clean_up') def clean_up(self, task): """Clean up the deployment environment for the task's node. diff --git a/ironic/drivers/modules/autodetect.py b/ironic/drivers/modules/autodetect.py new file mode 100644 index 0000000000..00093b7344 --- /dev/null +++ b/ironic/drivers/modules/autodetect.py @@ -0,0 +1,200 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_log import log as logging + +from ironic.common import driver_factory +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import metrics_utils +from ironic.conf import CONF +from ironic.drivers import base + +LOG = logging.getLogger(__name__) + +METRICS = metrics_utils.get_metrics_logger(__name__) + + +class AutodetectDeploy(base.DeployInterface): + """Deploy interface that auto-detects the appropriate deployment method. + + """ + + def __init__(self): + super(AutodetectDeploy, self).__init__() + + # Validate that all autodetect interfaces are enabled + for interface_name in CONF.autodetect_deploy_interfaces: + self._validate_autodetect_interface(interface_name) + + def _validate_autodetect_interface(self, interface_name): + """Validate that the autodetect interface is enabled. + + :param interface_name: Name of the deploy interface to validate. + :raises: InvalidParameterValue if the interface is not enabled. + """ + + enabled_interfaces = CONF.enabled_deploy_interfaces + if interface_name not in enabled_interfaces: + raise exception.InvalidParameterValue( + _("Deploy interface '%(interface)s' is configured in " + "autodetect_deploy_interfaces but is not in " + "enabled_deploy_interfaces. Please add '%(interface)s' " + "to enabled_deploy_interfaces or remove it from " + "autodetect_deploy_interfaces.") + % {'interface': interface_name}) + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return {} + + @METRICS.timer('AutodetectDeploy.validate') + def validate(self, task): + """Validate the driver-specific Node deployment info. + + This method creates the deploy interface that would be switched to + and calls its validate() method. + + :param task: A TaskManager instance containing the node to act on. + :raises: MissingParameterValue if required parameters are missing. + """ + switchable = self._create_switchable_interface(task) + interface, interface_name, interface_supports = switchable + return interface.validate(task) + + @METRICS.timer('AutodetectDeploy.deploy') + @base.deploy_step(priority=100) + def deploy(self, task): + """Perform a deployment to the task's node. + + This method should not be called directly as the autodetect interface + is expected to switch to a concrete interface during + switch_interface(). If this is called, it means the interface switch + did not happen. + + :param task: A TaskManager instance containing the node to act on. + :raises: InstanceDeployFailure if deployment fails. + """ + raise exception.InstanceDeployFailure( + _("Autodetect deploy interface did not switch to a concrete " + "interface during switch_interface(). This indicates a bug or " + "misconfiguration.")) + + @METRICS.timer('AutodetectDeploy.tear_down') + def tear_down(self, task): + """Tear down a previous deployment on the task's node. + + :param task: A TaskManager instance containing the node to act on. + :returns: deploy state DELETED. + """ + # Autodetect deploy interface does not perform any actual deployment. + # This is handled by AgentBaseMixin.tear_down() for actual deployments + pass + + @METRICS.timer('AutodetectDeploy.prepare') + def prepare(self, task): + """Prepare the deployment environment for the task's node. + + """ + # Autodetect deploy interface does not perform any actual deployment. + # This is handled by AgentBaseMixin.prepare() for actual deployments + raise exception.InstanceDeployFailure( + _("Autodetect deploy interface did not switch to a concrete " + "interface during switch_interface(). This indicates a bug or " + "misconfiguration.")) + + @METRICS.timer('AutodetectDeploy.clean_up') + def clean_up(self, task): + """Clean up the deployment environment for the task's node. + + :param task: A TaskManager instance containing the node to act on. + """ + pass + + @METRICS.timer('AutodetectDeploy.take_over') + def take_over(self, task): + """Take over management of this task's node from a dead conductor. + + :param task: A TaskManager instance containing the node to act on. + """ + pass + + def _create_switchable_interface(self, task): + """Detect and create the deploy interface to switch to. + + :param task: A TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if the interface is not enabled. + :returns: A tuple of (interface instance, interface name, + supports deploy). + """ + node = task.node + hw_type = driver_factory.get_hardware_type(node.driver) + + interface = None + interface_name = None + interface_supports = False + for interface_name in CONF.autodetect_deploy_interfaces: + self._validate_autodetect_interface(interface_name) + # Get the new deploy interface instance from the factory + interface = driver_factory.get_interface( + hw_type, 'deploy', interface_name) + + interface_supports = interface.supports_deploy(task) + if interface_supports: + break + + if not interface: + raise exception.InvalidParameterValue( + _("No valid deploy interfaces found in " + "autodetect_deploy_interfaces configuration.")) + + return interface, interface_name, interface_supports + + @METRICS.timer('AutodetectDeploy.switch_interface') + def switch_interface(self, task): + """Switch the interface to use for deployment. + + This calls supports_deploy() methods of deploy interfaces + configured in the 'autodetect_deploy_interfaces' option, in order, + to determine which interface is supported for the current node/image. + The first interface that returns True from supports_deploy() is chosen. + If no interfaces are detected as supported, the last interface in the + list is chosen as the fallback. + + :raises: InvalidParameterValue if the interface is not enabled. + :param task: A TaskManager instance containing the node to act on. + """ + + switchable = self._create_switchable_interface(task) + interface, interface_name, interface_supports = switchable + if not interface_supports: + LOG.warning("No deploy interfaces in autodetect_deploy_interfaces " + "are supported for this node/image. " + "Using last interface: %s", interface_name) + + LOG.info("autodetect switching to deploy interface: %s", + interface_name) + + node = task.node + # Save the original deploy interface to restore later + node.set_driver_internal_info( + 'original_deploy_interface', + task.node.deploy_interface) + # Update the node's deploy interface name + node.deploy_interface = interface_name + # Replace the deploy interface on the driver + task.driver.deploy = interface + node.save() diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index c1d3de09e9..72855bf7c0 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -608,6 +608,9 @@ def validate_image_properties(task, deploy_info): if boot_option == 'kickstart': properties.append('stage2_id') image_props = get_image_properties(task.context, image_href) + # boot_iso_id is sufficient for ramdisk deploy + if boot_option == 'ramdisk' and image_props.get('boot_iso_id'): + return else: # We are likely netbooting in this case... properties = ['kernel', 'ramdisk'] @@ -890,9 +893,21 @@ def get_image_instance_info(node): else: boot_option = get_boot_option(node) if boot_option == 'ramdisk': - # Ramdisk deploy does not require an image - info['kernel'] = node.instance_info.get('kernel') - info['ramdisk'] = node.instance_info.get('ramdisk') + kernel = node.instance_info.get('kernel') + ramdisk_val = node.instance_info.get('ramdisk') + if kernel or ramdisk_val: + # Standalone: kernel/ramdisk directly specified + info['kernel'] = kernel + info['ramdisk'] = ramdisk_val + elif image_source: + # Nova path: resolve kernel/ramdisk from Glance + # image properties later (in + # pxe_utils.get_instance_image_info) + info['image_source'] = image_source + else: + # Nothing useful provided — will fail validation + info['kernel'] = None + info['ramdisk'] = None else: info['image_source'] = image_source diff --git a/ironic/drivers/modules/ramdisk.py b/ironic/drivers/modules/ramdisk.py index 643431fe23..6d0475bcec 100644 --- a/ironic/drivers/modules/ramdisk.py +++ b/ironic/drivers/modules/ramdisk.py @@ -17,7 +17,9 @@ from oslo_log import log as logging from ironic.common import exception +from ironic.common.glance_service import service_utils from ironic.common.i18n import _ +from ironic.common import image_service from ironic.common import metrics_utils from ironic.common import states from ironic.conductor import task_manager @@ -37,6 +39,56 @@ class RamdiskDeploy(agent_base.AgentBaseMixin, agent_base.HeartbeatMixin, def get_properties(self): return {} + def supports_deploy(self, task): + """Check if this interface supports the given deployment. + + Ramdisk deploy is appropriate when + ``ironic_ramdisk_deploy=True`` is present **and** one of + the following holds: + + * ``boot_iso`` is set in instance_info, or + * ``kernel`` and ``ramdisk`` are set in instance_info, or + * ``image_source`` is a Glance image with ``boot_iso_id`` + property, or + * ``image_source`` is a Glance image with ``kernel_id`` + and ``ramdisk_id`` properties. + + For instance_info cases the sentinel is looked up in + ``instance_info``; for Glance image cases it is looked up + in the image properties. + + :param task: A TaskManager instance containing the node to + act on. + :returns: True if ramdisk deploy is appropriate. + """ + instance_info = task.node.instance_info + if (instance_info.get('boot_iso') + and instance_info.get('ironic_ramdisk_deploy')): + return True + if (instance_info.get('kernel') + and instance_info.get('ramdisk') + and instance_info.get('ironic_ramdisk_deploy')): + return True + image_source = instance_info.get('image_source') + if (image_source + and service_utils.is_glance_image(image_source)): + try: + props = deploy_utils.get_image_properties( + task.context, image_source) + if not props.get('ironic_ramdisk_deploy'): + return False + if props.get('boot_iso_id'): + return True + if (props.get('kernel_id') + and props.get('ramdisk_id')): + return True + except Exception: + LOG.warning( + 'Failed to query Glance image %s ' + 'for ramdisk deploy detection', + image_source) + return False + def validate(self, task): if 'ramdisk_boot' not in task.driver.boot.capabilities: raise exception.InvalidParameterValue( @@ -95,6 +147,68 @@ def prepare(self, task): # NOTE(TheJulia): If this was any other interface, we would # unconfigure tenant networks, add provisioning networks, etc. task.driver.storage.attach_volumes(task) + # Resolve boot image info from Glance when only + # image_source is provided (e.g. Nova path). + self._resolve_image_info_from_glance(task) if node.provision_state in (states.ACTIVE, states.UNRESCUING): # In the event of takeover or unrescue. task.driver.boot.prepare_instance(task) + + def _resolve_image_info_from_glance(self, task): + """Resolve boot_iso or kernel/ramdisk from Glance image properties. + + When only image_source is set (e.g. Nova deploys), query Glance + for the image properties and populate instance_info with + boot_iso, or kernel and ramdisk as appropriate. + + :param task: a TaskManager instance. + """ + node = task.node + i_info = node.instance_info + image_source = i_info.get('image_source') + + if (not image_source + or i_info.get('boot_iso') + or i_info.get('kernel') + or not service_utils.is_glance_image(image_source)): + return + + try: + img_service = image_service.get_image_service( + image_source, context=task.context) + image_props = img_service.show(image_source)['properties'] + except (exception.GlanceConnectionFailed, + exception.ImageNotAuthorized, + exception.ImageNotFound, + exception.Invalid) as e: + LOG.warning('Failed to get Glance image properties for ' + 'node %(node)s: %(err)s', + {'node': node.uuid, 'err': e}) + return + + if image_props.get('boot_iso_id'): + i_info['boot_iso'] = str(image_props['boot_iso_id']) + i_info['original_image_source'] = str(image_source) + elif (image_props.get('kernel_id') + and image_props.get('ramdisk_id')): + i_info['kernel'] = str(image_props['kernel_id']) + i_info['ramdisk'] = str(image_props['ramdisk_id']) + else: + return + + # TODO(JayF): Image metadata was already inspected before + # prepare was called on the deploy driver, so + # we need to clear out invalid metadata that was + # gleaned before we got here. Ideally, we'd + # improve ordering such that we never need to do this. + i_info.pop('image_source', None) + i_info.pop('image_type', None) + node.del_driver_internal_info('is_whole_disk_image') + + # NOTE(JayF): The presence of i_info[image_source] is taken as + # a sentinel value to mean "direct deploy". It cannot + # be left in instance_info. + i_info['original_image_source'] = str(image_source) + i_info.pop('image_source', None) + node.instance_info = i_info + node.save() diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 20a660b24b..1e6bee81f5 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -1968,6 +1968,33 @@ def test_do_node_deploy_rebuild_active_state_error(self, mock_iwdi): self.assertTrue(node.driver_internal_info['is_whole_disk_image']) self.assertIsNone(node.driver_internal_info['deploy_steps']) + def test_do_node_deploy_rebuild_boot_iso_cleared(self, mock_iwdi): + # Tests that boot_iso and original_image_source are cleared + # on rebuild, so the driver re-resolves them from Glance. + mock_iwdi.return_value = True + self._start_service() + with mock.patch.object(fake.FakeDeploy, + 'deploy', autospec=True) as mock_deploy: + mock_deploy.return_value = states.DEPLOYING + node = obj_utils.create_test_node( + self.context, driver='fake-hardware', + provision_state=states.ACTIVE, + target_provision_state=states.NOSTATE, + instance_info={ + 'image_source': uuidutils.generate_uuid(), + 'boot_iso': 'glance://boot-iso-uuid', + 'original_image_source': 'glance://old-image', + }, + driver_internal_info={'is_whole_disk_image': False}) + + self.service.do_node_deploy( + self.context, node.uuid, rebuild=True) + node.refresh() + # Verify boot_iso and original_image_source are cleared. + self.assertNotIn('boot_iso', node.instance_info) + self.assertNotIn( + 'original_image_source', node.instance_info) + def test_do_node_deploy_rebuild_active_state_waiting(self, mock_iwdi): mock_iwdi.return_value = False self._start_service() diff --git a/ironic/tests/unit/drivers/modules/test_agent_base.py b/ironic/tests/unit/drivers/modules/test_agent_base.py index 010d24f40f..236f850cba 100644 --- a/ironic/tests/unit/drivers/modules/test_agent_base.py +++ b/ironic/tests/unit/drivers/modules/test_agent_base.py @@ -1727,3 +1727,42 @@ def test__freshly_booted_multi_command(self): {'command_name': 'get_deploy_steps'}, {'command_name': 'get_service_steps'}] self.assertFalse(agent_base._freshly_booted(commands, 'deploy')) + + +class RestoreDeployInterfaceTestCase(AgentDeployMixinBaseTest): + + def setUp(self): + super(RestoreDeployInterfaceTestCase, self).setUp() + self.config(enabled_deploy_interfaces=['bootc', 'direct']) + + def test_restore_interface_with_original(self): + """Test restore_interface when original exists.""" + self.node.driver_internal_info = { + 'original_deploy_interface': 'direct' + } + self.node.deploy_interface = 'bootc' + self.node.save() + + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + self.deploy.restore_interface(task) + + self.node.refresh() + self.assertEqual('direct', self.node.deploy_interface) + self.assertNotIn('original_deploy_interface', + self.node.driver_internal_info) + + def test_restore_interface_without_original(self): + """Test restore_interface when original doesn't exist.""" + original_interface = self.node.deploy_interface + self.node.save() + + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + self.deploy.restore_interface(task) + + self.node.refresh() + # Deploy interface should remain unchanged + self.assertEqual(original_interface, self.node.deploy_interface) + self.assertNotIn('original_deploy_interface', + self.node.driver_internal_info) diff --git a/ironic/tests/unit/drivers/modules/test_autodetect.py b/ironic/tests/unit/drivers/modules/test_autodetect.py new file mode 100644 index 0000000000..f23658cc67 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/test_autodetect.py @@ -0,0 +1,346 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Test class for autodetect deploy.""" + +from unittest import mock + +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules import autodetect +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as obj_utils + + +class AutodetectDeployTestCase(db_base.DbTestCase): + + def setUp(self): + super(AutodetectDeployTestCase, self).setUp() + + # Enable required deploy interfaces for autodetect to use + self.config( + autodetect_deploy_interfaces=['ramdisk', 'bootc', 'direct'], + enabled_deploy_interfaces=[ + 'autodetect', 'direct', 'bootc', 'ramdisk'], + default_deploy_interface='autodetect', + ) + + # Create a test node + instance_info = {'image_source': 'http://example.com/image'} + self.node = obj_utils.create_test_node( + self.context, + bios_interface='fake', + boot_interface='fake', + console_interface='fake', + deploy_interface='autodetect', + firmware_interface='no-firmware', + inspect_interface='no-inspect', + management_interface='fake', + network_interface='noop', + power_interface='fake', + raid_interface='no-raid', + rescue_interface='no-rescue', + storage_interface='noop', + vendor_interface='no-vendor', + instance_info=instance_info) + self.port = obj_utils.create_test_port(self.context, + node_id=self.node.id) + self.deploy = autodetect.AutodetectDeploy() + + def test_init_validates_enabled_interfaces(self): + """Test __init__ validates interfaces are enabled.""" + # This should not raise since setUp configured both bootc and direct + # as enabled + deploy = autodetect.AutodetectDeploy() + self.assertIsNotNone(deploy) + + def test_init_raises_when_interface_not_enabled(self): + """Test __init__ raises when autodetect interface not enabled.""" + # Configure autodetect to use 'ansible' but don't enable it + self.config( + autodetect_deploy_interfaces=['ansible', 'direct'], + enabled_deploy_interfaces=['autodetect', 'direct'], + ) + + # Should raise InvalidParameterValue + exc = self.assertRaises(exception.InvalidParameterValue, + autodetect.AutodetectDeploy) + self.assertIn('ansible', str(exc)) + self.assertIn('enabled_deploy_interfaces', str(exc)) + + def test_init_raises_when_multiple_interfaces_not_enabled(self): + """Test __init__ raises for first non-enabled interface.""" + # Configure multiple autodetect interfaces that aren't enabled + self.config( + autodetect_deploy_interfaces=['ansible', 'ramdisk', 'direct'], + enabled_deploy_interfaces=['autodetect', 'direct'], + ) + + # Should raise for the first one encountered (ansible) + exc = self.assertRaises(exception.InvalidParameterValue, + autodetect.AutodetectDeploy) + self.assertIn('ansible', str(exc)) + + def test_get_properties(self): + """Test get_properties returns an empty dict.""" + props = self.deploy.get_properties() + self.assertEqual({}, props) + + @mock.patch.object(autodetect.AutodetectDeploy, + '_create_switchable_interface', autospec=True) + def test_validate(self, mock_create_switch): + """Test validate calls validate on the switched interface.""" + mock_interface = mock.MagicMock() + mock_create_switch.return_value = (mock_interface, 'direct', True) + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.deploy.validate(task) + mock_create_switch.assert_called_once_with(self.deploy, task) + mock_interface.validate.assert_called_once_with(task) + + def test_deploy_raises_exception(self): + """Test deploy raises InstanceDeployFailure. + + The deploy method should never be called since autodetect + should switch to a concrete interface before deployment. + """ + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InstanceDeployFailure, + self.deploy.deploy, task) + + def test_tear_down(self): + """Test tear_down completes without error.""" + with task_manager.acquire(self.context, self.node.uuid) as task: + result = self.deploy.tear_down(task) + # Should return None and not raise any exceptions + self.assertIsNone(result) + + def test_prepare_raises_exception(self): + """Test prepare raises InstanceDeployFailure. + + The prepare method should never be called since autodetect + should switch to a concrete interface before deployment. + """ + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InstanceDeployFailure, + self.deploy.prepare, task) + + def test_clean_up(self): + """Test clean_up completes without error.""" + with task_manager.acquire(self.context, self.node.uuid) as task: + result = self.deploy.clean_up(task) + # Should return None and not raise any exceptions + self.assertIsNone(result) + + def test_take_over(self): + """Test take_over completes without error.""" + with task_manager.acquire(self.context, self.node.uuid) as task: + result = self.deploy.take_over(task) + # Should return None and not raise any exceptions + self.assertIsNone(result) + + @mock.patch('ironic.common.driver_factory.get_interface', autospec=True) + @mock.patch('ironic.common.driver_factory.get_hardware_type', + autospec=True) + def test__create_switchable_interface_bootc(self, mock_get_hw_type, + mock_get_interface): + """Test _create_switchable_interface selects bootc.""" + mock_hw_type = mock.MagicMock() + mock_get_hw_type.return_value = mock_hw_type + + # Mock bootc to support + mock_bootc_interface = mock.MagicMock() + mock_bootc_interface.supports_deploy.return_value = True + + # Return different interfaces based on the interface_name argument + def get_interface_side_effect(hw_type, iface_type, iface_name): + if iface_name == 'bootc': + return mock_bootc_interface + iface = mock.MagicMock() + iface.supports_deploy.return_value = False + return iface + + mock_get_interface.side_effect = get_interface_side_effect + + with task_manager.acquire(self.context, self.node.uuid) as task: + switchable = self.deploy._create_switchable_interface(task) + interface, name, supports = switchable + + # Should select bootc + self.assertEqual('bootc', name) + self.assertTrue(supports) + self.assertEqual(mock_bootc_interface, interface) + + @mock.patch('ironic.common.driver_factory.get_interface', autospec=True) + @mock.patch('ironic.common.driver_factory.get_hardware_type', + autospec=True) + def test__create_switchable_interface_fallback(self, mock_get_hw_type, + mock_get_interface): + """Test _create_switchable_interface falls back to last interface.""" + mock_hw_type = mock.MagicMock() + mock_get_hw_type.return_value = mock_hw_type + + # Mock all interfaces to not support + mock_ramdisk_interface = mock.MagicMock() + mock_ramdisk_interface.supports_deploy.return_value = False + + mock_bootc_interface = mock.MagicMock() + mock_bootc_interface.supports_deploy.return_value = False + + mock_direct_interface = mock.MagicMock() + mock_direct_interface.supports_deploy.return_value = False + + def get_interface_side_effect(hw_type, iface_type, iface_name): + if iface_name == 'ramdisk': + return mock_ramdisk_interface + elif iface_name == 'bootc': + return mock_bootc_interface + elif iface_name == 'direct': + return mock_direct_interface + iface = mock.MagicMock() + iface.supports_deploy.return_value = False + return iface + + mock_get_interface.side_effect = get_interface_side_effect + + with task_manager.acquire(self.context, self.node.uuid) as task: + switchable = self.deploy._create_switchable_interface(task) + interface, name, supports = switchable + + # Should fallback to direct (last in list) + self.assertEqual('direct', name) + self.assertFalse(supports) + self.assertEqual(mock_direct_interface, interface) + + @mock.patch('ironic.common.driver_factory.get_interface', autospec=True) + @mock.patch('ironic.common.driver_factory.get_hardware_type', + autospec=True) + def test__create_switchable_interface_no_valid_interfaces( + self, mock_get_hw_type, mock_get_interface): + """Test _create_switchable_interface with empty config.""" + # Configure empty autodetect_deploy_interfaces + self.config( + autodetect_deploy_interfaces=[], + ) + + mock_hw_type = mock.MagicMock() + mock_get_hw_type.return_value = mock_hw_type + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + self.deploy._create_switchable_interface, task) + + @mock.patch.object(autodetect.LOG, 'info', autospec=True) + @mock.patch.object(autodetect.AutodetectDeploy, + '_create_switchable_interface', autospec=True) + def test_switch_interface(self, mock_create_switch, mock_log_info): + """Test switch_interface switches to detected interface.""" + mock_interface = mock.MagicMock() + mock_create_switch.return_value = (mock_interface, 'bootc', True) + + with task_manager.acquire(self.context, self.node.uuid) as task: + original_interface = task.node.deploy_interface + self.deploy.switch_interface(task) + + # Verify the interface was switched + self.assertEqual('bootc', task.node.deploy_interface) + self.assertEqual(mock_interface, task.driver.deploy) + + # Verify original interface was saved + self.assertEqual( + original_interface, + task.node.driver_internal_info['original_deploy_interface']) + + # Verify log message + mock_log_info.assert_called_once_with( + "autodetect switching to deploy interface: %s", "bootc") + + @mock.patch.object(autodetect.LOG, 'warning', autospec=True) + @mock.patch.object(autodetect.LOG, 'info', autospec=True) + @mock.patch.object(autodetect.AutodetectDeploy, + '_create_switchable_interface', autospec=True) + def test_switch_interface_not_supported(self, mock_create_switch, + mock_log_info, mock_log_warning): + """Test switch_interface with no supported interface.""" + mock_interface = mock.MagicMock() + # Interface is not supported (last parameter is False) + mock_create_switch.return_value = (mock_interface, 'direct', False) + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.deploy.switch_interface(task) + + # Verify warning was logged + self.assertTrue(mock_log_warning.called) + warning_msg, interface = mock_log_warning.call_args[0] + self.assertIn("No deploy interfaces", warning_msg) + self.assertEqual("direct", interface) + + # Verify the interface was still switched to the fallback + self.assertEqual('direct', task.node.deploy_interface) + self.assertEqual(mock_interface, task.driver.deploy) + + @mock.patch.object(autodetect.AutodetectDeploy, + '_create_switchable_interface', autospec=True) + def test_switch_interface_preserves_node_state(self, mock_create_switch): + """Test switch_interface saves node state correctly.""" + mock_interface = mock.MagicMock() + mock_create_switch.return_value = (mock_interface, 'bootc', True) + + with task_manager.acquire(self.context, self.node.uuid) as task: + original_interface = task.node.deploy_interface + + self.deploy.switch_interface(task) + + # Reload the node from the database + task.node.refresh() + + # Verify changes were persisted + self.assertEqual('bootc', task.node.deploy_interface) + self.assertEqual( + original_interface, + task.node.driver_internal_info['original_deploy_interface']) + + @mock.patch('ironic.common.driver_factory.get_interface', + autospec=True) + @mock.patch('ironic.common.driver_factory.get_hardware_type', + autospec=True) + def test__create_switchable_interface_ramdisk( + self, mock_get_hw_type, mock_get_interface): + """Test _create_switchable_interface selects ramdisk.""" + mock_hw_type = mock.MagicMock() + mock_get_hw_type.return_value = mock_hw_type + + mock_ramdisk_interface = mock.MagicMock() + mock_ramdisk_interface.supports_deploy.return_value = True + + def get_interface_side_effect( + hw_type, iface_type, iface_name): + if iface_name == 'ramdisk': + return mock_ramdisk_interface + iface = mock.MagicMock() + iface.supports_deploy.return_value = False + return iface + + mock_get_interface.side_effect = ( + get_interface_side_effect) + + with task_manager.acquire( + self.context, self.node.uuid) as task: + switchable = ( + self.deploy._create_switchable_interface( + task)) + interface, name, supports = switchable + + self.assertEqual('ramdisk', name) + self.assertTrue(supports) + self.assertEqual( + mock_ramdisk_interface, interface) diff --git a/ironic/tests/unit/drivers/modules/test_deploy_utils.py b/ironic/tests/unit/drivers/modules/test_deploy_utils.py index 69c0cceb26..33e9d44ddf 100644 --- a/ironic/tests/unit/drivers/modules/test_deploy_utils.py +++ b/ironic/tests/unit/drivers/modules/test_deploy_utils.py @@ -1351,6 +1351,70 @@ def test_validate_image_properties_ramdisk_deploy( utils.validate_image_properties(self.task, inst_info) image_service_show_mock.assert_not_called() + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='ramdisk') + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_validate_image_properties_ramdisk_deploy_glance( + self, image_service_mock, boot_options_mock): + """Ramdisk deploy with Glance image_source validates via Glance.""" + instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.instance_info = instance_info + inst_info = utils.get_image_instance_info(self.node) + image_service_mock.return_value.show.return_value = { + 'properties': { + 'kernel_id': 'kernel-uuid', + 'ramdisk_id': 'ramdisk-uuid', + }, + } + utils.validate_image_properties(self.task, inst_info) + image_service_mock.assert_called_once_with( + 'glance://image-uuid', context=self.context + ) + + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='ramdisk') + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_validate_image_properties_ramdisk_glance_boot_iso_id( + self, image_service_mock, boot_options_mock): + """Ramdisk deploy with Glance boot_iso_id passes validation.""" + instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.instance_info = instance_info + inst_info = utils.get_image_instance_info(self.node) + image_service_mock.return_value.show.return_value = { + 'properties': { + 'boot_iso_id': 'boot-iso-uuid', + }, + } + utils.validate_image_properties(self.task, inst_info) + image_service_mock.assert_called_once_with( + 'glance://image-uuid', context=self.context + ) + + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='kickstart') + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_validate_image_properties_non_ramdisk_boot_iso_id_fails( + self, image_service_mock, boot_options_mock): + """Non-ramdisk deploy with only boot_iso_id fails validation.""" + instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.instance_info = instance_info + inst_info = utils.get_image_instance_info(self.node) + image_service_mock.return_value.show.return_value = { + 'properties': { + 'boot_iso_id': 'boot-iso-uuid', + }, + } + self.assertRaises( + exception.MissingParameterValue, + utils.validate_image_properties, + self.task, inst_info) + @mock.patch.object(utils, 'get_boot_option', autospec=True, return_value='kickstart') @mock.patch.object(image_service.HttpImageService, 'show', autospec=True) @@ -1481,6 +1545,50 @@ def test__get_img_instance_info_ramdisk_deploy(self, mock_boot_opt): self.assertIsNotNone(info['ramdisk']) self.assertNotIn('image_source', info) + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='ramdisk') + def test__get_img_instance_info_ramdisk_with_image_source( + self, mock_boot_opt): + """Ramdisk boot with only image_source (Nova/Glance path).""" + instance_info = { + 'image_source': 'glance://image-uuid', + } + + info = self._test__get_img_instance_info( + instance_info=instance_info) + self.assertEqual('glance://image-uuid', info['image_source']) + self.assertNotIn('kernel', info) + self.assertNotIn('ramdisk', info) + + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='ramdisk') + def test__get_img_instance_info_ramdisk_with_kernel_ramdisk( + self, mock_boot_opt): + """Ramdisk boot with kernel+ramdisk takes precedence.""" + instance_info = { + 'image_source': 'glance://image-uuid', + 'kernel': 'http://kernel', + 'ramdisk': 'http://ramdisk', + } + + info = self._test__get_img_instance_info( + instance_info=instance_info) + self.assertEqual('http://kernel', info['kernel']) + self.assertEqual('http://ramdisk', info['ramdisk']) + self.assertNotIn('image_source', info) + + @mock.patch.object(utils, 'get_boot_option', autospec=True, + return_value='ramdisk') + def test__get_img_instance_info_ramdisk_nothing( + self, mock_boot_opt): + """Ramdisk boot with nothing set raises error.""" + instance_info = {} + + self.assertRaises( + exception.MissingParameterValue, + self._test__get_img_instance_info, + instance_info=instance_info) + class InstanceInfoTestCase(db_base.DbTestCase): diff --git a/ironic/tests/unit/drivers/modules/test_ramdisk.py b/ironic/tests/unit/drivers/modules/test_ramdisk.py index 66d11aa185..42c1090c6e 100644 --- a/ironic/tests/unit/drivers/modules/test_ramdisk.py +++ b/ironic/tests/unit/drivers/modules/test_ramdisk.py @@ -20,6 +20,7 @@ from ironic.common import boot_devices from ironic.common import dhcp_factory from ironic.common import exception +from ironic.common import image_service from ironic.common import pxe_utils from ironic.common import states from ironic.conductor import task_manager @@ -60,7 +61,8 @@ def setUp(self): self.config(**config_kwarg) self.config(enabled_hardware_types=['fake-hardware']) instance_info = {'kernel': 'kernelUUID', - 'ramdisk': 'ramdiskUUID'} + 'ramdisk': 'ramdiskUUID', + 'ironic_ramdisk_deploy': 'True'} self.node = obj_utils.create_test_node( self.context, driver='fake-hardware', @@ -287,6 +289,89 @@ def test_execute_clean_step(self, mock_execute_step): self.assertIs(result, mock_execute_step.return_value) mock_execute_step.assert_called_once_with(task, step, 'clean') + @mock.patch.object(pxe.PXEBoot, 'prepare_instance', autospec=True) + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_prepare_resolves_glance_boot_iso( + self, mock_image_service, mock_prepare_instance): + """Ramdisk prepare resolves boot_iso from Glance.""" + mock_show = mock.MagicMock() + mock_show.return_value = { + 'properties': {'boot_iso_id': 'glance-iso-uuid'} + } + mock_image_service.return_value.show = mock_show + + self.node.provision_state = states.DEPLOYING + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.deploy.prepare(task) + self.assertEqual( + 'glance-iso-uuid', + task.node.instance_info['boot_iso']) + self.assertFalse(mock_prepare_instance.called) + + @mock.patch.object(pxe.PXEBoot, 'prepare_instance', autospec=True) + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_prepare_resolves_glance_kernel_ramdisk( + self, mock_image_service, mock_prepare_instance): + """Ramdisk prepare resolves kernel/ramdisk from Glance.""" + mock_show = mock.MagicMock() + mock_show.return_value = { + 'properties': { + 'kernel_id': 'glance-kernel-uuid', + 'ramdisk_id': 'glance-ramdisk-uuid', + } + } + mock_image_service.return_value.show = mock_show + + self.node.provision_state = states.DEPLOYING + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.deploy.prepare(task) + self.assertEqual( + 'glance-kernel-uuid', + task.node.instance_info['kernel']) + self.assertEqual( + 'glance-ramdisk-uuid', + task.node.instance_info['ramdisk']) + self.assertFalse(mock_prepare_instance.called) + + @mock.patch.object(pxe.PXEBoot, 'prepare_instance', autospec=True) + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_prepare_skips_glance_when_kernel_set( + self, mock_image_service, mock_prepare_instance): + """Ramdisk prepare skips Glance when kernel already set.""" + self.node.provision_state = states.DEPLOYING + self.node.instance_info = { + 'kernel': 'http://kernel', + 'ramdisk': 'http://ramdisk', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.deploy.prepare(task) + mock_image_service.assert_not_called() + self.assertFalse(mock_prepare_instance.called) + + @mock.patch.object(pxe.PXEBoot, 'prepare_instance', autospec=True) + @mock.patch.object(image_service, 'get_image_service', autospec=True) + def test_prepare_skips_glance_when_not_glance_image( + self, mock_image_service, mock_prepare_instance): + """Ramdisk prepare skips Glance for non-Glance image sources.""" + self.node.provision_state = states.DEPLOYING + self.node.instance_info = { + 'image_source': 'http://example.com/image', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.deploy.prepare(task) + mock_image_service.assert_not_called() + self.assertFalse(mock_prepare_instance.called) + @mock.patch.object(deploy_utils, 'prepare_inband_cleaning', autospec=True) def test_prepare_cleaning(self, prepare_inband_cleaning_mock): prepare_inband_cleaning_mock.return_value = states.CLEANWAIT @@ -303,3 +388,133 @@ def test_tear_down_cleaning(self, tear_down_cleaning_mock): task.driver.deploy.tear_down_cleaning(task) tear_down_cleaning_mock.assert_called_once_with( task, manage_boot=True) + + def test_supports_deploy_kernel_ramdisk(self): + """Returns True when kernel and ramdisk are set.""" + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + def test_supports_deploy_boot_iso(self): + """Returns True when boot_iso is set.""" + self.node.instance_info = {'boot_iso': 'bootISOUUID', + 'ironic_ramdisk_deploy': True} + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + def test_supports_deploy_kernel_ramdisk_with_image_source(self): + """Returns True when kernel and ramdisk are set with image.""" + self.node.instance_info = { + 'kernel': 'kernelUUID', + 'ramdisk': 'ramdiskUUID', + 'image_source': 'glance://image-uuid', + 'ironic_ramdisk_deploy': True, + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + def test_supports_deploy_empty(self): + """Returns False when nothing relevant is set.""" + self.node.instance_info = {} + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertFalse( + task.driver.deploy.supports_deploy(task)) + + @mock.patch.object(deploy_utils, 'get_image_properties', + autospec=True) + def test_supports_deploy_glance_image_source( + self, mock_get_props): + """Returns True when Glance image has kernel/ramdisk/sentinel.""" + mock_get_props.return_value = { + 'kernel_id': 'kernel-uuid', + 'ramdisk_id': 'ramdisk-uuid', + 'ironic_ramdisk_deploy': 'True', + } + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + @mock.patch.object(deploy_utils, 'get_image_properties', + autospec=True) + def test_supports_deploy_glance_boot_iso_id( + self, mock_get_props): + """Returns True when Glance image has boot_iso_id.""" + mock_get_props.return_value = { + 'boot_iso_id': 'boot-iso-uuid', + 'ironic_ramdisk_deploy': True, + } + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + @mock.patch.object(deploy_utils, 'get_image_properties', + autospec=True) + def test_supports_deploy_glance_with_sentinel( + self, mock_get_props): + """Returns True with kernel/ramdisk and sentinel property.""" + mock_get_props.return_value = { + 'kernel_id': 'kernel-uuid', + 'ramdisk_id': 'ramdisk-uuid', + 'ironic_ramdisk_deploy': 'True', + } + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertTrue( + task.driver.deploy.supports_deploy(task)) + + @mock.patch.object(deploy_utils, 'get_image_properties', + autospec=True) + def test_supports_deploy_glance_kernel_ramdisk_no_sentinel( + self, mock_get_props): + """Returns False with kernel/ramdisk but no sentinel.""" + mock_get_props.return_value = { + 'kernel_id': 'kernel-uuid', + 'ramdisk_id': 'ramdisk-uuid', + } + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertFalse( + task.driver.deploy.supports_deploy(task)) + + @mock.patch.object(deploy_utils, 'get_image_properties', + autospec=True) + def test_supports_deploy_glance_no_kernel_ramdisk( + self, mock_get_props): + """Returns False when Glance image lacks kernel/ramdisk.""" + mock_get_props.return_value = {} + self.node.instance_info = { + 'image_source': 'glance://image-uuid', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertFalse( + task.driver.deploy.supports_deploy(task)) + + def test_supports_deploy_http_image_source(self): + """Returns False for non-Glance image_source.""" + self.node.instance_info = { + 'image_source': 'http://example.com/image', + } + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertFalse( + task.driver.deploy.supports_deploy(task)) diff --git a/releasenotes/notes/autodetect-deploy-00d39930a108bb74.yaml b/releasenotes/notes/autodetect-deploy-00d39930a108bb74.yaml new file mode 100644 index 0000000000..5d0297e49f --- /dev/null +++ b/releasenotes/notes/autodetect-deploy-00d39930a108bb74.yaml @@ -0,0 +1,26 @@ +--- +features: + - | + Adds a new ``autodetect`` deploy interface that automatically selects + the appropriate concrete deploy interface based on image metadata and + node configuration. This simplifies node configuration by eliminating + the need to manually specify different deploy interfaces for different + image types. + + The autodetect interface inspects the deployment at the beginning of the + deployment and switches to the most appropriate deploy interface from the + configured list in ``autodetect_deploy_interfaces``. + The detection logic checks interfaces in priority order, so if + ``autodetect_deploy_interfaces`` is set to ``['bootc', 'direct']``: + + - ``bootc`` Selected for OCI container images (oci:// URLs) that are + identified as bootc images through container introspection + - ``direct`` Fallback for standard disk images and OCI artifacts + + The autodetect interface is now enabled by default in + ``enabled_deploy_interfaces``. A new configuration option + ``autodetect_deploy_interfaces`` (default: ``['direct']``) + controls which interfaces can be auto-selected and their priority order. + + The original deploy interface is preserved in driver_internal_info + and automatically restored when exiting the ``cleaning`` state. diff --git a/releasenotes/notes/autodetect-ramdisk-default-1a3f51e01a4c90be.yaml b/releasenotes/notes/autodetect-ramdisk-default-1a3f51e01a4c90be.yaml new file mode 100644 index 0000000000..de1197f28c --- /dev/null +++ b/releasenotes/notes/autodetect-ramdisk-default-1a3f51e01a4c90be.yaml @@ -0,0 +1,10 @@ +--- +upgrade: + - | + The ``ramdisk`` deploy interface is now included in the default + value of ``autodetect_deploy_interfaces``. The new default is + ``['ramdisk', 'bootc', 'direct']``. Operators who have not + explicitly set ``autodetect_deploy_interfaces`` will now have + ramdisk deploy auto-selected when using an ``image_source`` + from glance with properly configured metadata. + diff --git a/releasenotes/notes/autodetect-ramdisk-deploy-dcd1968311d96f45.yaml b/releasenotes/notes/autodetect-ramdisk-deploy-dcd1968311d96f45.yaml new file mode 100644 index 0000000000..6c93c984fc --- /dev/null +++ b/releasenotes/notes/autodetect-ramdisk-deploy-dcd1968311d96f45.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + The ``ramdisk`` deploy interface now implements ``supports_deploy()``, + allowing it to be used with the ``autodetect`` deploy interface. + When ``ramdisk`` is included in ``autodetect_deploy_interfaces`` + and ``enabled_deploy_interfaces``, autodetect will select it when the + node's + ``instance_info`` contains ``kernel`` and ``ramdisk`` without an + ``image_source``, or when ``boot_iso`` is set. diff --git a/releasenotes/notes/ramdisk-deploy-from-nova-02c1753b152549fb.yaml b/releasenotes/notes/ramdisk-deploy-from-nova-02c1753b152549fb.yaml new file mode 100644 index 0000000000..0f5c2f9984 --- /dev/null +++ b/releasenotes/notes/ramdisk-deploy-from-nova-02c1753b152549fb.yaml @@ -0,0 +1,12 @@ +features: + - | + The ramdisk deploy_interface now accepts a single glance image in + image_source in addition to the previous syntax requiring separate kernel + and ramdisk ids. This will permit operators using Nova to deploy instances + using the ramdisk deploy_interface. + + Operators wishing to use this new support will need to upload a + placeholder image in Glance with metadata indicating either a boot_iso_id + or a kernel_id and ramdisk_id. See the Ramdisk Boot section of the admin + guide for more detail. + diff --git a/setup.cfg b/setup.cfg index 6e8a192367..0d6962ec86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,6 +98,7 @@ ironic.hardware.interfaces.console = ironic.hardware.interfaces.deploy = anaconda = ironic.drivers.modules.pxe:PXEAnacondaDeploy ansible = ironic.drivers.modules.ansible.deploy:AnsibleDeploy + autodetect = ironic.drivers.modules.autodetect:AutodetectDeploy bootc = ironic.drivers.modules.agent:BootcAgentDeploy custom-agent = ironic.drivers.modules.agent:CustomAgentDeploy direct = ironic.drivers.modules.agent:AgentDeploy