From abaa3fa8765baa72279c9f64ef65ac9c91bd6739 Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Fri, 16 Jan 2026 17:21:43 +1300 Subject: [PATCH 1/8] Add an autodetect deploy interface 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. Assisted-By: Claude Code - Claude Sonnet 4.5 Change-Id: I87118cab0f51339e57c93f205d98e0861f2a3596 Signed-off-by: Steve Baker Closes-Bug: #2130646 NOTE(dougszu): This was an almost clean backport Conflicts: ironic/conductor/cleaning.py pyproject.toml --- doc/source/admin/interfaces/deploy.rst | 68 ++++ ironic/conductor/cleaning.py | 3 + ironic/conductor/deployments.py | 3 + ironic/conductor/utils.py | 3 + ironic/conf/default.py | 15 +- ironic/drivers/base.py | 34 ++ ironic/drivers/generic.py | 8 +- ironic/drivers/modules/agent.py | 32 ++ ironic/drivers/modules/agent_base.py | 19 ++ ironic/drivers/modules/autodetect.py | 201 ++++++++++++ .../unit/drivers/modules/test_agent_base.py | 39 +++ .../unit/drivers/modules/test_autodetect.py | 303 ++++++++++++++++++ .../autodetect-deploy-00d39930a108bb74.yaml | 26 ++ setup.cfg | 1 + 14 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 ironic/drivers/modules/autodetect.py create mode 100644 ironic/tests/unit/drivers/modules/test_autodetect.py create mode 100644 releasenotes/notes/autodetect-deploy-00d39930a108bb74.yaml diff --git a/doc/source/admin/interfaces/deploy.rst b/doc/source/admin/interfaces/deploy.rst index 4d3127573e..f14cb1e5a7 100644 --- a/doc/source/admin/interfaces/deploy.rst +++ b/doc/source/admin/interfaces/deploy.rst @@ -121,6 +121,64 @@ 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 = direct + +This default will always switch to :ref:`Direct deploy ` +for deployment. + +.. 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 @@ -254,6 +312,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/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..393d7471a6 100644 --- a/ironic/conductor/deployments.py +++ b/ironic/conductor/deployments.py @@ -173,6 +173,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..5ed417db00 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=['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. ' + '"bootc" has 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..e48f972691 --- /dev/null +++ b/ironic/drivers/modules/autodetect.py @@ -0,0 +1,201 @@ +# 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/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..e07a77d087 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/test_autodetect.py @@ -0,0 +1,303 @@ +# 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=['bootc', 'direct'], + enabled_deploy_interfaces=['autodetect', 'direct', 'bootc'], + 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 + return mock.MagicMock() + + 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 (second in priority list) + 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 == 'bootc': + return mock_bootc_interface + elif iface_name == 'direct': + return mock_direct_interface + return mock.MagicMock() + + 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 priority 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']) 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/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 From 3c7537f12ca52e6ae271f146c91d516758bf1c41 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Mon, 2 Mar 2026 15:30:04 -0800 Subject: [PATCH 2/8] Support ramdisk deploy with Glance image_source When Nova deploys an instance on an Ironic node using the ramdisk deploy interface, it sets image_source but not kernel/ramdisk in instance_info. Previously this caused validation to fail because get_image_instance_info() only looked for direct kernel/ramdisk values in the ramdisk boot path. Modify get_image_instance_info() to pass image_source through when kernel/ramdisk are not directly set, and resolve kernel_id/ramdisk_id or boot_iso_id from Glance image properties. This enables Nova to deploy to nodes configured with the ramdisk deploy_interface. The image_source is preserved in instance_info as original_image_source. Generated-By: claude-code Change-Id: I5a4f3e2b8c1d9e7a6f0b3c4d5e8f1a2b3c4d5e6f Signed-off-by: Jay Faulkner --- doc/source/admin/ramdisk-boot.rst | 87 ++++++++++++++ .../install/configure-glance-images.rst | 5 + ironic/drivers/modules/deploy_utils.py | 21 +++- ironic/drivers/modules/ramdisk.py | 64 +++++++++++ .../unit/drivers/modules/test_deploy_utils.py | 108 ++++++++++++++++++ .../unit/drivers/modules/test_ramdisk.py | 84 ++++++++++++++ ...isk-deploy-from-nova-02c1753b152549fb.yaml | 12 ++ 7 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/ramdisk-deploy-from-nova-02c1753b152549fb.yaml diff --git a/doc/source/admin/ramdisk-boot.rst b/doc/source/admin/ramdisk-boot.rst index 5beff555e3..3725492a11 100644 --- a/doc/source/admin/ramdisk-boot.rst +++ b/doc/source/admin/ramdisk-boot.rst @@ -125,6 +125,93 @@ 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: + + .. 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 \ + --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 raw --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..760ba4f011 100644 --- a/doc/source/install/configure-glance-images.rst +++ b/doc/source/install/configure-glance-images.rst @@ -84,6 +84,11 @@ 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 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/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..f5c04068a6 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 @@ -95,6 +97,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/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..4e2a88b834 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 @@ -287,6 +288,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 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. + From 5691f61082868c51c644deaabf88f09c134d5ca6 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Mon, 2 Mar 2026 15:49:04 -0800 Subject: [PATCH 3/8] Add ramdisk to autodetect deploy interface Implement supports_deploy() on RamdiskDeploy so the autodetect deploy interface can select it automatically. The ramdisk interface is chosen when instance_info contains kernel and ramdisk without an image_source, or when boot_iso is set. Because a Glance image with kernel_id/ramdisk_id alone is ambiguous - it could be a partition image - Require the sentinel property ironic_ramdisk_deploy=True in image metadata to distinguish ramdisk deploy images during autodetect. Also add boot_iso_id detection to supports_deploy() so that Glance images referencing a boot ISO are automatically detected as ramdisk deploy candidates. Related-bug: #2137729 Generated-By: claude-code Change-Id: I7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c Signed-off-by: Jay Faulkner NOTE(dougszu): Just missing bootc interface in doc which is implemented in Epoxy. Conflicts: doc/source/admin/interfaces/deploy.rst --- doc/source/admin/interfaces/deploy.rst | 18 ++- doc/source/admin/ramdisk-boot.rst | 14 +- .../install/configure-glance-images.rst | 5 +- ironic/drivers/modules/ramdisk.py | 50 +++++++ .../unit/drivers/modules/test_autodetect.py | 57 +++++++- .../unit/drivers/modules/test_ramdisk.py | 133 +++++++++++++++++- ...etect-ramdisk-deploy-dcd1968311d96f45.yaml | 10 ++ 7 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 releasenotes/notes/autodetect-ramdisk-deploy-dcd1968311d96f45.yaml diff --git a/doc/source/admin/interfaces/deploy.rst b/doc/source/admin/interfaces/deploy.rst index f14cb1e5a7..938db4f52b 100644 --- a/doc/source/admin/interfaces/deploy.rst +++ b/doc/source/admin/interfaces/deploy.rst @@ -163,10 +163,12 @@ their priority order. The default configuration is: .. code-block:: ini [DEFAULT] - autodetect_deploy_interfaces = direct + autodetect_deploy_interfaces = ramdisk,bootc,direct -This default will always switch to :ref:`Direct deploy ` -for deployment. +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 @@ -236,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 diff --git a/doc/source/admin/ramdisk-boot.rst b/doc/source/admin/ramdisk-boot.rst index 3725492a11..42f64dc7d9 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 ------------------ @@ -155,7 +162,11 @@ and Ironic resolves the kernel and ramdisk from Glance automatically. #. 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: + 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 @@ -164,6 +175,7 @@ and Ironic resolves the kernel and ramdisk from Glance automatically. --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: diff --git a/doc/source/install/configure-glance-images.rst b/doc/source/install/configure-glance-images.rst index 760ba4f011..aec4c90447 100644 --- a/doc/source/install/configure-glance-images.rst +++ b/doc/source/install/configure-glance-images.rst @@ -86,8 +86,9 @@ the Image service for each one as it is generated. - 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 or a single ``boot_iso_id`` property pointing to - an ISO in Glance. See :doc:`/admin/ramdisk-boot` for details. + ``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/drivers/modules/ramdisk.py b/ironic/drivers/modules/ramdisk.py index f5c04068a6..6d0475bcec 100644 --- a/ironic/drivers/modules/ramdisk.py +++ b/ironic/drivers/modules/ramdisk.py @@ -39,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( diff --git a/ironic/tests/unit/drivers/modules/test_autodetect.py b/ironic/tests/unit/drivers/modules/test_autodetect.py index e07a77d087..27c982c6bf 100644 --- a/ironic/tests/unit/drivers/modules/test_autodetect.py +++ b/ironic/tests/unit/drivers/modules/test_autodetect.py @@ -28,8 +28,9 @@ def setUp(self): # Enable required deploy interfaces for autodetect to use self.config( - autodetect_deploy_interfaces=['bootc', 'direct'], - enabled_deploy_interfaces=['autodetect', 'direct', 'bootc'], + autodetect_deploy_interfaces=['ramdisk', 'bootc', 'direct'], + enabled_deploy_interfaces=[ + 'autodetect', 'direct', 'bootc', 'ramdisk'], default_deploy_interface='autodetect', ) @@ -164,7 +165,9 @@ def test__create_switchable_interface_bootc(self, mock_get_hw_type, def get_interface_side_effect(hw_type, iface_type, iface_name): if iface_name == 'bootc': return mock_bootc_interface - return mock.MagicMock() + iface = mock.MagicMock() + iface.supports_deploy.return_value = False + return iface mock_get_interface.side_effect = get_interface_side_effect @@ -172,7 +175,7 @@ def get_interface_side_effect(hw_type, iface_type, iface_name): switchable = self.deploy._create_switchable_interface(task) interface, name, supports = switchable - # Should select bootc (second in priority list) + # Should select bootc self.assertEqual('bootc', name) self.assertTrue(supports) self.assertEqual(mock_bootc_interface, interface) @@ -197,11 +200,15 @@ def test__create_switchable_interface_fallback(self, mock_get_hw_type, mock_direct_interface.supports_deploy.return_value = False def get_interface_side_effect(hw_type, iface_type, iface_name): - if iface_name == 'bootc': + if iface_name == 'ramdisk': + return mock_ramdisk_interface + elif iface_name == 'bootc': return mock_bootc_interface elif iface_name == 'direct': return mock_direct_interface - return mock.MagicMock() + iface = mock.MagicMock() + iface.supports_deploy.return_value = False + return iface mock_get_interface.side_effect = get_interface_side_effect @@ -209,7 +216,7 @@ def get_interface_side_effect(hw_type, iface_type, iface_name): switchable = self.deploy._create_switchable_interface(task) interface, name, supports = switchable - # Should fallback to direct (last in priority list) + # Should fallback to direct (last in list) self.assertEqual('direct', name) self.assertFalse(supports) self.assertEqual(mock_direct_interface, interface) @@ -301,3 +308,39 @@ def test_switch_interface_preserves_node_state(self, mock_create_switch): 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_ramdisk.py b/ironic/tests/unit/drivers/modules/test_ramdisk.py index 4e2a88b834..42c1090c6e 100644 --- a/ironic/tests/unit/drivers/modules/test_ramdisk.py +++ b/ironic/tests/unit/drivers/modules/test_ramdisk.py @@ -61,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', @@ -387,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-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. From 4890fc1efb5b9977684cbe43de5c8ab672859f62 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Mon, 2 Mar 2026 15:56:46 -0800 Subject: [PATCH 4/8] Enable ramdisk autodetect by default Add ramdisk to the default autodetect_deploy_interfaces list so the autodetect deploy interface can select it without explicit operator configuration. Also update supports_deploy() to query Glance image properties for kernel_id/ramdisk_id when only image_source is set, supporting the Nova deployment path. Closes-bug: #2137729 Generated-By: claude-code Change-Id: I53e0b0750e723d2aa9b1f5656e36360a0999a8a4 Signed-off-by: Jay Faulkner NOTE(dougszu): This was arround missing Idbed1770c79b2f84bb27c8545395a0391a7b4b9c Conflicts: ironic/conf/default.py --- ironic/conf/default.py | 10 +++++----- .../autodetect-ramdisk-default-1a3f51e01a4c90be.yaml | 10 ++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 releasenotes/notes/autodetect-ramdisk-default-1a3f51e01a4c90be.yaml diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 5ed417db00..e64325cea5 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -134,7 +134,7 @@ cfg.StrOpt('default_deploy_interface', help=_DEFAULT_IFACE_HELP.format('deploy')), cfg.ListOpt('autodetect_deploy_interfaces', - default=['direct'], + 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 ' @@ -142,10 +142,10 @@ 'with the first interface having the highest ' 'priority. The last interface in the list will be ' 'chosen if no interfaces are detected as supported. ' - '"bootc" has detection support and ' - '"direct" is an appropriate fallback for most cases. ' - 'All interfaces listed here must also be in ' - '"enabled_deploy_interfaces".')), + '"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/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. + From 2d8c0eb9e10001f7a36d465d4374a0c6156b339b Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Thu, 12 Mar 2026 10:46:07 -0700 Subject: [PATCH 5/8] Correct disk-format in ramdisk boot docs Follow-up as requsted in 978828; correctly document required disk format to avoid angry image security checks. Change-Id: Ic9b61dc998d291896851c6534dcc0c06fb3e43cb Signed-off-by: Jay Faulkner --- doc/source/admin/ramdisk-boot.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/admin/ramdisk-boot.rst b/doc/source/admin/ramdisk-boot.rst index 42f64dc7d9..20319a3618 100644 --- a/doc/source/admin/ramdisk-boot.rst +++ b/doc/source/admin/ramdisk-boot.rst @@ -196,7 +196,7 @@ ISO stored in Glance via the ``boot_iso_id`` property: .. code-block:: shell openstack image create my-ramdisk-boot-iso --public \ - --disk-format raw --container-format bare \ + --disk-format iso --container-format bare \ --file my-ramdisk.iso Store the image UUID as ``MY_BOOT_ISO_UUID``. From 840a116a04305542bb61a52e117d49ea2a6fd5d3 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Mon, 30 Mar 2026 10:29:24 -0700 Subject: [PATCH 6/8] Fix nova rebuilds w/ramdisk driver We were not properly cleaning up boot_iso and original_image_source from instance_info on rebuild, leading to inconsistent instance_info and a failure. Closes-bug: #2146823 Assisted-by: Claude-code Change-Id: I176eef440601090a0b6dffb8d76b0e1458c91ca8 Signed-off-by: Jay Faulkner --- ironic/conductor/deployments.py | 2 ++ ironic/tests/unit/conductor/test_manager.py | 27 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/ironic/conductor/deployments.py b/ironic/conductor/deployments.py index 393d7471a6..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 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() From 71cb843c73e6f6360fa46c37448d1466f60825a7 Mon Sep 17 00:00:00 2001 From: Doug Szumski Date: Mon, 18 May 2026 12:47:26 +0100 Subject: [PATCH 7/8] Add github workflows Change-Id: I328bb78bce336a0d8e2f42a7dffd73acdf6fa866 --- .github/CODEOWNERS | 1 + .github/workflows/tag-and-release.yml | 12 ++++++++++++ .github/workflows/tox.yml | 7 +++++++ 3 files changed, 20 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/tag-and-release.yml create mode 100644 .github/workflows/tox.yml 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 From 329cd06b69d9b748a2c8e6a63fef598eca41c73e Mon Sep 17 00:00:00 2001 From: Doug Szumski Date: Mon, 18 May 2026 15:38:34 +0100 Subject: [PATCH 8/8] Nit fixes for pep8 These nits are from the cherry-picks of upstream patches. Change-Id: I9deea715e4bce492ebca2e1a7df8cdce0b446492 --- ironic/drivers/modules/autodetect.py | 3 +-- .../tests/unit/drivers/modules/test_autodetect.py | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ironic/drivers/modules/autodetect.py b/ironic/drivers/modules/autodetect.py index e48f972691..00093b7344 100644 --- a/ironic/drivers/modules/autodetect.py +++ b/ironic/drivers/modules/autodetect.py @@ -104,7 +104,6 @@ def tear_down(self, task): # 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. @@ -151,7 +150,7 @@ def _create_switchable_interface(self, task): 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) + hw_type, 'deploy', interface_name) interface_supports = interface.supports_deploy(task) if interface_supports: diff --git a/ironic/tests/unit/drivers/modules/test_autodetect.py b/ironic/tests/unit/drivers/modules/test_autodetect.py index 27c982c6bf..f23658cc67 100644 --- a/ironic/tests/unit/drivers/modules/test_autodetect.py +++ b/ironic/tests/unit/drivers/modules/test_autodetect.py @@ -96,7 +96,7 @@ def test_get_properties(self): self.assertEqual({}, props) @mock.patch.object(autodetect.AutodetectDeploy, - '_create_switchable_interface', autospec=True) + '_create_switchable_interface', autospec=True) def test_validate(self, mock_create_switch): """Test validate calls validate on the switched interface.""" mock_interface = mock.MagicMock() @@ -115,7 +115,7 @@ def test_deploy_raises_exception(self): """ with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaises(exception.InstanceDeployFailure, - self.deploy.deploy, task) + self.deploy.deploy, task) def test_tear_down(self): """Test tear_down completes without error.""" @@ -132,7 +132,7 @@ def test_prepare_raises_exception(self): """ with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaises(exception.InstanceDeployFailure, - self.deploy.prepare, task) + self.deploy.prepare, task) def test_clean_up(self): """Test clean_up completes without error.""" @@ -237,11 +237,11 @@ def test__create_switchable_interface_no_valid_interfaces( with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaises(exception.InvalidParameterValue, - self.deploy._create_switchable_interface, task) + self.deploy._create_switchable_interface, task) @mock.patch.object(autodetect.LOG, 'info', autospec=True) @mock.patch.object(autodetect.AutodetectDeploy, - '_create_switchable_interface', autospec=True) + '_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() @@ -267,7 +267,7 @@ def test_switch_interface(self, mock_create_switch, mock_log_info): @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) + '_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.""" @@ -289,7 +289,7 @@ def test_switch_interface_not_supported(self, mock_create_switch, self.assertEqual(mock_interface, task.driver.deploy) @mock.patch.object(autodetect.AutodetectDeploy, - '_create_switchable_interface', autospec=True) + '_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()