iPXE ISO Ramdisk booting

Adds an iPXE interface to boot via a virtual media ISO as if it
was virtual media.

Story: 2007644
Task: 39823
Change-Id: Ie7971692758f3a5421f0826fdaf3d2366f652236
This commit is contained in:
Julia Kreger 2020-05-19 18:27:03 -07:00
parent bd0033611d
commit 0cbb0397b1
9 changed files with 285 additions and 6 deletions

View File

@ -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
----------------

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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):

View File

@ -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

View File

@ -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,

View File

@ -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.