From fab833e60787321ea126554491f13ee56b95b455 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Thu, 26 Mar 2026 12:12:18 -0600 Subject: [PATCH 1/2] bls: Add BLS installer module and unit tests Add install_bls.py which generates BLS Type 1 boot entries in /boot/loader/entries/ and a loader.conf file. Each installed kernel gets a default entry and optionally a rescue entry, following the naming convention l{seq}-{version}.conf. Entries include machine-id (from /etc/machine-id) and architecture fields. Add 'bls' as a recognised bootloader in the config validator. Add unit tests covering loader.conf generation, individual entry building (default, rescue, separate /boot partition, machine-id, architecture), entry list construction, and the full install flow. Co-developed-by: Claude Opus 4.6 (1M context) --- curtin/commands/install_bls.py | 160 ++++++++++++++++ curtin/config.py | 2 +- tests/unittests/test_commands_install_bls.py | 185 +++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 curtin/commands/install_bls.py create mode 100644 tests/unittests/test_commands_install_bls.py diff --git a/curtin/commands/install_bls.py b/curtin/commands/install_bls.py new file mode 100644 index 00000000..607da6a4 --- /dev/null +++ b/curtin/commands/install_bls.py @@ -0,0 +1,160 @@ +# This file is part of curtin. See LICENSE file for copyright and license info. + +"""Install Boot Loader Specification (BLS) Type 1 boot entries + +This creates individual boot-entry files in /boot/loader/entries/ as +described in the UAPI Boot Loader Specification: +https://uapi-group.org/specifications/specs/boot_loader_specification/ +""" + +import os + +from curtin import paths +from curtin import util +from curtin.log import LOG + +# BLS architecture names that differ from the machine name +BLS_ARCH_MAP = { + 'x86_64': 'x86-64', + 'i686': 'x86', + 'i586': 'x86', + 'ppc64le': 'ppc64-le', +} + +LOADER_DIR = '/boot/loader' +ENTRIES_DIR = LOADER_DIR + '/entries' + + +def build_loader_conf(timeout=50): + """Build the content of the loader.conf file + + :param: timeout: Boot menu timeout in seconds + """ + return f"""\ +timeout {timeout} +""" + + +def get_bls_architecture(machine): + """Return the BLS architecture name for the given machine + + :param: machine: A string specifying the target machine architecture. + """ + return BLS_ARCH_MAP.get(machine, machine) + + +def get_machine_id(target): + """Read the machine-id from the target filesystem + + :param: target: Path to the chroot mountpoint + Return: machine-id string, or None if not available + """ + machine_id_path = paths.target_path(target, '/etc/machine-id') + if not os.path.exists(machine_id_path): + return None + return util.load_file(machine_id_path).strip() + + +def build_entry(fw_boot_dir, kernel_path, initrd_path, + version, root_spec, machine_id=None, + architecture=None, rescue=False): + """Build the content of a single BLS entry file + + :param: fw_boot_dir: Firmware's view of the /boot directory + :param: kernel_path: Kernel filename (e.g. vmlinuz-6.8.0-48-generic) + :param: initrd_path: Initrd filename + :param: version: Kernel version string + :param: root_spec: Root device to pass to kernel + :param: machine_id: Machine identifier from /etc/machine-id + :param: architecture: BLS architecture name (e.g. x86-64, arm64) + :param: rescue: If True, generate a rescue entry + """ + title = f'Linux {version}' + options = f'root={root_spec} ro' + if rescue: + title += ' (rescue target)' + options += ' single' + else: + options += ' quiet' + + lines = [ + f'title {title}', + f'version {version}', + ] + if machine_id: + lines.append(f'machine-id {machine_id}') + if architecture: + lines.append(f'architecture {architecture}') + lines += [ + f'linux {fw_boot_dir}/{kernel_path}', + f'initrd {fw_boot_dir}/{initrd_path}', + f'options {options}', + ] + + return '\n'.join(lines) + '\n' + + +def build_entries(bootcfg, target, fw_boot_dir, root_spec, machine): + """Build all BLS entry files + + :param: bootcfg: A boot-config dict + :param: target: Path to the chroot mountpoint + :param: fw_boot_dir: Firmware's view of the /boot directory + :param: root_spec: Root device to pass to kernel + :param: machine: A string specifying the target machine architecture. + Return: list of (filename, content) tuples + """ + machine_id = get_machine_id(target) + architecture = get_bls_architecture(machine) + entries = [] + for seq, (kernel_path, full_initrd_path, version) in enumerate( + paths.get_kernel_list(target)): + LOG.debug('P: Writing BLS config for %s...', kernel_path) + initrd_path = os.path.basename(full_initrd_path) + + if 'default' in bootcfg.alternatives: + fname = f'l{seq}-{version}.conf' + content = build_entry( + fw_boot_dir, kernel_path, initrd_path, + version, root_spec, machine_id, architecture) + entries.append((fname, content)) + + if 'rescue' in bootcfg.alternatives: + fname = f'l{seq}r-{version}.conf' + content = build_entry( + fw_boot_dir, kernel_path, initrd_path, + version, root_spec, machine_id, architecture, + rescue=True) + entries.append((fname, content)) + + return entries + + +def install_bls(bootcfg, target, fw_boot_dir, root_spec, machine): + """Install BLS Type 1 boot entries to the target chroot + + :param: bootcfg: A boot-config dict + :param: target: Path to the chroot mountpoint + :param: fw_boot_dir: Firmware's view of the /boot directory + :param: root_spec: Root device to pass to kernel + :param: machine: A string specifying the target machine architecture. + """ + LOG.debug( + "P: Writing BLS entries, fw_boot_dir '%s' root_spec '%s'...", + fw_boot_dir, root_spec) + + loader_path = paths.target_path(target, LOADER_DIR) + entries_path = paths.target_path(target, ENTRIES_DIR) + util.ensure_dir(entries_path) + + # Write loader.conf + loader_conf = os.path.join(loader_path, 'loader.conf') + with open(loader_conf, 'w', encoding='utf-8') as outf: + outf.write(build_loader_conf()) + + # Write individual entry files + for fname, content in build_entries( + bootcfg, target, fw_boot_dir, root_spec, machine): + entry_path = os.path.join(entries_path, fname) + with open(entry_path, 'w', encoding='utf-8') as outf: + outf.write(content) diff --git a/curtin/config.py b/curtin/config.py index c5182a57..70ded29f 100644 --- a/curtin/config.py +++ b/curtin/config.py @@ -144,7 +144,7 @@ def _check_bootloaders(inst, attr, vals): if len(vals) != len(set(vals)): raise ValueError(f'bootloaders list contains duplicates: {vals}') for val in vals: - if val not in ['grub', 'extlinux']: + if val not in ['grub', 'extlinux', 'bls']: raise ValueError(f'Unknown bootloader {val}: {vals}') diff --git a/tests/unittests/test_commands_install_bls.py b/tests/unittests/test_commands_install_bls.py new file mode 100644 index 00000000..012fb03a --- /dev/null +++ b/tests/unittests/test_commands_install_bls.py @@ -0,0 +1,185 @@ +# This file is part of curtin. See LICENSE file for copyright and license info. + +import os +from pathlib import Path +import tempfile + +from .helpers import CiTestCase + +from curtin import config +from curtin.commands import install_bls + + +USE_BLS = ['bls'] +ROOT_DEV = '/dev/sda1' +MACHINE = 'x86_64' + + +class TestInstallBls(CiTestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory(suffix='-curtin') + self.target = self.tmpdir.name + + versions = ['6.8.0-40', '5.15.0-127', '6.8.0-48'] + boot = os.path.join(self.target, 'boot') + Path(f'{boot}').mkdir() + for ver in versions: + Path(f'{boot}/config-{ver}-generic').touch() + Path(f'{boot}/initrd.img-{ver}-generic').touch() + Path(f'{boot}/vmlinuz-{ver}-generic').touch() + + # Create /etc/machine-id in target + etc = os.path.join(self.target, 'etc') + Path(etc).mkdir() + with open(os.path.join(etc, 'machine-id'), 'w') as f: + f.write('abc123\n') + + Path(f'{self.target}/empty-dir').mkdir() + self.maxDiff = None + + def tearDown(self): + self.tmpdir.cleanup() + + def test_loader_conf(self): + """loader.conf should contain a timeout""" + out = install_bls.build_loader_conf() + self.assertIn('timeout 50', out) + + def test_loader_conf_custom_timeout(self): + """loader.conf should accept a custom timeout""" + out = install_bls.build_loader_conf(timeout=10) + self.assertIn('timeout 10', out) + + def test_entry_default(self): + """A default entry should have quiet option""" + out = install_bls.build_entry( + '/boot', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV) + self.assertIn('title Linux 6.8.0-48-generic\n', out) + self.assertIn('version 6.8.0-48-generic\n', out) + self.assertIn('linux /boot/vmlinuz-6.8.0-48-generic\n', out) + self.assertIn( + 'initrd /boot/initrd.img-6.8.0-48-generic\n', out) + self.assertIn( + f'options root={ROOT_DEV} ro quiet\n', out) + self.assertNotIn('single', out) + + def test_entry_machine_id(self): + """An entry should include machine-id when provided""" + out = install_bls.build_entry( + '/boot', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV, machine_id='abc123') + self.assertIn('machine-id abc123\n', out) + + def test_entry_architecture(self): + """An entry should include architecture when provided""" + out = install_bls.build_entry( + '/boot', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV, architecture='x86-64') + self.assertIn('architecture x86-64\n', out) + + def test_entry_no_machine_id(self): + """An entry should omit machine-id when not provided""" + out = install_bls.build_entry( + '/boot', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV) + self.assertNotIn('machine-id', out) + + def test_entry_rescue(self): + """A rescue entry should have single and no quiet""" + out = install_bls.build_entry( + '/boot', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV, rescue=True) + self.assertIn('(rescue target)', out) + self.assertIn( + f'options root={ROOT_DEV} ro single\n', out) + self.assertNotIn('quiet', out) + + def test_entry_separate_boot(self): + """Separate /boot partition uses empty fw_boot_dir""" + out = install_bls.build_entry( + '', 'vmlinuz-6.8.0-48-generic', + 'initrd.img-6.8.0-48-generic', + '6.8.0-48-generic', ROOT_DEV) + self.assertIn('linux /vmlinuz-6.8.0-48-generic\n', out) + self.assertIn( + 'initrd /initrd.img-6.8.0-48-generic\n', out) + + def test_build_entries_empty(self): + """No kernels should produce no entries""" + entries = install_bls.build_entries( + config.BootCfg(USE_BLS), + f'{self.target}/empty-dir', '', ROOT_DEV, MACHINE) + self.assertEqual([], entries) + + def test_build_entries_normal(self): + """Normal config should produce default and rescue entries""" + entries = install_bls.build_entries( + config.BootCfg(USE_BLS), + self.target, '/boot', ROOT_DEV, MACHINE) + # 3 kernels * 2 alternatives = 6 entries + self.assertEqual(6, len(entries)) + fnames = [e[0] for e in entries] + self.assertIn('l0-6.8.0-48-generic.conf', fnames) + self.assertIn('l0r-6.8.0-48-generic.conf', fnames) + self.assertIn('l1-6.8.0-40-generic.conf', fnames) + self.assertIn('l2-5.15.0-127-generic.conf', fnames) + # Check machine-id and architecture are present + content = entries[0][1] + self.assertIn('machine-id abc123\n', content) + self.assertIn('architecture ', content) + + def test_build_entries_no_rescue(self): + """Config without rescue should only produce default entries""" + cfg = config.BootCfg(USE_BLS, alternatives=['default']) + entries = install_bls.build_entries( + cfg, self.target, '/boot', ROOT_DEV, MACHINE) + self.assertEqual(3, len(entries)) + fnames = [e[0] for e in entries] + self.assertNotIn('l0r-6.8.0-48-generic.conf', fnames) + + def test_build_entries_no_default(self): + """Config without default should only produce rescue entries""" + cfg = config.BootCfg(USE_BLS, alternatives=['rescue']) + entries = install_bls.build_entries( + cfg, self.target, '/boot', ROOT_DEV, MACHINE) + self.assertEqual(3, len(entries)) + fnames = [e[0] for e in entries] + self.assertIn('l0r-6.8.0-48-generic.conf', fnames) + self.assertNotIn('l0-6.8.0-48-generic.conf', fnames) + + def test_install(self): + """Install should create loader.conf and entry files""" + bootcfg = config.BootCfg(USE_BLS) + install_bls.install_bls( + bootcfg, self.target, '/boot', ROOT_DEV, MACHINE) + + loader_conf = os.path.join( + self.target, 'boot/loader/loader.conf') + self.assertTrue(os.path.exists(loader_conf)) + + entries_dir = os.path.join( + self.target, 'boot/loader/entries') + self.assertTrue(os.path.isdir(entries_dir)) + + entry_files = sorted(os.listdir(entries_dir)) + self.assertEqual(6, len(entry_files)) + self.assertIn('l0-6.8.0-48-generic.conf', entry_files) + + def test_install_separate_boot(self): + """Install with separate /boot should use empty fw_boot_dir""" + bootcfg = config.BootCfg(USE_BLS) + install_bls.install_bls( + bootcfg, self.target, '', ROOT_DEV, MACHINE) + + entry_path = os.path.join( + self.target, + 'boot/loader/entries/l0-6.8.0-48-generic.conf') + with open(entry_path) as f: + content = f.read() + self.assertIn('linux /vmlinuz-6.8.0-48-generic', content) From a2d6666a5a369c67f44f5b06bf675e0f0c0b6298 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Thu, 26 Mar 2026 12:12:04 -0600 Subject: [PATCH 2/2] bls: Add BLS bootloader support to curthooks The Boot Loader Specification (BLS) Type 1 defines a standard layout for boot entries in /boot/loader/entries/ that firmware (such as U-Boot) can discover without a bootloader-specific configuration generator. Extract the common boot-parameter logic (firmware boot directory, root device specification) from setup_extlinux() into a shared _get_boot_params() helper. Add setup_bls() which uses the helper then calls install_bls() to write the entry files. Wire it into setup_boot() with event reporting, matching the pattern used by extlinux. Co-developed-by: Claude Opus 4.6 (1M context) --- curtin/commands/curthooks.py | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/curtin/commands/curthooks.py b/curtin/commands/curthooks.py index 63dc9c85..89035034 100644 --- a/curtin/commands/curthooks.py +++ b/curtin/commands/curthooks.py @@ -33,6 +33,7 @@ from curtin.reporter import events from curtin.commands import apply_net, apt_config, block_meta from curtin.commands.install_grub import install_grub +from curtin.commands.install_bls import install_bls from curtin.commands.install_extlinux import install_extlinux from curtin.url_helper import get_maas_version @@ -867,13 +868,14 @@ def translate_old_grub_schema(cfg): cfg['boot'] = grub_cfg -def setup_extlinux( - cfg: dict, - target: str): - """Set up an extlinux.conf file +def _get_boot_params(cfg): + """Extract common boot parameters from the storage configuration - :param: cfg: A config dict containing config.BootCfg in cfg['boot']. - :param: target: A string specifying the path to the chroot mountpoint. + Determines the firmware boot directory and root device specification + from the storage configuration. + + :param: cfg: A config dict containing storage and boot configuration. + Return: tuple of (bootcfg, fw_boot_dir, spec) """ bootcfg = config.fromdict(config.BootCfg, cfg.get('boot', {})) @@ -893,9 +895,35 @@ def setup_extlinux( fdata = block_meta.mount_data(root, storage_cfg_dict) spec = block_meta.resolve_fdata_spec(fdata) + return bootcfg, fw_boot_dir, spec + + +def setup_extlinux( + cfg: dict, + target: str): + """Set up an extlinux.conf file + + :param: cfg: A config dict containing config.BootCfg in cfg['boot']. + :param: target: A string specifying the path to the chroot mountpoint. + """ + bootcfg, fw_boot_dir, spec = _get_boot_params(cfg) install_extlinux(bootcfg, target, fw_boot_dir, spec) +def setup_bls( + cfg: dict, + target: str, + machine: str): + """Set up Boot Loader Specification (BLS) Type 1 entry files + + :param: cfg: A config dict containing config.BootCfg in cfg['boot']. + :param: target: A string specifying the path to the chroot mountpoint. + :param: machine: A string specifying the target machine architecture. + """ + bootcfg, fw_boot_dir, spec = _get_boot_params(cfg) + install_bls(bootcfg, target, fw_boot_dir, spec, machine) + + def setup_boot( cfg: dict, target: str, @@ -929,6 +957,16 @@ def setup_boot( 'extlinux is not supported on s390x; use zipl') setup_extlinux(cfg, target) + if 'bls' in bootloaders: + with events.ReportEventStack( + name=stack_prefix + '/install-bls', + reporting_enabled=True, level="INFO", + description="installing BLS entries to target"): + if machine == 's390x': + raise ValueError( + 'BLS is not supported on s390x; use zipl') + setup_bls(cfg, target, machine) + if machine == 's390x': with events.ReportEventStack( name=stack_prefix + '/install-zipl',