From 9a563f83061396e941a7e6017b4e0a0d4da190cb Mon Sep 17 00:00:00 2001 From: "Brian C. Lane" Date: Mon, 21 Jul 2025 14:18:37 -0700 Subject: [PATCH] org.osbuild.grub2.iso: Add support for optional fips menu On RHEL 9.7+ and on RHEL 10.1+ we need to be able to include a menu that boots the installer environment with fips=1 on the cmdline. This adds an optional menu entry controlled by the "fips" boolean. This also includes a new test for the menus with and without fips included. Related: RHEL-104075 --- stages/org.osbuild.grub2.iso | 27 +++- stages/org.osbuild.grub2.iso.meta.json | 3 + stages/test/test_grub2_iso.py | 172 +++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 stages/test/test_grub2_iso.py diff --git a/stages/org.osbuild.grub2.iso b/stages/org.osbuild.grub2.iso index 56930aee..3d0f4726 100755 --- a/stages/org.osbuild.grub2.iso +++ b/stages/org.osbuild.grub2.iso @@ -37,6 +37,7 @@ menuentry 'Test this media & install ${product} ${version}' --class fedora --cla linux ${kernelpath} ${root} rd.live.check quiet initrd ${initrdpath} } +$fipsentry submenu 'Troubleshooting -->' { menuentry 'Install ${product} ${version} in basic graphics mode' --class fedora --class gnu-linux --class gnu --class os { linux ${kernelpath} ${root} nomodeset quiet @@ -49,6 +50,14 @@ submenu 'Troubleshooting -->' { } """ +# Optional FIPS menu entry +FIPS_ENTRY_TEMPLATE = """ +menuentry 'Install ${product} ${version} in FIPS mode' --class fedora --class gnu-linux --class gnu --class os { + linux ${kernelpath} ${root} quiet fips=1 + initrd ${initrdpath} +} +""" + def main(root, options): name = options["product"]["name"] @@ -60,6 +69,7 @@ def main(root, options): kopts = options["kernel"].get("opts") cfg = options.get("config", {}) timeout = cfg.get("timeout", 60) + fips = options.get("fips", False) efidir = os.path.join(root, "EFI", "BOOT") os.makedirs(efidir) @@ -83,18 +93,25 @@ def main(root, options): shutil.copy2("/usr/share/grub/unicode.pf2", fontdir) print(f"kernel dir at {kdir}") - - tplt = string.Template(GRUB2_EFI_CFG_TEMPLATE) - data = tplt.safe_substitute({ + tplt_variables = { "version": version, "product": name, "kernelpath": os.path.join(kdir, "vmlinuz"), "initrdpath": os.path.join(kdir, "initrd.img"), "isolabel": isolabel, "root": " ".join(kopts), - "timeout": timeout - }) + "timeout": timeout, + "fipsentry": "" + } + # Insert optional fips menu entry + if fips: + fips_tmpl = string.Template(FIPS_ENTRY_TEMPLATE) + fipsentry = fips_tmpl.safe_substitute(tplt_variables) + tplt_variables["fipsentry"] = fipsentry + + tplt = string.Template(GRUB2_EFI_CFG_TEMPLATE) + data = tplt.safe_substitute(tplt_variables) config = os.path.join(efidir, "grub.cfg") with open(config, "w", encoding="utf8") as cfg: cfg.write(data) diff --git a/stages/org.osbuild.grub2.iso.meta.json b/stages/org.osbuild.grub2.iso.meta.json index d1714185..0dce1c84 100644 --- a/stages/org.osbuild.grub2.iso.meta.json +++ b/stages/org.osbuild.grub2.iso.meta.json @@ -56,6 +56,9 @@ "vendor": { "type": "string" }, + "fips": { + "type": "boolean" + }, "config": { "description": "Configuration options for grub itself", "type": "object", diff --git a/stages/test/test_grub2_iso.py b/stages/test/test_grub2_iso.py new file mode 100644 index 00000000..5bd195bc --- /dev/null +++ b/stages/test/test_grub2_iso.py @@ -0,0 +1,172 @@ +#!/usr/bin/python3 + +import os.path +from unittest.mock import call, patch + +import pytest + +STAGE_NAME = "org.osbuild.grub2.iso" + +CONFIG_PART_1 = """ +function load_video { + insmod efi_gop + insmod efi_uga + insmod video_bochs + insmod video_cirrus + insmod all_video +} + +load_video +set gfxpayload=keep +insmod gzio +insmod part_gpt +insmod ext2 + +set timeout=60 +### END /etc/grub.d/00_header ### + +search --no-floppy --set=root -l 'Fedora-42-Everything-x86_64' + +### BEGIN /etc/grub.d/10_linux ### +menuentry 'Install Fedora 42' --class fedora --class gnu-linux --class gnu --class os { + linux /images/pxeboot/vmlinuz inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64 quiet + initrd /images/pxeboot/initrd.img +} +menuentry 'Test this media & install Fedora 42' --class fedora --class gnu-linux --class gnu --class os { + linux /images/pxeboot/vmlinuz inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64 rd.live.check quiet + initrd /images/pxeboot/initrd.img +} +""" + +CONFIG_PART_2 = """ +submenu 'Troubleshooting -->' { + menuentry 'Install Fedora 42 in basic graphics mode' --class fedora --class gnu-linux --class gnu --class os { + linux /images/pxeboot/vmlinuz inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64 nomodeset quiet + initrd /images/pxeboot/initrd.img + } + menuentry 'Rescue a Fedora system' --class fedora --class gnu-linux --class gnu --class os { + linux /images/pxeboot/vmlinuz inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64 inst.rescue quiet + initrd /images/pxeboot/initrd.img + } +} +""" + +CONFIG_FIPS = """ +menuentry 'Install Fedora 42 in FIPS mode' --class fedora --class gnu-linux --class gnu --class os { + linux /images/pxeboot/vmlinuz inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64 quiet fips=1 + initrd /images/pxeboot/initrd.img +} +""" + + +@patch("shutil.copy2") +@pytest.mark.parametrize("test_data,expected_conf", [ + # default + ({}, CONFIG_PART_1 + CONFIG_PART_2), + # fips menu enable + ({"fips": True}, CONFIG_PART_1 + CONFIG_FIPS + CONFIG_PART_2) +]) +def test_grub2_iso(mocked_copy2, tmp_path, stage_module, test_data, expected_conf): + treedir = tmp_path / "tree" + treedir.mkdir(parents=True, exist_ok=True) + efidir = treedir / "EFI/BOOT" + confpath = efidir / "grub.cfg" + + # from fedora-ostree-bootiso-xz.json + options = { + "product": { + "name": "Fedora", + "version": "42" + }, + "kernel": { + "dir": "/images/pxeboot", + "opts": [ + "inst.stage2=hd:LABEL=Fedora-42-Everything-x86_64" + ] + }, + "isolabel": "Fedora-42-Everything-x86_64", + "architectures": [ + "X64" + ], + "vendor": "fedora" + } + options.update(test_data) + + stage_module.main(treedir, options) + + assert os.path.exists(confpath) + assert confpath.read_text() == expected_conf + assert mocked_copy2.call_args_list == [ + call("/boot/efi/EFI/fedora/shimx64.efi", os.fspath(efidir / "BOOTX64.EFI")), + call("/boot/efi/EFI/fedora/mmx64.efi", os.fspath(efidir / "mmx64.efi")), + call("/boot/efi/EFI/fedora/gcdx64.efi", os.fspath(efidir / "grubx64.efi")), + call("/usr/share/grub/unicode.pf2", os.fspath(efidir / "fonts")) + ] + + +@pytest.mark.parametrize("test_data,expected_err", [ + # bad + ( + {}, ["'isolabel' is a required property", "'kernel' is a required property", "'product' is a required property"] + ), + ( + { + "isolabel": "an-isolabel", + "product": { + "name": "a-name", + "version": "a-version", + }, + "kernel": {}, + }, ["'dir' is a required property"], + ), + ( + { + "isolabel": "an-isolabel", + "product": {}, + "kernel": { + "dir": "/path/to", + }, + }, ["'name' is a required property", "'version' is a required property"], + ), + # good + ( + { + "isolabel": "an-isolabel", + "product": { + "name": "a-name", + "version": "a-version", + }, + "kernel": { + "dir": "/path/to", + }, + }, "", + ), + # good + fips + ( + { + "isolabel": "an-isolabel", + "product": { + "name": "a-name", + "version": "a-version", + }, + "kernel": { + "dir": "/path/to", + }, + "fips": True, + }, "", + ), +]) +def test_schema_validation(stage_schema, test_data, expected_err): + test_input = { + "type": STAGE_NAME, + "options": {}, + } + test_input["options"].update(test_data) + res = stage_schema.validate(test_input) + + if expected_err == "": + assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}" + else: + assert res.valid is False + err_msgs = sorted([e.as_dict()["message"] for e in res.errors]) + assert err_msgs == expected_err