diff --git a/doc/source/install/standalone.rst b/doc/source/install/standalone.rst index 71479c47e1..92a1a89f08 100644 --- a/doc/source/install/standalone.rst +++ b/doc/source/install/standalone.rst @@ -270,6 +270,78 @@ For iLO drivers, fields that should be provided are: images, the file system modification time is used. +Ramdisk booting +--------------- + +Advanced operators, specifically ones working with ephemeral workloads, +may find it more useful to explicitly treat a node as one that would always +boot from a Ramdisk. + +This functionality is largely intended for network booting, however some +other boot interface, such as the ``redfish-virtual-media`` support enabling +the same basic functionality through the existing interfaces. + +To use, a few different settings must be modified. + +#. Change the ``deploy_interface`` on the node to ``ramdisk``:: + + openstack baremetal node set $NODE_UUID \ + --deploy-interface ramdisk + +#. Set a kernel and ramdisk to be utilized:: + + openstack baremetal node set $NODE_UUID \ + --instance-info kernel=$KERNEL_URL \ + --instance-info ramdisk=$RAMDISK_URL + +#. Deploy the node:: + + openstack baremetal node deploy $NODE_UUID + + .. warning:: + Configuration drives, also known as a configdrive, is not supported + with the ``ramdisk`` deploy interface. Please ensure your ramdisk + CPIO archive contains all necessary configuration and credentials. + This is as no disk image is written to the disk of the node being + provisioned with a ramdisk. + +The node ramdisk components will then be assembled by the conductor, +appropriate configuration put in place, and the node will then be powered +on. From there, normal node booting will occur. Upon undeployment of the node, +normal cleaning proceedures will occur as configured with-in the conductor. + +Ramdisk booting with ISO media +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Currently supported for the use of ramdisks with the ``redfish-virtual-media`` +and ``ipxe`` boot interfaces, an operator may request an explict ISO file to +be booted. + +#. Store the URL to the ISO image to ``instance_info/boot_iso``, + instead of a ``kernel`` or ``ramdisk`` setting:: + + openstack barmetal node set $NODE_UUID \ + --instance-info boot_iso=$BOOT_ISO_URL + +#. Deploy the node:: + + openstack baremetal node deploy $NODE_UUID + + +.. warning:: + This feature, when utilized with the ``ipxe`` ``boot_interface``, + will only allow a kernel and ramdisk to be booted from the + supplied ISO file. Any additional contents, such as additional + ramdisk contents or installer package files will be unavailable + after the boot of the Operating System. Operators wishing to leverage + this functionality for actions such as OS installation should explore + use of the standard ``ramdisk`` ``deploy_interface`` along with the + ``instance_info/kernel_append_params`` setting to pass arbitrary + settings such as a mirror URL for the initial ramdisk to load data from. + This is a limitation of iPXE and the overall boot process of the + operating system where memory allocated by iPXE is released. + + Other references ---------------- diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index 6cee177124..7d0af987cb 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -628,6 +628,13 @@ def get_instance_image_info(task, ipxe_enabled=False): else: root_dir = get_root_dir() i_info = node.instance_info + if i_info.get('boot_iso'): + image_info['boot_iso'] = ( + i_info['boot_iso'], + os.path.join(root_dir, node.uuid, 'boot_iso')) + + return image_info + labels = ('kernel', 'ramdisk') d_info = deploy_utils.get_image_instance_info(node) if not (i_info.get('kernel') and i_info.get('ramdisk')): @@ -637,7 +644,6 @@ def get_instance_image_info(task, ipxe_enabled=False): i_info[label] = str(iproperties[label + '_id']) node.instance_info = i_info node.save() - for label in labels: image_info[label] = ( i_info[label], @@ -726,6 +732,14 @@ def build_instance_pxe_options(task, pxe_info, ipxe_enabled=False): pxe_opts['ramdisk_opts'] = i_info['ramdisk_kernel_arguments'] except KeyError: pass + try: + # TODO(TheJulia): Boot iso should change at a later point + # if we serve more than just as a pass-through. + if i_info.get('boot_iso'): + pxe_opts['boot_iso_url'] = '/'.join( + [CONF.deploy.http_url, node.uuid, 'boot_iso']) + except KeyError: + pass return pxe_opts @@ -937,7 +951,6 @@ def prepare_instance_pxe_config(task, image_info, is in use by the caller. :returns: None """ - node = task.node # Generate options for both IPv4 and IPv6, and they can be # filtered down later based upon the port options. diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index 71aaf7bc6c..45cfdee518 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -510,12 +510,14 @@ def validate_image_properties(ctx, deploy_info, properties): :raises: MissingParameterValue if the image doesn't contain the mentioned properties. """ - image_href = deploy_info['image_source'] + image_href = deploy_info.get('image_source') boot_iso = deploy_info.get('boot_iso') if image_href and boot_iso: raise exception.InvalidParameterValue(_( "An 'image_source' and 'boot_iso' parameter may not be " "specified at the same time.")) + if not image_href: + image_href = boot_iso try: img_service = image_service.get_image_service(image_href, context=ctx) image_props = img_service.show(image_href)['properties'] @@ -710,7 +712,6 @@ def get_image_instance_info(node): is_whole_disk_image = node.driver_internal_info.get('is_whole_disk_image') boot_iso = node.instance_info.get('boot_iso') - if not boot_iso: info['image_source'] = node.instance_info.get('image_source') else: diff --git a/ironic/drivers/modules/ipxe_config.template b/ironic/drivers/modules/ipxe_config.template index 84b01d1047..f8bfc19e97 100644 --- a/ironic/drivers/modules/ipxe_config.template +++ b/ironic/drivers/modules/ipxe_config.template @@ -33,9 +33,14 @@ boot :boot_ramdisk imgfree +{%- if pxe_options.boot_iso_url %} +sanboot {{ pxe_options.boot_iso_url }} +{%- else %} kernel {% if pxe_options.ipxe_timeout > 0 %}--timeout {{ pxe_options.ipxe_timeout }} {% endif %}{{ pxe_options.aki_path }} root=/dev/ram0 text {{ pxe_options.pxe_append_params|default("", true) }} {{ pxe_options.ramdisk_opts|default('', true) }} initrd=ramdisk || goto boot_ramdisk initrd {% if pxe_options.ipxe_timeout > 0 %}--timeout {{ pxe_options.ipxe_timeout }} {% endif %}{{ pxe_options.ari_path }} || goto boot_ramdisk boot +{%- endif %} + {%- if pxe_options.boot_from_volume %} :boot_iscsi diff --git a/ironic/drivers/modules/pxe_base.py b/ironic/drivers/modules/pxe_base.py index d3cb8316e8..1c4ecb5980 100644 --- a/ironic/drivers/modules/pxe_base.py +++ b/ironic/drivers/modules/pxe_base.py @@ -244,7 +244,6 @@ class PXEBaseMixin(object): boot_option = deploy_utils.get_boot_option(node) boot_device = None instance_image_info = {} - if boot_option == "ramdisk": instance_image_info = pxe_utils.get_instance_image_info( task, ipxe_enabled=self.ipxe_enabled) @@ -365,6 +364,14 @@ class PXEBaseMixin(object): {'node': node.uuid}) pxe_utils.validate_boot_parameters_for_trusted_boot(node) + # Check if we have invalid parameters being passed which will not work + # for ramdisk configurations. + if (node.instance_info.get('image_source') + and node.instance_info.get('boot_iso')): + raise exception.InvalidParameterValue(_( + "An 'image_source' and 'boot_iso' parameter may not be " + "specified at the same time.")) + pxe_utils.parse_driver_info(node) @METRICS.timer('PXEBaseMixin.validate') @@ -393,6 +400,8 @@ class PXEBaseMixin(object): if (node.driver_internal_info.get('is_whole_disk_image') or deploy_utils.get_boot_option(node) == 'local'): props = [] + elif d_info.get('boot_iso'): + props = ['boot_iso'] elif service_utils.is_glance_image(d_info['image_source']): props = ['kernel_id', 'ramdisk_id'] else: diff --git a/ironic/tests/unit/common/test_pxe_utils.py b/ironic/tests/unit/common/test_pxe_utils.py index c647147a26..d37c89d655 100644 --- a/ironic/tests/unit/common/test_pxe_utils.py +++ b/ironic/tests/unit/common/test_pxe_utils.py @@ -108,6 +108,12 @@ class TestPXEUtils(db_base.DbTestCase): self.ipxe_options_boot_from_volume_extra_volume.pop( 'initrd_filename', None) + self.ipxe_options_boot_from_iso = self.ipxe_options.copy() + self.ipxe_options_boot_from_iso.update({ + 'boot_from_iso': True, + 'boot_iso_url': 'http://1.2.3.4:1234/uuid/iso' + }) + self.node = object_utils.create_test_node(self.context) def test_default_pxe_config(self): @@ -218,6 +224,27 @@ class TestPXEUtils(db_base.DbTestCase): expected_template = f.read().rstrip() self.assertEqual(str(expected_template), rendered_template) + def test_default_ipxe_boot_from_iso(self): + self.config( + pxe_config_template='ironic/drivers/modules/ipxe_config.template', + group='pxe' + ) + self.config(http_url='http://1.2.3.4:1234', group='deploy') + + pxe_options = self.ipxe_options_boot_from_iso + + rendered_template = utils.render_template( + CONF.pxe.pxe_config_template, + {'pxe_options': pxe_options, + 'ROOT': '{{ ROOT }}'}, + ) + + templ_file = 'ironic/tests/unit/drivers/' \ + 'ipxe_config_boot_from_iso.template' + with open(templ_file) as f: + expected_template = f.read().rstrip() + self.assertEqual(str(expected_template), rendered_template) + def test_default_grub_config(self): pxe_opts = self.pxe_options pxe_opts['boot_mode'] = 'uefi' @@ -1040,6 +1067,20 @@ class PXEInterfacesTestCase(db_base.DbTestCase): image_info = pxe_utils.get_instance_image_info(task) self.assertEqual({}, image_info) + @mock.patch('ironic.drivers.modules.deploy_utils.get_boot_option', + return_value='ramdisk') + def test_get_instance_image_info_boot_iso(self, boot_opt_mock): + self.node.instance_info = {'boot_iso': 'http://localhost/boot.iso'} + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + image_info = pxe_utils.get_instance_image_info( + task, ipxe_enabled=True) + self.assertEqual('http://localhost/boot.iso', + image_info['boot_iso'][0]) + + boot_opt_mock.assert_called_once_with(task.node) + @mock.patch.object(deploy_utils, 'fetch_images', autospec=True) def test__cache_tftp_images_master_path(self, mock_fetch_image): temp_dir = tempfile.mkdtemp() @@ -1414,7 +1455,8 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): ipxe_use_swift=False, debug=False, boot_from_volume=False, - mode='deploy'): + mode='deploy', + iso_boot=False): self.config(debug=debug) self.config(pxe_append_params='test_param', group='pxe') self.config(ipxe_timeout=ipxe_timeout, group='pxe') @@ -1520,6 +1562,19 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): expected_options.pop('deployment_ari_path') expected_options.pop('initrd_filename') + if iso_boot: + self.node.instance_info = {'boot_iso': 'http://test.url/file.iso'} + self.node.save() + print(expected_options) + print(image_info) + iso_url = os.path.join(http_url, self.node.uuid, 'boot_iso') + expected_options.update( + { + 'boot_iso_url': iso_url + + } + ) + with task_manager.acquire(self.context, self.node.uuid, shared=True) as task: options = pxe_utils.build_pxe_config_options(task, @@ -1708,6 +1763,9 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): self._test_build_pxe_config_options_ipxe(mode='rescue', ipxe_timeout=120) + def test_build_pxe_config_options_ipxe_boot_iso(self): + self._test_build_pxe_config_options_ipxe(iso_boot=True) + @mock.patch('ironic.common.utils.rmtree_without_raise', autospec=True) @mock.patch('ironic_lib.utils.unlink_without_raise', autospec=True) def test_clean_up_ipxe_config_uefi(self, unlink_mock, rmtree_mock): diff --git a/ironic/tests/unit/drivers/ipxe_config_boot_from_iso.template b/ironic/tests/unit/drivers/ipxe_config_boot_from_iso.template new file mode 100644 index 0000000000..ede73acff3 --- /dev/null +++ b/ironic/tests/unit/drivers/ipxe_config_boot_from_iso.template @@ -0,0 +1,39 @@ +#!ipxe + +set attempts:int32 10 +set i:int32 0 + +goto deploy + +:deploy +imgfree +kernel http://1.2.3.4:1234/deploy_kernel selinux=0 troubleshoot=0 text test_param BOOTIF=${mac} initrd=deploy_ramdisk || goto retry + +initrd http://1.2.3.4:1234/deploy_ramdisk || goto retry +boot + +:retry +iseq ${i} ${attempts} && goto fail || +inc i +echo No response, retrying in {i} seconds. +sleep ${i} +goto deploy + +:fail +echo Failed to get a response after ${attempts} attempts +echo Powering off in 30 seconds. +sleep 30 +poweroff + +:boot_partition +imgfree +kernel http://1.2.3.4:1234/kernel root={{ ROOT }} ro text test_param initrd=ramdisk || goto boot_partition +initrd http://1.2.3.4:1234/ramdisk || goto boot_partition +boot + +:boot_ramdisk +imgfree +sanboot http://1.2.3.4:1234/uuid/iso + +:boot_whole_disk +sanboot --no-describe diff --git a/ironic/tests/unit/drivers/modules/test_ipxe.py b/ironic/tests/unit/drivers/modules/test_ipxe.py index 1aad6b7a3e..d0bb625ff9 100644 --- a/ironic/tests/unit/drivers/modules/test_ipxe.py +++ b/ironic/tests/unit/drivers/modules/test_ipxe.py @@ -131,6 +131,30 @@ class iPXEBootTestCase(db_base.DbTestCase): self.assertRaises(exception.MissingParameterValue, task.driver.boot.validate, task) + @mock.patch.object(image_service.GlanceImageService, 'show', autospec=True) + @mock.patch('ironic.drivers.modules.deploy_utils.get_boot_option', + return_value='ramdisk', autospec=True) + def test_validate_with_boot_iso(self, mock_boot_option, mock_glance): + i_info = self.node.driver_info + i_info['boot_iso'] = "http://localhost:1234/boot.iso" + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.boot.validate(task) + self.assertTrue(mock_boot_option.called) + self.assertTrue(mock_glance.called) + + def test_validate_with_boot_iso_and_image_source(self): + i_info = self.node.instance_info + i_info['image_source'] = "http://localhost:1234/image" + i_info['boot_iso'] = "http://localhost:1234/boot.iso" + self.node.instance_info = i_info + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.boot.validate, + task) + def test_validate_fail_missing_image_source(self): info = dict(INST_INFO_DICT) del info['image_source'] @@ -820,6 +844,52 @@ class iPXEBootTestCase(db_base.DbTestCase): boot_devices.PXE, persistent=True) + @mock.patch('os.path.isfile', lambda filename: False) + @mock.patch.object(pxe_utils, 'create_pxe_config', autospec=True) + @mock.patch.object(manager_utils, 'node_set_boot_device', autospec=True) + @mock.patch.object(deploy_utils, 'switch_pxe_config', autospec=True) + @mock.patch.object(dhcp_factory, 'DHCPFactory', autospec=True) + @mock.patch.object(pxe_utils, 'cache_ramdisk_kernel', autospec=True) + @mock.patch.object(pxe_utils, 'get_instance_image_info', autospec=True) + def test_prepare_instance_netboot_ramdisk( + self, get_image_info_mock, cache_mock, + dhcp_factory_mock, switch_pxe_config_mock, + set_boot_device_mock, create_pxe_config_mock): + http_url = 'http://192.1.2.3:1234' + self.config(http_url=http_url, group='deploy') + provider_mock = mock.MagicMock() + dhcp_factory_mock.return_value = provider_mock + self.node.instance_info = {'boot_iso': 'http://1.2.3.4:1234/boot.iso', + 'capabilities': {'boot_option': 'ramdisk'}} + image_info = {'kernel': ('', '/path/to/kernel'), + 'deploy_kernel': ('', '/path/to/kernel'), + 'ramdisk': ('', '/path/to/ramdisk'), + 'deploy_ramdisk': ('', '/path/to/ramdisk')} + get_image_info_mock.return_value = image_info + self.node.provision_state = states.DEPLOYING + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + print(task.node) + dhcp_opts = pxe_utils.dhcp_options_for_instance(task, + ipxe_enabled=True) + dhcp_opts += pxe_utils.dhcp_options_for_instance( + task, ipxe_enabled=True, ip_version=6) + pxe_config_path = pxe_utils.get_pxe_config_file_path( + task.node.uuid, ipxe_enabled=True) + task.driver.boot.prepare_instance(task) + self.assertTrue(get_image_info_mock.called) + self.assertTrue(cache_mock.called) + provider_mock.update_dhcp.assert_called_once_with(task, dhcp_opts) + create_pxe_config_mock.assert_called_once_with( + task, mock.ANY, CONF.pxe.ipxe_config_template, + ipxe_enabled=True) + switch_pxe_config_mock.assert_called_once_with( + pxe_config_path, None, boot_modes.LEGACY_BIOS, False, + ipxe_enabled=True, iscsi_boot=False, ramdisk_boot=True) + set_boot_device_mock.assert_called_once_with(task, + boot_devices.PXE, + persistent=True) + @mock.patch.object(manager_utils, 'node_set_boot_device', autospec=True) @mock.patch.object(pxe_utils, 'clean_up_pxe_config', autospec=True) def test_prepare_instance_localboot(self, clean_up_pxe_config_mock, diff --git a/releasenotes/notes/add-ipxe-boot-iso-support-6ae2f5cc2250be3e.yaml b/releasenotes/notes/add-ipxe-boot-iso-support-6ae2f5cc2250be3e.yaml new file mode 100644 index 0000000000..a9139eb445 --- /dev/null +++ b/releasenotes/notes/add-ipxe-boot-iso-support-6ae2f5cc2250be3e.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Adds functionality to the ``ipxe`` boot interface to support use of an + ``instance_info\boot_iso`` value with the ``ramdisk`` deployment interface. +other: + - | + Support for iPXE booting a ISO medium will only work if the ramdisk loaded + by the bootloader contains all artifacts required for the booting operating + system to load. This is a limitation of iPXE and x86 systems architecture, + as the memory allocated for the rest of the ISO disk image in memory is + freed by the booting kernel.