From d4dbc6e3aba25681ece13d172dbc1b2f5c2fad86 Mon Sep 17 00:00:00 2001 From: Ilya Etingof Date: Tue, 8 Oct 2019 10:34:11 +0200 Subject: [PATCH] Support burning configdrive into boot ISO Adds the ability to write configdrive contents on the boot images that ironic produces. Such boot image will be labeled as `config-2` to facilitate tools like `cloud-init` or `Glean` to find configuration source there. As of this commit, the new code is not used by anything in ironic yet. Change-Id: Ib5ed0ce09ce97ddb914fa4f830265dfc24a0bc1a Story: 2006691 Task: 36990 --- ironic/common/images.py | 152 ++++++++++++++---- ironic/drivers/modules/redfish/boot.py | 61 +++++-- ironic/tests/unit/common/test_images.py | 56 ++++--- .../unit/drivers/modules/redfish/test_boot.py | 3 + 4 files changed, 203 insertions(+), 69 deletions(-) diff --git a/ironic/common/images.py b/ironic/common/images.py index 4273d25e29..1bd1755fdb 100644 --- a/ironic/common/images.py +++ b/ironic/common/images.py @@ -19,6 +19,7 @@ Handling of VM disk images. """ +import contextlib import os import shutil @@ -154,8 +155,67 @@ def _generate_cfg(kernel_params, template, options): return utils.render_template(template, options) -def create_isolinux_image_for_bios(output_file, kernel, ramdisk, - kernel_params=None): +def _read_dir(root_dir, prefix_dir=None): + """Gather files under given directory. + + :param root_dir: a directory to traverse. + :returns: a dict mapping absolute paths to relative to the `root_dir`. + """ + files_info = {} + + if not prefix_dir: + prefix_dir = root_dir + + for entry in os.listdir(root_dir): + path = os.path.join(root_dir, entry) + if os.path.isdir(path): + files_info.update(_read_dir(path, prefix_dir)) + + else: + files_info[path] = path[len(prefix_dir) + 1:] + + return files_info + + +@contextlib.contextmanager +def _collect_files(image_path): + """Mount image and return a dictionary of paths found there. + + Mounts given image under a temporary directory, walk its contents + and produce a dictionary of absolute->relative paths found on the + image. + + :param image_path: ISO9660 or FAT-formatted image to mount. + :raises: ImageCreationFailed, if image inspection failed. + :returns: a dict mapping absolute paths to relative to the mount point. + """ + if not image_path: + yield {} + return + + with utils.tempdir() as mount_dir: + try: + utils.mount(image_path, mount_dir, '-o', 'loop') + + except processutils.ProcessExecutionError as e: + LOG.exception("Mounting filesystem image %(image)s " + "failed", {'image': image_path}) + raise exception.ImageCreationFailed(image_type='iso', error=e) + + try: + yield _read_dir(mount_dir) + + except EnvironmentError as e: + LOG.exception( + "Examining image %(images)s failed: ", {'image': image_path}) + _umount_without_raise(mount_dir) + raise exception.ImageCreationFailed(image_type='iso', error=e) + + _umount_without_raise(mount_dir) + + +def create_isolinux_image_for_bios( + output_file, kernel, ramdisk, kernel_params=None, configdrive=None): """Creates an isolinux image on the specified file. Copies the provided kernel, ramdisk to a directory, generates the isolinux @@ -169,6 +229,8 @@ def create_isolinux_image_for_bios(output_file, kernel, ramdisk, :param kernel_params: a list of strings(each element being a string like 'K=V' or 'K' or combination of them like 'K1=V1,K2,...') to be added as the kernel cmdline. + :param configdrive: ISO9660 or FAT-formatted OpenStack config drive + image. This image will be written onto the built ISO image. Optional. :raises: ImageCreationFailed, if image creation failed while copying files or while running command to generate iso. """ @@ -200,11 +262,15 @@ def create_isolinux_image_for_bios(output_file, kernel, ramdisk, if ldlinux_src: files_info[ldlinux_src] = LDLINUX_BIN - try: - _create_root_fs(tmpdir, files_info) - except (OSError, IOError) as e: - LOG.exception("Creating the filesystem root failed.") - raise exception.ImageCreationFailed(image_type='iso', error=e) + with _collect_files(configdrive) as cfgdrv_files: + files_info.update(cfgdrv_files) + + try: + _create_root_fs(tmpdir, files_info) + + except EnvironmentError as e: + LOG.exception("Creating the filesystem root failed.") + raise exception.ImageCreationFailed(image_type='iso', error=e) cfg = _generate_cfg(kernel_params, CONF.isolinux_config_template, options) @@ -213,7 +279,8 @@ def create_isolinux_image_for_bios(output_file, kernel, ramdisk, utils.write_to_file(isolinux_cfg, cfg) try: - utils.execute('mkisofs', '-r', '-V', "VMEDIA_BOOT_ISO", + utils.execute('mkisofs', '-r', '-V', + 'config-2' if configdrive else 'VMEDIA_BOOT_ISO', '-cache-inodes', '-J', '-l', '-no-emul-boot', '-boot-load-size', '4', '-boot-info-table', '-b', ISOLINUX_BIN, '-o', output_file, tmpdir) @@ -222,9 +289,9 @@ def create_isolinux_image_for_bios(output_file, kernel, ramdisk, raise exception.ImageCreationFailed(image_type='iso', error=e) -def create_esp_image_for_uefi(output_file, kernel, ramdisk, - deploy_iso=None, esp_image=None, - kernel_params=None): +def create_esp_image_for_uefi( + output_file, kernel, ramdisk, deploy_iso=None, esp_image=None, + kernel_params=None, configdrive=None): """Creates an ESP image on the specified file. Copies the provided kernel, ramdisk and EFI system partition image (ESP) to @@ -244,6 +311,8 @@ def create_esp_image_for_uefi(output_file, kernel, ramdisk, :param kernel_params: a list of strings(each element being a string like 'K=V' or 'K' or combination of them like 'K1=V1,K2,...') to be added as the kernel cmdline. + :param configdrive: ISO9660 or FAT-formatted OpenStack config drive + image. This image will be written onto the built ISO image. Optional. :raises: ImageCreationFailed, if image creation failed while copying files or while running command to generate iso. """ @@ -290,16 +359,20 @@ def create_esp_image_for_uefi(output_file, kernel, ramdisk, files_info.update(uefi_path_info) - try: - _create_root_fs(tmpdir, files_info) + with _collect_files(configdrive) as cfgdrv_files: + files_info.update(cfgdrv_files) - except (OSError, IOError) as e: - LOG.exception("Creating the filesystem root failed.") - raise exception.ImageCreationFailed(image_type='iso', error=e) + try: + _create_root_fs(tmpdir, files_info) - finally: - if deploy_iso: - _umount_without_raise(mountdir) + except EnvironmentError as e: + LOG.exception("Creating the filesystem root failed.") + raise exception.ImageCreationFailed( + image_type='iso', error=e) + + finally: + if deploy_iso: + _umount_without_raise(mountdir) # Generate and copy grub config file. grub_conf = _generate_cfg(kernel_params, @@ -308,8 +381,9 @@ def create_esp_image_for_uefi(output_file, kernel, ramdisk, # Create the boot_iso. try: - utils.execute('mkisofs', '-r', '-V', "VMEDIA_BOOT_ISO", '-l', - '-e', e_img_rel_path, '-no-emul-boot', + utils.execute('mkisofs', '-r', '-V', + 'config-2' if configdrive else 'VMEDIA_BOOT_ISO', + '-l', '-e', e_img_rel_path, '-no-emul-boot', '-o', output_file, tmpdir) except processutils.ProcessExecutionError as e: @@ -437,7 +511,8 @@ def get_temp_url_for_glance_image(context, image_uuid): def create_boot_iso(context, output_filename, kernel_href, ramdisk_href, deploy_iso_href=None, esp_image_href=None, - root_uuid=None, kernel_params=None, boot_mode=None): + root_uuid=None, kernel_params=None, boot_mode=None, + configdrive_href=None): """Creates a bootable ISO image for a node. Given the hrefs for kernel, ramdisk, root partition's UUID and @@ -455,12 +530,15 @@ def create_boot_iso(context, output_filename, kernel_href, ISO is desired. :param esp_image_href: URL or glance UUID of FAT12/16/32-formatted EFI system partition image containing the EFI boot loader (e.g. GRUB2) - for each hardware architecture to boot. This image will be embedded - into the ISO image. If not specified, the `deploy_iso_href` option + for each hardware architecture to boot. This image will be written + onto the ISO image. If not specified, the `deploy_iso_href` option is only required for building UEFI-bootable ISO. :param kernel_params: a string containing whitespace separated values kernel cmdline arguments of the form K=V or K (optional). :boot_mode: the boot mode in which the deploy is to happen. + :param configdrive_href: URL to ISO9660 or FAT-formatted OpenStack config + drive image. This image will be embedded into the built ISO image. + Optional. :raises: ImageCreationFailed, if creating boot ISO failed. """ with utils.tempdir() as tmpdir: @@ -470,6 +548,14 @@ def create_boot_iso(context, output_filename, kernel_href, fetch(context, kernel_href, kernel_path) fetch(context, ramdisk_href, ramdisk_path) + if configdrive_href: + configdrive_path = os.path.join( + tmpdir, configdrive_href.split('/')[-1]) + fetch(context, configdrive_href, configdrive_path) + + else: + configdrive_path = None + params = [] if root_uuid: params.append('root=UUID=%s' % root_uuid) @@ -493,17 +579,15 @@ def create_boot_iso(context, output_filename, kernel_href, elif CONF.esp_image: esp_image_path = CONF.esp_image - create_esp_image_for_uefi(output_filename, - kernel_path, - ramdisk_path, - deploy_iso=deploy_iso_path, - esp_image=esp_image_path, - kernel_params=params) + create_esp_image_for_uefi( + output_filename, kernel_path, ramdisk_path, + deploy_iso=deploy_iso_path, esp_image=esp_image_path, + kernel_params=params, configdrive=configdrive_path) + else: - create_isolinux_image_for_bios(output_filename, - kernel_path, - ramdisk_path, - params) + create_isolinux_image_for_bios( + output_filename, kernel_path, ramdisk_path, + kernel_params=params, configdrive=configdrive_path) def is_whole_disk_image(ctx, instance_info): diff --git a/ironic/drivers/modules/redfish/boot.py b/ironic/drivers/modules/redfish/boot.py index 107dd42beb..f842a04fdc 100644 --- a/ironic/drivers/modules/redfish/boot.py +++ b/ironic/drivers/modules/redfish/boot.py @@ -20,6 +20,7 @@ from urllib import parse as urlparse from ironic_lib import utils as ironic_utils from oslo_log import log +from oslo_serialization import base64 from oslo_utils import importutils from ironic.common import boot_devices @@ -411,7 +412,8 @@ class RedfishVirtualMediaBoot(base.BootInterface): @classmethod def _prepare_iso_image(cls, task, kernel_href, ramdisk_href, - bootloader_href=None, root_uuid=None, params=None): + bootloader_href=None, configdrive=None, + root_uuid=None, params=None): """Prepare an ISO to boot the node. Build bootable ISO out of `kernel_href` and `ramdisk_href` (and @@ -423,6 +425,9 @@ class RedfishVirtualMediaBoot(base.BootInterface): :param ramdisk_href: URL or Glance UUID of the ramdisk to use :param bootloader_href: URL or Glance UUID of the EFI bootloader image to use when creating UEFI bootbable ISO + :param configdrive: URL to or a compressed blob of a ISO9660 or + FAT-formatted OpenStack config drive image. This image will be + written onto the built ISO image. Optional. :param root_uuid: optional uuid of the root partition. :param params: a dictionary containing 'parameter name'->'value' mapping to be passed to kernel command line. @@ -467,24 +472,48 @@ class RedfishVirtualMediaBoot(base.BootInterface): 'params': kernel_params}) with tempfile.NamedTemporaryFile( - dir=CONF.tempdir, suffix='.iso') as fileobj: - boot_iso_tmp_file = fileobj.name - images.create_boot_iso( - task.context, boot_iso_tmp_file, - kernel_href, ramdisk_href, - esp_image_href=bootloader_href, - root_uuid=root_uuid, - kernel_params=kernel_params, - boot_mode=boot_mode) + dir=CONF.tempdir, suffix='.iso') as boot_fileobj: - iso_object_name = cls._get_iso_image_name(task.node) + with tempfile.NamedTemporaryFile( + dir=CONF.tempdir, suffix='.img') as cfgdrv_fileobj: - image_url = cls._publish_image(boot_iso_tmp_file, iso_object_name) + configdrive_href = configdrive - LOG.debug("Created ISO %(name)s in Swift for node %(node)s, exposed " - "as temporary URL %(url)s", {'node': task.node.uuid, - 'name': iso_object_name, - 'url': image_url}) + if configdrive: + parsed_url = urlparse.urlparse(configdrive) + if not parsed_url.scheme: + cfgdrv_blob = base64.decode_as_bytes(configdrive) + + with open(cfgdrv_fileobj.name, 'wb') as f: + f.write(cfgdrv_blob) + + configdrive_href = urlparse.urlunparse( + ('file', '', cfgdrv_fileobj.name, '', '', '')) + + LOG.info("Burning configdrive %(url)s to boot ISO image " + "for node %(node)s", {'url': configdrive_href, + 'node': task.node.uuid}) + + boot_iso_tmp_file = boot_fileobj.name + images.create_boot_iso( + task.context, boot_iso_tmp_file, + kernel_href, ramdisk_href, + esp_image_href=bootloader_href, + configdrive_href=configdrive_href, + root_uuid=root_uuid, + kernel_params=kernel_params, + boot_mode=boot_mode) + + iso_object_name = cls._get_iso_image_name(task.node) + + image_url = cls._publish_image( + boot_iso_tmp_file, iso_object_name) + + LOG.debug("Created ISO %(name)s in object store for node %(node)s, " + "exposed as temporary URL " + "%(url)s", {'node': task.node.uuid, + 'name': iso_object_name, + 'url': image_url}) return image_url diff --git a/ironic/tests/unit/common/test_images.py b/ironic/tests/unit/common/test_images.py index ddfd5b1501..9aa711f877 100644 --- a/ironic/tests/unit/common/test_images.py +++ b/ironic/tests/unit/common/test_images.py @@ -413,6 +413,21 @@ class FsImageTestCase(base.TestCase): options) self.assertEqual(expected_cfg, cfg) + @mock.patch.object(images, 'os', autospec=True) + def test__read_dir(self, mock_os): + mock_os.path.join = os.path.join + mock_os.path.isdir.side_effect = (False, True, False) + mock_os.listdir.side_effect = [['a', 'b'], ['c']] + + file_info = images._read_dir('/mnt') + + expected = { + '/mnt/a': 'a', + '/mnt/b/c': 'b/c' + } + + self.assertEqual(expected, file_info) + @mock.patch.object(os.path, 'relpath', autospec=True) @mock.patch.object(os, 'walk', autospec=True) @mock.patch.object(utils, 'mount', autospec=True) @@ -749,8 +764,8 @@ class FsImageTestCase(base.TestCase): params = ['root=UUID=root-uuid', 'kernel-params'] create_isolinux_mock.assert_called_once_with( 'output_file', 'tmpdir/kernel-uuid', 'tmpdir/ramdisk-uuid', - deploy_iso='tmpdir/deploy_iso-uuid', esp_image=None, - kernel_params=params) + deploy_iso='tmpdir/deploy_iso-uuid', + esp_image=None, kernel_params=params, configdrive=None) @mock.patch.object(images, 'create_esp_image_for_uefi', autospec=True) @mock.patch.object(images, 'fetch', autospec=True) @@ -778,7 +793,7 @@ class FsImageTestCase(base.TestCase): create_isolinux_mock.assert_called_once_with( 'output_file', 'tmpdir/kernel-uuid', 'tmpdir/ramdisk-uuid', deploy_iso=None, esp_image='tmpdir/efiboot-uuid', - kernel_params=params) + kernel_params=params, configdrive=None) @mock.patch.object(images, 'create_esp_image_for_uefi', autospec=True) @mock.patch.object(images, 'fetch', autospec=True) @@ -805,8 +820,8 @@ class FsImageTestCase(base.TestCase): params = ['root=UUID=root-uuid', 'kernel-params'] create_isolinux_mock.assert_called_once_with( 'output_file', 'tmpdir/kernel-href', 'tmpdir/ramdisk-href', - deploy_iso='tmpdir/deploy_iso-href', esp_image=None, - kernel_params=params) + deploy_iso='tmpdir/deploy_iso-href', + esp_image=None, kernel_params=params, configdrive=None) @mock.patch.object(images, 'create_esp_image_for_uefi', autospec=True) @mock.patch.object(images, 'fetch', autospec=True) @@ -834,7 +849,7 @@ class FsImageTestCase(base.TestCase): create_isolinux_mock.assert_called_once_with( 'output_file', 'tmpdir/kernel-href', 'tmpdir/ramdisk-href', deploy_iso=None, esp_image='tmpdir/efiboot-href', - kernel_params=params) + kernel_params=params, configdrive=None) @mock.patch.object(images, 'create_isolinux_image_for_bios', autospec=True) @mock.patch.object(images, 'fetch', autospec=True) @@ -847,25 +862,27 @@ class FsImageTestCase(base.TestCase): images.create_boot_iso('ctx', 'output_file', 'kernel-uuid', 'ramdisk-uuid', 'deploy_iso-uuid', - 'efiboot-uuid', 'root-uuid', 'kernel-params', - 'bios') + 'efiboot-uuid', 'root-uuid', + 'kernel-params', 'bios', 'configdrive') fetch_images_mock.assert_any_call( 'ctx', 'kernel-uuid', 'tmpdir/kernel-uuid') fetch_images_mock.assert_any_call( 'ctx', 'ramdisk-uuid', 'tmpdir/ramdisk-uuid') + fetch_images_mock.assert_any_call( + 'ctx', 'configdrive', 'tmpdir/configdrive') + # Note (NobodyCam): the original assert asserted that fetch_images # was not called with parameters, this did not # work, So I instead assert that there were only # Two calls to the mock validating the above # asserts. - self.assertEqual(2, fetch_images_mock.call_count) + self.assertEqual(3, fetch_images_mock.call_count) params = ['root=UUID=root-uuid', 'kernel-params'] - create_isolinux_mock.assert_called_once_with('output_file', - 'tmpdir/kernel-uuid', - 'tmpdir/ramdisk-uuid', - params) + create_isolinux_mock.assert_called_once_with( + 'output_file', 'tmpdir/kernel-uuid', 'tmpdir/ramdisk-uuid', + kernel_params=params, configdrive='tmpdir/configdrive') @mock.patch.object(images, 'create_isolinux_image_for_bios', autospec=True) @mock.patch.object(images, 'fetch', autospec=True) @@ -879,19 +896,20 @@ class FsImageTestCase(base.TestCase): images.create_boot_iso('ctx', 'output_file', 'kernel-uuid', 'ramdisk-uuid', 'deploy_iso-uuid', - 'efiboot-uuid', 'root-uuid', 'kernel-params', - None) + 'efiboot-uuid', 'root-uuid', + 'kernel-params', None, 'http://configdrive') fetch_images_mock.assert_any_call( 'ctx', 'kernel-uuid', 'tmpdir/kernel-uuid') fetch_images_mock.assert_any_call( 'ctx', 'ramdisk-uuid', 'tmpdir/ramdisk-uuid') + fetch_images_mock.assert_any_call( + 'ctx', 'http://configdrive', 'tmpdir/configdrive') params = ['root=UUID=root-uuid', 'kernel-params'] - create_isolinux_mock.assert_called_once_with('output_file', - 'tmpdir/kernel-uuid', - 'tmpdir/ramdisk-uuid', - params) + create_isolinux_mock.assert_called_once_with( + 'output_file', 'tmpdir/kernel-uuid', 'tmpdir/ramdisk-uuid', + configdrive='tmpdir/configdrive', kernel_params=params) @mock.patch.object(image_service, 'get_image_service', autospec=True) def test_get_glance_image_properties_no_such_prop(self, diff --git a/ironic/tests/unit/drivers/modules/redfish/test_boot.py b/ironic/tests/unit/drivers/modules/redfish/test_boot.py index aa072fa0f7..c1d86486a7 100644 --- a/ironic/tests/unit/drivers/modules/redfish/test_boot.py +++ b/ironic/tests/unit/drivers/modules/redfish/test_boot.py @@ -364,6 +364,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): mock_create_boot_iso.assert_called_once_with( mock.ANY, mock.ANY, 'http://kernel/img', 'http://ramdisk/img', boot_mode='uefi', esp_image_href='http://bootloader/img', + configdrive_href=mock.ANY, kernel_params='nofb nomodeset vga=normal', root_uuid='1be26c0b-03f2-4d2e-ae87-c02d7f33c123') @@ -393,6 +394,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): mock_create_boot_iso.assert_called_once_with( mock.ANY, mock.ANY, 'http://kernel/img', 'http://ramdisk/img', boot_mode=None, esp_image_href=None, + configdrive_href=mock.ANY, kernel_params='nofb nomodeset vga=normal', root_uuid='1be26c0b-03f2-4d2e-ae87-c02d7f33c123') @@ -416,6 +418,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): mock_create_boot_iso.assert_called_once_with( mock.ANY, mock.ANY, 'http://kernel/img', 'http://ramdisk/img', boot_mode=None, esp_image_href=None, + configdrive_href=mock.ANY, kernel_params=kernel_params, root_uuid='1be26c0b-03f2-4d2e-ae87-c02d7f33c123')