← Back to team overview

curtin-dev team mailing list archive

[Merge] ~sjg1/curtin:boot-schema-update-23.1 into curtin:release/23.1

 

Simon Glass has proposed merging ~sjg1/curtin:boot-schema-update-23.1 into curtin:release/23.1.

Commit message:
Curtin currently assumes that grub is used as the bootloader.

Generalise the schema to support multiple bootloaders

TBD: Add extlinux support

DO NOT SQUASH


Requested reviews:
  Dan Bungert (dbungert)
  Michael Hudson-Doyle (mwhudson)

For more details, see:
https://code.launchpad.net/~sjg1/curtin/+git/curtin/+merge/480709

The idea here is to allow MAAS to set up RISC-V clients without needing grub. They can use extlinux instead, meaning that U-Boot is enough to handle the full boot.

I have made the schema changes but I need to figure out how to get the device and mount point for the root and boot devices.
-- 
Your team curtin developers is subscribed to branch curtin:release/23.1.
diff --git a/.gitignore b/.gitignore
index 8f343e7..1c6bd5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ __pycache__
 .tox
 .coverage
 curtin.egg-info/
+doc/_build
diff --git a/curtin/commands/curthooks.py b/curtin/commands/curthooks.py
index 48e45d5..3c749a6 100644
--- a/curtin/commands/curthooks.py
+++ b/curtin/commands/curthooks.py
@@ -29,6 +29,7 @@ from curtin.net import deps as ndeps
 from curtin.reporter import events
 from curtin.commands import apply_net, apt_config
 from curtin.commands.install_grub import install_grub
+from curtin.commands.install_extlinux import install_extlinux
 from curtin.url_helper import get_maas_version
 
 from . import populate_one_subcmd
@@ -424,7 +425,7 @@ def install_kernel(cfg, target):
                      " System may not boot.", package)
 
 
-def uefi_remove_old_loaders(grubcfg, target):
+def uefi_remove_old_loaders(bootcfg, target):
     """Removes the old UEFI loaders from efibootmgr."""
     efi_output = util.get_efibootmgr(target)
     LOG.debug('UEFI remove old olders efi output:\n%s', efi_output)
@@ -435,7 +436,7 @@ def uefi_remove_old_loaders(grubcfg, target):
         if re.match(r'^.*File\(\\EFI.*$', info['path'])
     }
     old_efi_entries.pop(current_uefi_boot, None)
-    remove_old_loaders = grubcfg.get('remove_old_uefi_loaders', True)
+    remove_old_loaders = bootcfg.get('remove_old_uefi_loaders', True)
     if old_efi_entries:
         if remove_old_loaders:
             with util.ChrootableTarget(target) as in_chroot:
@@ -517,7 +518,7 @@ def _reorder_new_entry(boot_order, efi_output, efi_orig=None, variant=None):
     return new_order
 
 
-def uefi_reorder_loaders(grubcfg, target, efi_orig=None, variant=None):
+def uefi_reorder_loaders(bootcfg, target, efi_orig=None, variant=None):
     """Reorders the UEFI BootOrder to place BootCurrent first.
 
     The specifically doesn't try to do to much. The order in which grub places
@@ -529,14 +530,14 @@ def uefi_reorder_loaders(grubcfg, target, efi_orig=None, variant=None):
     is installed after the the previous first entry (before we installed grub).
 
     """
-    if grubcfg.get('reorder_uefi', True):
+    if bootcfg.get('reorder_uefi', True):
         efi_output = util.get_efibootmgr(target=target)
         LOG.debug('UEFI efibootmgr output after install:\n%s', efi_output)
         currently_booted = efi_output.get('current', None)
         boot_order = efi_output.get('order', [])
         new_boot_order = None
         force_fallback_reorder = config.value_as_boolean(
-            grubcfg.get('reorder_uefi_force_fallback', False))
+            bootcfg.get('reorder_uefi_force_fallback', False))
         if currently_booted and force_fallback_reorder is False:
             if currently_booted in boot_order:
                 boot_order.remove(currently_booted)
@@ -571,12 +572,12 @@ def uefi_reorder_loaders(grubcfg, target, efi_orig=None, variant=None):
         LOG.debug("Currently booted UEFI loader might no longer boot.")
 
 
-def uefi_remove_duplicate_entries(grubcfg, target, to_remove=None):
-    if not grubcfg.get('remove_duplicate_entries', True):
+def uefi_remove_duplicate_entries(bootcfg, target, to_remove=None):
+    if not bootcfg.get('remove_duplicate_entries', True):
         LOG.debug("Skipped removing duplicate UEFI boot entries per config.")
         return
     if to_remove is None:
-        to_remove = uefi_find_duplicate_entries(grubcfg, target)
+        to_remove = uefi_find_duplicate_entries(bootcfg, target)
 
     # check so we don't run ChrootableTarget code unless we have things to do
     if to_remove:
@@ -588,7 +589,7 @@ def uefi_remove_duplicate_entries(grubcfg, target, to_remove=None):
                                 '--delete-bootnum'])
 
 
-def uefi_find_duplicate_entries(grubcfg, target, efi_output=None):
+def uefi_find_duplicate_entries(bootcfg, target, efi_output=None):
     seen = set()
     to_remove = []
     if efi_output is None:
@@ -725,11 +726,7 @@ def setup_grub(cfg, target, osfamily=DISTROS.debian, variant=None):
     from curtin.commands.block_meta import (extract_storage_ordered_dict,
                                             get_path_to_storage_volume)
 
-    grubcfg = cfg.get('grub', {})
-
-    # copy legacy top level name
-    if 'grub_install_devices' in cfg and 'install_devices' not in grubcfg:
-        grubcfg['install_devices'] = cfg['grub_install_devices']
+    bootcfg = cfg.get('boot', {})
 
     LOG.debug("setup grub on target %s", target)
     # if there is storage config, look for devices tagged with 'grub_device'
@@ -755,15 +752,15 @@ def setup_grub(cfg, target, osfamily=DISTROS.debian, variant=None):
                     get_path_to_storage_volume(item_id, storage_cfg_odict))
 
         if len(storage_grub_devices) > 0:
-            if len(grubcfg.get('install_devices', [])):
+            if len(bootcfg.get('install_devices', [])):
                 LOG.warn("Storage Config grub device config takes precedence "
                          "over grub 'install_devices' value, ignoring: %s",
-                         grubcfg['install_devices'])
-            grubcfg['install_devices'] = storage_grub_devices
+                         bootcfg['install_devices'])
+            bootcfg['install_devices'] = storage_grub_devices
 
-    LOG.debug("install_devices: %s", grubcfg.get('install_devices'))
-    if 'install_devices' in grubcfg:
-        instdevs = grubcfg.get('install_devices')
+    LOG.debug("install_devices: %s", bootcfg.get('install_devices'))
+    if 'install_devices' in bootcfg:
+        instdevs = bootcfg.get('install_devices')
         if isinstance(instdevs, str):
             instdevs = [instdevs]
         if instdevs is None:
@@ -815,16 +812,74 @@ def setup_grub(cfg, target, osfamily=DISTROS.debian, variant=None):
     else:
         instdevs = ["none"]
 
-    update_nvram = grubcfg.get('update_nvram', True)
+    update_nvram = bootcfg.get('update_nvram', True)
     if uefi_bootable and update_nvram:
         efi_orig_output = util.get_efibootmgr(target)
-        uefi_remove_old_loaders(grubcfg, target)
+        uefi_remove_old_loaders(bootcfg, target)
 
-    install_grub(instdevs, target, uefi=uefi_bootable, grubcfg=grubcfg)
+    install_grub(instdevs, target, uefi=uefi_bootable, bootcfg=bootcfg)
 
     if uefi_bootable and update_nvram:
-        uefi_reorder_loaders(grubcfg, target, efi_orig_output, variant)
-        uefi_remove_duplicate_entries(grubcfg, target)
+        uefi_reorder_loaders(bootcfg, target, efi_orig_output, variant)
+        uefi_remove_duplicate_entries(bootcfg, target)
+
+
+def translate_old_grub_schema(cfg):
+    """Translate the old top-level 'grub' configure to the new 'boot' one"""
+    grub_cfg = cfg.get('grub', {})
+
+    # Use the 'boot' key, if present
+    if 'boot' in cfg:
+        if grub_cfg:
+            raise ValueError("Configuration has both 'grub' and 'boot' keys")
+        return
+
+    # copy legacy top level name
+    if 'grub_install_devices' in cfg and 'install_devices' not in cfg:
+        grub_cfg['install_devices'] = cfg['grub_install_devices']
+
+    if 'grub' in cfg:
+        del cfg['grub']
+
+    # Create a bootloaders list with 'grub', which is implied in the old config
+    grub_cfg['bootloaders'] = ['grub']
+
+    cfg['boot'] = grub_cfg
+
+
+def setup_boot(
+        cfg: dict,
+        target: str,
+        machine: str,
+        stack_prefix: str,
+        osfamily: str,
+        variant: str,
+        ) -> None:
+    translate_old_grub_schema(cfg)
+
+    boot_cfg = cfg['boot']
+
+    # Manually check that the bootloaders are correct
+    config.check_bootcfg(boot_cfg)
+
+    bootloaders = boot_cfg['bootloaders']
+
+    # For now we have a hard-coded mechanism to determine whether grub should
+    # be installed or not. Even if the grub info is present in the config, we
+    # check the machine to decide whether or not to install it.
+    if 'grub' in bootloaders and uses_grub(machine):
+        with events.ReportEventStack(
+                name=stack_prefix + '/install-grub',
+                reporting_enabled=True, level="INFO",
+                description="installing grub to target devices"):
+            setup_grub(cfg, target, osfamily=osfamily, variant=variant)
+
+    if 'extlinux' in bootloaders:
+        with events.ReportEventStack(
+                name=stack_prefix + '/install-extlinux',
+                reporting_enabled=True, level="INFO",
+                description="installing extlinux to target devices"):
+            install_extlinux(cfg, target, osfamily=osfamily, variant=variant)
 
 
 def update_initramfs(target=None, all_kernels=False):
@@ -866,12 +921,7 @@ def update_initramfs(target=None, all_kernels=False):
     # update-initramfs's -u (update) method.  If the file does
     # not exist, then we need to run the -c (create) method.
     boot = paths.target_path(target, 'boot')
-    for kernel in sorted(glob.glob(boot + '/vmlinu*-*')):
-        kfile = os.path.basename(kernel)
-        # handle vmlinux or vmlinuz
-        kprefix = kfile.split('-')[0]
-        version = kfile.replace(kprefix + '-', '')
-        initrd = kernel.replace(kprefix, 'initrd.img')
+    for kfile, initrd, version in paths.get_kernel_list(target):
         # -u == update, -c == create
         mode = '-u' if os.path.exists(initrd) else '-c'
         cmd = ['update-initramfs', mode, '-k', version]
@@ -1721,6 +1771,18 @@ def redhat_update_initramfs(target, cfg):
         in_chroot.subp(dracut_cmd, capture=True)
 
 
+def uses_grub(machine):
+    # As a rule, ARMv7 systems don't use grub. This may change some
+    # day, but for now, assume no. They do require the initramfs
+    # to be updated, and this also triggers boot loader setup via
+    # flash-kernel.
+    if (machine.startswith('armv7') or
+            machine.startswith('s390x') or
+            machine.startswith('aarch64') and not util.is_uefi_bootable()):
+        return False
+    return True
+
+
 def builtin_curthooks(cfg, target, state):
     LOG.info('Running curtin builtin curthooks')
     stack_prefix = state.get('report_stack_prefix', '')
@@ -1901,21 +1963,8 @@ def builtin_curthooks(cfg, target, state):
             reporting_enabled=True, level="INFO",
             description="configuring target system bootloader"):
 
-        # As a rule, ARMv7 systems don't use grub. This may change some
-        # day, but for now, assume no. They do require the initramfs
-        # to be updated, and this also triggers boot loader setup via
-        # flash-kernel.
-        if (machine.startswith('armv7') or
-                machine.startswith('s390x') or
-                machine.startswith('aarch64') and not util.is_uefi_bootable()):
-            return
-
-        with events.ReportEventStack(
-                name=stack_prefix + '/install-grub',
-                reporting_enabled=True, level="INFO",
-                description="installing grub to target devices"):
-            setup_grub(cfg, target, osfamily=osfamily,
-                       variant=distro_info.variant)
+        setup_boot(cfg, target, machine, stack_prefix, osfamily=osfamily,
+                   variant=distro_info.variant)
 
 
 def curthooks(args):
diff --git a/curtin/commands/install_extlinux.py b/curtin/commands/install_extlinux.py
new file mode 100644
index 0000000..5048b06
--- /dev/null
+++ b/curtin/commands/install_extlinux.py
@@ -0,0 +1,92 @@
+# This file is part of curtin. See LICENSE file for copyright and license info.
+
+"""This loosely follows the u-boot-update script in the u-boot-menu package"""
+
+import io
+import os
+
+from curtin import paths
+from curtin.log import LOG
+
+EXTLINUX_DIR = '/boot/extlinux'
+
+
+def build_content(target: str, bootcfg: dict):
+    """Build the content of the extlinux.conf file
+
+    For now this only supports x86, since it does not handle the 'fdt' option.
+    Rather than add that, the plan is to use a FIT (Flat Image Tree) which can
+    handle FDT selection automatically. This should avoid the complexity
+    associated with fdt and fdtdir options.
+
+    We assume that the boot/ directory is in the root partition, rather than
+    being in a separate partition. TBD.
+    """
+    def get_entry(label, params, menu_label_append=''):
+        return '''\
+label {label}
+\tmenu label {menu_label} {version}{menu_label_append}
+\tlinux /{kernel_path}
+\tinitrd /{initrd_path}
+\tappend root={root} {params}'''.format(
+        label=label,
+        menu_label=menu_label,
+        version=version,
+        menu_label_append=menu_label_append,
+        kernel_path=kernel_path,
+        initrd_path=initrd_path,
+        root=root,
+        params=params)
+
+    buf = io.StringIO()
+    params = 'ro quiet'
+    alternatives = ['default', 'recovery']
+    menu_label = 'Ubuntu 22.04.5 LTS'
+    root = '/dev/mapper/vgubuntu-root'  # TODO
+
+    # For the recovery option, remove 'quiet' and add 'single'
+    without_quiet = filter(lambda word: word != 'quiet', params.split())
+    rec_params = ' '.join(list(without_quiet) + ['single'])
+
+    print('''\
+## %s/extlinux.conf
+##
+## IMPORTANT WARNING
+##
+## The configuration of this file is generated automatically.
+## Do not edit this file manually, use: u-boot-update
+
+default l0
+menu title U-Boot menu
+prompt 0
+timeout 50''' % EXTLINUX_DIR, file=buf)
+    for seq, (kernel_path, full_initrd_path, version) in enumerate(
+            paths.get_kernel_list(target)):
+        LOG.debug('P: Writing config for %s...', kernel_path)
+        initrd_path = os.path.basename(full_initrd_path)
+        print(file=buf)
+        print(file=buf)
+        print(get_entry('l%d' % seq, params), file=buf)
+
+        if 'recovery' in alternatives:
+            print(file=buf)
+            print(get_entry('l%dr' % seq, rec_params, ' (rescue target)'),
+                  file=buf)
+
+    return buf.getvalue()
+
+
+def install_extlinux(
+        target: str,
+        bootcfg: dict,
+        ):
+    """Install extlinux to the target chroot.
+
+    :param: target: A string specifying the path to the chroot mountpoint.
+    :param: bootcfg: An config dict with grub config options.
+    """
+    content = build_content(target, bootcfg)
+    extlinux_path = paths.target_path(target, '/boot/extlinux')
+    os.makedirs(extlinux_path, exist_ok=True)
+    with open(extlinux_path + '/extlinux.conf', 'w', encoding='utf-8') as outf:
+        outf.write(content)
diff --git a/curtin/commands/install_grub.py b/curtin/commands/install_grub.py
index edc6d33..5c514ee 100644
--- a/curtin/commands/install_grub.py
+++ b/curtin/commands/install_grub.py
@@ -2,7 +2,6 @@ import os
 import re
 import platform
 import shutil
-import sys
 
 from curtin import block
 from curtin import config
@@ -10,8 +9,6 @@ from curtin import distro
 from curtin import util
 from curtin.log import LOG
 from curtin.paths import target_path
-from curtin.reporter import events
-from . import populate_one_subcmd
 
 CMD_ARGUMENTS = (
     ((('-t', '--target'),
@@ -206,14 +203,14 @@ def replace_grub_cmdline_linux_default(target, new_args):
     LOG.debug('updated %s to set: %s', target_grubconf, newcontent)
 
 
-def write_grub_config(target, grubcfg, grub_conf, new_params):
+def write_grub_config(target, bootcfg, grub_conf, new_params):
     replace_default = config.value_as_boolean(
-        grubcfg.get('replace_linux_default', True))
+        bootcfg.get('replace_linux_default', True))
     if replace_default:
         replace_grub_cmdline_linux_default(target, new_params)
 
     probe_os = config.value_as_boolean(
-        grubcfg.get('probe_additional_os', False))
+        bootcfg.get('probe_additional_os', False))
     if not probe_os:
         probe_content = [
             ('# Curtin disable grub os prober that might find other '
@@ -224,7 +221,7 @@ def write_grub_config(target, grubcfg, grub_conf, new_params):
                         "\n".join(probe_content), omode='a+')
 
     # if terminal is present in config, but unset, then don't
-    grub_terminal = grubcfg.get('terminal', 'console')
+    grub_terminal = bootcfg.get('terminal', 'console')
     if not isinstance(grub_terminal, str):
         raise ValueError("Unexpected value %s for 'terminal'. "
                          "Value must be a string" % grub_terminal)
@@ -397,14 +394,14 @@ def check_target_arch_machine(target, arch=None, machine=None, uefi=None):
         raise RuntimeError(errmsg)
 
 
-def install_grub(devices, target, uefi=None, grubcfg=None):
+def install_grub(devices, target, uefi=None, bootcfg=None):
     """Install grub to devices inside target chroot.
 
     :param: devices: List of block device paths to install grub upon.
     :param: target: A string specifying the path to the chroot mountpoint.
     :param: uefi: A boolean set to True if system is UEFI bootable otherwise
                   False.
-    :param: grubcfg: An config dict with grub config options.
+    :param: bootcfg: An config dict with grub config options.
     """
 
     if not devices:
@@ -414,8 +411,8 @@ def install_grub(devices, target, uefi=None, grubcfg=None):
         raise ValueError("Invalid parameter 'target': %s" % target)
 
     LOG.debug("installing grub to target=%s devices=%s [replace_defaults=%s]",
-              target, devices, grubcfg.get('replace_default'))
-    update_nvram = config.value_as_boolean(grubcfg.get('update_nvram', True))
+              target, devices, bootcfg.get('replace_default'))
+    update_nvram = config.value_as_boolean(bootcfg.get('update_nvram', True))
     distroinfo = distro.get_distroinfo(target=target)
     target_arch = distro.get_architecture(target=target)
     rhel_ver = (distro.rpm_get_dist_id(target)
@@ -428,7 +425,7 @@ def install_grub(devices, target, uefi=None, grubcfg=None):
     grub_conf = get_grub_config_file(target, distroinfo.family)
     new_params = get_carryover_params(distroinfo)
     prepare_grub_dir(target, grub_conf)
-    write_grub_config(target, grubcfg, grub_conf, new_params)
+    write_grub_config(target, bootcfg, grub_conf, new_params)
     grub_cmd = get_grub_install_command(uefi, distroinfo, target)
     if uefi:
         install_cmds, post_cmds = gen_uefi_install_commands(
@@ -447,31 +444,4 @@ def install_grub(devices, target, uefi=None, grubcfg=None):
             in_chroot.subp(cmd, env=env, capture=True)
 
 
-def install_grub_main(args):
-    state = util.load_command_environment()
-
-    if args.target is not None:
-        target = args.target
-    else:
-        target = state['target']
-
-    if target is None:
-        sys.stderr.write("Unable to find target.  "
-                         "Use --target or set TARGET_MOUNT_POINT\n")
-        sys.exit(2)
-
-    cfg = config.load_command_config(args, state)
-    stack_prefix = state.get('report_stack_prefix', '')
-    uefi = util.is_uefi_bootable()
-    grubcfg = cfg.get('grub')
-    with events.ReportEventStack(
-            name=stack_prefix, reporting_enabled=True, level="INFO",
-            description="Installing grub to target devices"):
-        install_grub(args.devices, target, uefi=uefi, grubcfg=grubcfg)
-    sys.exit(0)
-
-
-def POPULATE_SUBCMD(parser):
-    populate_one_subcmd(parser, CMD_ARGUMENTS, install_grub_main)
-
 # vi: ts=4 expandtab syntax=python
diff --git a/curtin/config.py b/curtin/config.py
index 2106b23..3e8cb40 100644
--- a/curtin/config.py
+++ b/curtin/config.py
@@ -126,4 +126,19 @@ def value_as_boolean(value):
     false_values = (False, None, 0, '0', 'False', 'false', 'None', 'none', '')
     return value not in false_values
 
+
+def check_bootcfg(bootcfg):
+    vals = bootcfg.get('bootloaders')
+    if vals is None:
+        raise ValueError("missing required key: 'bootloaders'")
+    if not isinstance(vals, list):
+        raise ValueError('bootloaders must be a list: %s' % vals)
+    if not vals:
+        raise ValueError('Empty bootloaders list: %s' % vals)
+    if len(vals) != len(set(vals)):
+        raise ValueError('bootloaders list contains duplicates: %s' % vals)
+    for val in vals:
+        if val not in ['grub', 'extlinux']:
+            raise ValueError('Unknown bootloader %s: %s' % (val, vals))
+
 # vi: ts=4 expandtab syntax=python
diff --git a/curtin/paths.py b/curtin/paths.py
index 064b060..dee4fa3 100644
--- a/curtin/paths.py
+++ b/curtin/paths.py
@@ -1,5 +1,8 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
+import glob
 import os
+from packaging import version
+import re
 
 try:
     string_types = (basestring,)
@@ -31,4 +34,38 @@ def target_path(target, path=None):
 
     return os.path.join(target, path)
 
+
+def kernel_parse(fname):
+    """Function to extract version to use for sorting"""
+    m = re.search(r".*/vmlinu.-([\d.-]+)-.*", fname)
+    if not m:
+        raise ValueError("Cannot extract version from '%s'" % fname)
+    return version.parse(m.group(1))
+
+
+def get_kernel_list(target, full_initrd_path=True):
+    """yields [kernel filename, initrd path, version] for each kernel in target
+
+    For example:
+       ('vmlinuz-6.8.0-48-generic', '/boot/initrd.img-6.8.0-48-generic',
+        '6.8.0-48-generic')
+
+    If full_initrd_path is False, then only the basename of initrd is returned
+    """
+    root_path = target_path(target)
+    boot = target_path(root_path, 'boot')
+
+    for kernel in sorted(glob.glob(boot + '/vmlinu*-*'), key=kernel_parse,
+                         reverse=True):
+        kfile = os.path.basename(kernel)
+
+        # handle vmlinux or vmlinuz
+        kprefix = kfile.split('-')[0]
+        vers = kfile.replace(kprefix + '-', '')
+        initrd = kernel.replace(kprefix, 'initrd.img')
+        if not full_initrd_path:
+            initrd = os.path.basename(initrd)
+        yield kfile, initrd, vers
+
+
 # vi: ts=4 expandtab syntax=python
diff --git a/debian/control b/debian/control
index 9b81b73..3f0ef00 100644
--- a/debian/control
+++ b/debian/control
@@ -12,6 +12,7 @@ Build-Depends: debhelper (>= 7),
                python3-mock,
                python3-nose,
                python3-oauthlib,
+               python3-packaging,
                python3-parameterized,
                python3-setuptools,
                python3-yaml
diff --git a/doc/topics/config.rst b/doc/topics/config.rst
index 35ba863..71110bf 100644
--- a/doc/topics/config.rst
+++ b/doc/topics/config.rst
@@ -10,10 +10,14 @@ Configuration options
 ---------------------
 Curtin's top level config keys are as follows:
 
+.. contents::
+    :depth: 1
+    :local:
 
 - apt_mirrors (``apt_mirrors``)
 - apt_proxy (``apt_proxy``)
 - block-meta (``block``)
+- boot (``boot``)
 - curthooks (``curthooks``)
 - debconf_selections (``debconf_selections``)
 - disable_overlayroot (``disable_overlayroot``)
@@ -112,81 +116,65 @@ Specify the filesystem label on the boot partition.
           label: my-boot-partition
 
 
-curthooks
-~~~~~~~~~
-Configure how Curtin determines what :ref:`curthooks` to run during the installation
-process.
-
-**mode**: *<['auto', 'builtin', 'target']>*
-
-The default mode is ``auto``.
-
-In ``auto`` mode, curtin will execute curthooks within the image if present.
-For images without curthooks inside, curtin will execute its built-in hooks.
-
-Currently the built-in curthooks support the following OS families:
-
-- Ubuntu
-- Centos
-
-When specifying ``builtin``, curtin will only run the curthooks present in
-Curtin ignoring any curthooks that may be present in the target operating
-system.
-
-When specifying ``target``, curtin will attempt run the curthooks in the target
-operating system.  If the target does NOT contain any curthooks, then the
-built-in curthooks will be run instead.
-
-Any errors during execution of curthooks (built-in or target) will fail the
-installation.
-
-**Example**::
+boot
+~~~~
 
-  # ignore any target curthooks
-  curthooks:
-    mode: builtin
+Configures which bootloader(s) Curtin installs and some associated options.
+This is a list, which can contain up to two options: `grub` and `extlinux`.
 
-  # Only run target curthooks, fall back to built-in
-  curthooks:
-    mode: target
+Two bootloaders are available:
 
+- `GRUB <https://www.gnu.org/software/grub/>`_ (GRand Unified Bootloader)
+  installs itself on one or more block devices and takes care of booting.
+  Typically grub is built as an EFI application. Curtin controls aspects of
+  grub's configuration-file (/boot/grub/grub.cfg) which tells grub which OS
+  options to present to the user.
 
-debconf_selections
-~~~~~~~~~~~~~~~~~~
-Curtin will update the target with debconf set-selection values.  Users will
-need to be familiar with the package debconf options.  Users can probe a
-packages' debconf settings by using ``debconf-get-selections``.
+- `extlinux <https://wiki.syslinux.org/wiki/index.php?title=EXTLINUX>`_
+  is really just a file format, similar to a grub configuration-file but much
+  less flexible. It specifies which OS options to present to the user.
 
-**selection_name**: *<debconf-set-selections input>*
+One or both can be installed.
 
-``debconf-set-selections`` is in the form::
+The following properties are used for both bootloaders:
 
-  <packagename> <packagename/option-name> <type> <value>
+**bootloaders**: *<list of bootloaders>*
 
-**Example**::
+Selects the bootloaders to use. Valid options are "grub" and "extlinux".
 
-  debconf_selections:
-    set1: |
-      cloud-init cloud-init/datasources multiselect MAAS
-      lxd lxd/bridge-name string lxdbr0
-    set2: lxd lxd/setup-bridge boolean true
+**replace_linux_default**: *<boolean: default True>*
 
+Controls whether grub-install will update the Linux Default target
+value during installation.
 
+**terminal**: *<['unmodified', 'console', ...]>*
 
-disable_overlayroot
-~~~~~~~~~~~~~~~~~~~
-Curtin disables overlayroot in the target by default.
+For grub, this configures the target-system grub-option GRUB_TERMINAL
+``terminal`` value which is written to
+/etc/default/grub.d/50-curtin-settings.cfg.  Curtin does not attempt to validate
+this string, grub2 has many values that it accepts and the list is platform
+dependent.
 
-**disable_overlayroot**: *<boolean: default True>*
+For extlinux, this puts the console string in an APPEND line for each OS.
 
-**Example**::
+If ``terminal`` is not provided, Curtin will set the value to 'console'.  If the
+``terminal`` value is 'unmodified' then Curtin will not set any value at all and
+will use Grub defaults.
 
-  disable_overlayroot: False
+extlinux
+""""""""
 
+Curtin can add an ``extlinux.conf`` file to a filesystem. This contains a list
+of possible kernels, etc. similar to grub. This is somewhat more flexible on
+ARM/RISC-V since it can use `FIT <https://fitspec.osfw.foundation/>`_ and deal
+with devicetree, verified boot, etc. automatically. It also avoids specifying
+which bootloader must be used, since extlinux is supported by U-Boot, for
+example.
 
 grub
-~~~~
-Curtin configures grub as the target machine's boot loader.  Users
+""""
+
+Curtin can configure grub as the target machine's grub boot loader.  Users
 can control a few options to tailor how the system will boot after
 installation.
 
@@ -194,11 +182,6 @@ installation.
 
 Specify a list of devices onto which grub will attempt to install.
 
-**replace_linux_default**: *<boolean: default True>*
-
-Controls whether grub-install will update the Linux Default target
-value during installation.
-
 **update_nvram**: *<boolean: default True>*
 
 Certain platforms, like ``uefi`` and ``prep`` systems utilize
@@ -217,16 +200,6 @@ When False, curtin writes "GRUB_DISABLE_OS_PROBER=true" to target system in
 /etc/default/grub.d/50-curtin-settings.cfg.  If True, curtin won't modify the
 grub configuration value in the target system.
 
-**terminal**: *<['unmodified', 'console', ...]>*
-
-Configure target system grub option GRUB_TERMINAL ``terminal`` value
-which is written to /etc/default/grub.d/50-curtin-settings.cfg.  Curtin
-does not attempt to validate this string, grub2 has many values that
-it accepts and the list is platform dependent.  If ``terminal`` is
-not provided, Curtin will set the value to 'console'.  If the ``terminal``
-value is 'unmodified' then Curtin will not set any value at all and will
-use Grub defaults.
-
 **reorder_uefi**: *<boolean: default True>*
 
 Curtin is typically used with MAAS where the systems are configured to boot
@@ -266,7 +239,9 @@ This setting is ignored if *update_nvram* is False.
 
 **Example**::
 
-  grub:
+  boot:
+     bootloaders:
+        - grub
      install_devices:
        - /dev/sda1
      replace_linux_default: False
@@ -276,38 +251,138 @@ This setting is ignored if *update_nvram* is False.
 
 **Default terminal value, GRUB_TERMINAL=console**::
 
-  grub:
+  boot:
+     bootloaders:
+        - grub
      install_devices:
        - /dev/sda1
 
 **Don't set GRUB_TERMINAL in target**::
 
-  grub:
+  boot:
+     bootloaders:
+        - grub
      install_devices:
        - /dev/sda1
      terminal: unmodified
 
 **Allow grub to probe for additional OSes**::
 
-  grub:
-    install_devices:
-      - /dev/sda1
+  boot:
+     bootloaders:
+        - grub
+     install_devices:
+        - /dev/sda1
      probe_additional_os: True
 
 **Avoid writting any settings to etc/default/grub.d/50-curtin-settings.cfg**::
 
-  grub:
-    install_devices:
-      - /dev/sda1
+  boot:
+     bootloaders:
+        - grub
+     install_devices:
+        - /dev/sda1
      probe_additional_os: True
      terminal: unmodified
 
 **Enable Fallback UEFI Reordering**::
 
-  grub:
+  boot:
+     bootloaders:
+        - grub
      reorder_uefi: true
      reorder_uefi_force_fallback: true
 
+extlinux
+""""""""
+
+There are no options specific to extlinux.
+
+**Example**::
+
+  boot:
+     bootloaders:
+        - grub
+        - extlinux
+     install_devices:
+        - /dev/sda1
+
+curthooks
+~~~~~~~~~
+Configure how Curtin determines what :ref:`curthooks` to run during the installation
+process.
+
+**mode**: *<['auto', 'builtin', 'target']>*
+
+The default mode is ``auto``.
+
+In ``auto`` mode, curtin will execute curthooks within the image if present.
+For images without curthooks inside, curtin will execute its built-in hooks.
+
+Currently the built-in curthooks support the following OS families:
+
+- Ubuntu
+- Centos
+
+When specifying ``builtin``, curtin will only run the curthooks present in
+Curtin ignoring any curthooks that may be present in the target operating
+system.
+
+When specifying ``target``, curtin will attempt run the curthooks in the target
+operating system.  If the target does NOT contain any curthooks, then the
+built-in curthooks will be run instead.
+
+Any errors during execution of curthooks (built-in or target) will fail the
+installation.
+
+**Example**::
+
+  # ignore any target curthooks
+  curthooks:
+    mode: builtin
+
+  # Only run target curthooks, fall back to built-in
+  curthooks:
+    mode: target
+
+
+debconf_selections
+~~~~~~~~~~~~~~~~~~
+Curtin will update the target with debconf set-selection values.  Users will
+need to be familiar with the package debconf options.  Users can probe a
+packages' debconf settings by using ``debconf-get-selections``.
+
+**selection_name**: *<debconf-set-selections input>*
+
+``debconf-set-selections`` is in the form::
+
+  <packagename> <packagename/option-name> <type> <value>
+
+**Example**::
+
+  debconf_selections:
+    set1: |
+      cloud-init cloud-init/datasources multiselect MAAS
+      lxd lxd/bridge-name string lxdbr0
+    set2: lxd lxd/setup-bridge boolean true
+
+
+
+disable_overlayroot
+~~~~~~~~~~~~~~~~~~~
+Curtin disables overlayroot in the target by default.
+
+**disable_overlayroot**: *<boolean: default True>*
+
+**Example**::
+
+  disable_overlayroot: False
+
+grub
+~~~~
+
+This is an alias for **boot** with *bootloader* set to "grub". It is provided
+to maintain backwards compatibility
 
 http_proxy
 ~~~~~~~~~~
diff --git a/examples/tests/no-grub-file.yaml b/examples/tests/no-grub-file.yaml
index d5ba698..240dbb9 100644
--- a/examples/tests/no-grub-file.yaml
+++ b/examples/tests/no-grub-file.yaml
@@ -4,6 +4,7 @@ placeholder_simple_install: unused
 
 # configure curtin so it does not emit a grub config file
 # in etc/default/grub/grub.d
-grub:
+boot:
+   bootloaders: ['grub']
    probe_additional_os: true
    terminal: unmodified
diff --git a/tests/unittests/test_commands_install_extlinux.py b/tests/unittests/test_commands_install_extlinux.py
new file mode 100644
index 0000000..788a301
--- /dev/null
+++ b/tests/unittests/test_commands_install_extlinux.py
@@ -0,0 +1,129 @@
+# 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 import paths
+from curtin.commands import install_extlinux
+
+USE_EXTLINUX = {'bootloaders': ['extlinux']}
+
+EXPECT_HDR = '''\
+## /boot/extlinux/extlinux.conf
+##
+## IMPORTANT WARNING
+##
+## The configuration of this file is generated automatically.
+## Do not edit this file manually, use: u-boot-update
+
+default l0
+menu title U-Boot menu
+prompt 0
+timeout 50
+'''
+
+EXPECT_BODY = '''
+
+label l0
+\tmenu label Ubuntu 22.04.5 LTS 6.8.0-48-generic
+\tlinux /vmlinuz-6.8.0-48-generic
+\tinitrd /initrd.img-6.8.0-48-generic
+\tappend root=/dev/mapper/vgubuntu-root ro quiet
+
+label l0r
+\tmenu label Ubuntu 22.04.5 LTS 6.8.0-48-generic (rescue target)
+\tlinux /vmlinuz-6.8.0-48-generic
+\tinitrd /initrd.img-6.8.0-48-generic
+\tappend root=/dev/mapper/vgubuntu-root ro single
+
+
+label l1
+\tmenu label Ubuntu 22.04.5 LTS 6.8.0-40-generic
+\tlinux /vmlinuz-6.8.0-40-generic
+\tinitrd /initrd.img-6.8.0-40-generic
+\tappend root=/dev/mapper/vgubuntu-root ro quiet
+
+label l1r
+\tmenu label Ubuntu 22.04.5 LTS 6.8.0-40-generic (rescue target)
+\tlinux /vmlinuz-6.8.0-40-generic
+\tinitrd /initrd.img-6.8.0-40-generic
+\tappend root=/dev/mapper/vgubuntu-root ro single
+
+
+label l2
+\tmenu label Ubuntu 22.04.5 LTS 5.15.0-127-generic
+\tlinux /vmlinuz-5.15.0-127-generic
+\tinitrd /initrd.img-5.15.0-127-generic
+\tappend root=/dev/mapper/vgubuntu-root ro quiet
+
+label l2r
+\tmenu label Ubuntu 22.04.5 LTS 5.15.0-127-generic (rescue target)
+\tlinux /vmlinuz-5.15.0-127-generic
+\tinitrd /initrd.img-5.15.0-127-generic
+\tappend root=/dev/mapper/vgubuntu-root ro single
+'''
+
+
+class TestInstallExtlinux(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(boot).mkdir()
+        os.system('ls %s' % boot)
+        for ver in versions:
+            Path('%s/config-%s-generic' % (boot, ver)).touch()
+            Path('%s/initrd.img-%s-generic' % (boot, ver)).touch()
+            Path('%s/vmlinuz-%s-generic' % (boot, ver)).touch()
+
+        Path('%s/empty-dir' % self.target).mkdir()
+        self.maxDiff = None
+
+    def test_get_kernel_list(self):
+        iter = paths.get_kernel_list(self.target, full_initrd_path=False)
+        self.assertEqual(
+            ('vmlinuz-6.8.0-48-generic', 'initrd.img-6.8.0-48-generic',
+             '6.8.0-48-generic'),
+            next(iter))
+        self.assertEqual(
+            ('vmlinuz-6.8.0-40-generic', 'initrd.img-6.8.0-40-generic',
+             '6.8.0-40-generic'),
+            next(iter))
+        self.assertEqual(
+            ('vmlinuz-5.15.0-127-generic', 'initrd.img-5.15.0-127-generic',
+             '5.15.0-127-generic'),
+            next(iter))
+        try:
+            val = next(iter)
+            raise ValueError('Extra value %s' % val)
+        except StopIteration:
+            pass
+
+    def test_empty(self):
+        out = install_extlinux.build_content('%s/empty-dir' % self.target,
+                                             USE_EXTLINUX)
+        self.assertEqual(out, EXPECT_HDR)
+
+    def test_normal(self):
+        out = install_extlinux.build_content(self.target, USE_EXTLINUX)
+        self.assertEqual(EXPECT_HDR + EXPECT_BODY, out)
+
+    def test_no_recovery(self):
+        out = install_extlinux.build_content(self.target, USE_EXTLINUX)
+        self.assertEqual(EXPECT_HDR + EXPECT_BODY, out)
+
+    def test_install(self):
+        install_extlinux.install_extlinux(self.target, USE_EXTLINUX)
+        extlinux_path = self.target + '/boot/extlinux'
+        self.assertTrue(os.path.exists(extlinux_path))
+        extlinux_file = extlinux_path + '/extlinux.conf'
+        self.assertTrue(os.path.exists(extlinux_file))
+
+
+# vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_commands_install_grub.py b/tests/unittests/test_commands_install_grub.py
index de56adb..0005da0 100644
--- a/tests/unittests/test_commands_install_grub.py
+++ b/tests/unittests/test_commands_install_grub.py
@@ -9,6 +9,8 @@ from .helpers import CiTestCase
 import mock
 import os
 
+USE_GRUB = {'bootloaders': ['grub']}
+
 
 class TestGetGrubPackageName(CiTestCase):
 
@@ -456,7 +458,7 @@ class TestWriteGrubConfig(CiTestCase):
                 self.assertEqual(expected, found)
 
     def test_write_grub_config_defaults(self):
-        grubcfg = {}
+        bootcfg = USE_GRUB
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -468,12 +470,13 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_TERMINAL="console"'])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_no_replace(self):
-        grubcfg = {'replace_linux_default': False}
+        bootcfg = {'replace_linux_default': False}
+        bootcfg.update(USE_GRUB)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([])
         expected_curtin = "\n".join([
@@ -484,12 +487,13 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_TERMINAL="console"'])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_disable_probe(self):
-        grubcfg = {'probe_additional_os': False}  # DISABLE_OS_PROBER=1
+        bootcfg = {'probe_additional_os': False}  # DISABLE_OS_PROBER=1
+        bootcfg.update(USE_GRUB)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -501,12 +505,13 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_TERMINAL="console"'])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_enable_probe(self):
-        grubcfg = {'probe_additional_os': True}  # DISABLE_OS_PROBER=0, default
+        bootcfg = {'probe_additional_os': True}  # DISABLE_OS_PROBER=0, default
+        bootcfg.update(USE_GRUB)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -515,23 +520,25 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_TERMINAL="console"'])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_no_grub_settings_file(self):
-        grubcfg = {
+        bootcfg = {
             'probe_additional_os': True,
             'terminal': 'unmodified',
         }
+        bootcfg.update(USE_GRUB)
         new_params = []
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
         self.assertTrue(os.path.exists(self.target_grubdefault))
         self.assertFalse(os.path.exists(self.target_grubconf))
 
     def test_write_grub_config_specify_terminal(self):
-        grubcfg = {'terminal': 'serial'}
+        bootcfg = {'terminal': 'serial'}
+        bootcfg.update(USE_GRUB)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -543,12 +550,13 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_TERMINAL="serial"'])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_terminal_unmodified(self):
-        grubcfg = {'terminal': 'unmodified'}
+        bootcfg = {'terminal': 'unmodified'}
+        bootcfg.update(USE_GRUB)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -558,16 +566,16 @@ class TestWriteGrubConfig(CiTestCase):
              'GRUB_DISABLE_OS_PROBER="true"', ''])
 
         install_grub.write_grub_config(
-            self.target, grubcfg, self.grubconf, new_params)
+            self.target, bootcfg, self.grubconf, new_params)
 
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_invalid_terminal(self):
-        grubcfg = {'terminal': ['color-tv']}
+        bootcfg = {'terminal': ['color-tv']}
         new_params = ['foo=bar', 'wark=1']
         with self.assertRaises(ValueError):
             install_grub.write_grub_config(
-                self.target, grubcfg, self.grubconf, new_params)
+                self.target, bootcfg, self.grubconf, new_params)
 
 
 class TestFindEfiLoader(CiTestCase):
@@ -1643,26 +1651,26 @@ class TestInstallGrub(CiTestCase):
     def test_grub_install_raise_exception_on_no_devices(self):
         devices = []
         with self.assertRaises(ValueError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(devices, self.target, False, USE_GRUB)
 
     def test_grub_install_raise_exception_on_no_target(self):
         devices = ['foobar']
         with self.assertRaises(ValueError):
-            install_grub.install_grub(devices, None, False, {})
+            install_grub.install_grub(devices, None, False, USE_GRUB)
 
     def test_grub_install_raise_exception_on_s390x(self):
         self.m_distro_get_architecture.return_value = 's390x'
         self.m_platform_machine.return_value = 's390x'
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(devices, self.target, False, USE_GRUB)
 
     def test_grub_install_raise_exception_on_armv7(self):
         self.m_distro_get_architecture.return_value = 'armhf'
         self.m_platform_machine.return_value = 'armv7l'
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(devices, self.target, False, USE_GRUB)
 
     def test_grub_install_raise_exception_on_arm64_no_uefi(self):
         self.m_distro_get_architecture.return_value = 'arm64'
@@ -1670,12 +1678,12 @@ class TestInstallGrub(CiTestCase):
         uefi = False
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, uefi, {})
+            install_grub.install_grub(devices, self.target, uefi, USE_GRUB)
 
     def test_grub_install_ubuntu(self):
         devices = ['/dev/disk-a-part1']
         uefi = False
-        grubcfg = {}
+        bootcfg = USE_GRUB
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         self.m_get_grub_package_name.return_value = ('grub-pc', 'i386-pc')
@@ -1685,7 +1693,7 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(devices, self.target, uefi, bootcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1696,7 +1704,7 @@ class TestInstallGrub(CiTestCase):
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
         self.m_prepare_grub_dir.assert_called_with(self.target, grub_conf)
-        self.m_write_grub_config.assert_called_with(self.target, grubcfg,
+        self.m_write_grub_config.assert_called_with(self.target, bootcfg,
                                                     grub_conf, new_params)
         self.m_get_grub_install_command.assert_called_with(
             uefi, self.distroinfo, self.target)
@@ -1714,7 +1722,8 @@ class TestInstallGrub(CiTestCase):
         devices = ['/dev/disk-a-part1']
         uefi = True
         update_nvram = True
-        grubcfg = {'update_nvram': update_nvram}
+        bootcfg = {'update_nvram': update_nvram}
+        bootcfg.update(USE_GRUB)
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         grub_name = 'grub-efi-amd64'
@@ -1727,7 +1736,7 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_uefi_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(devices, self.target, uefi, bootcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1738,7 +1747,7 @@ class TestInstallGrub(CiTestCase):
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
         self.m_prepare_grub_dir.assert_called_with(self.target, grub_conf)
-        self.m_write_grub_config.assert_called_with(self.target, grubcfg,
+        self.m_write_grub_config.assert_called_with(self.target, bootcfg,
                                                     grub_conf, new_params)
         self.m_get_grub_install_command.assert_called_with(
             uefi, self.distroinfo, self.target)
@@ -1757,7 +1766,8 @@ class TestInstallGrub(CiTestCase):
         devices = ['/dev/disk-a-part1']
         uefi = True
         update_nvram = True
-        grubcfg = {'update_nvram': update_nvram}
+        bootcfg = {'update_nvram': update_nvram}
+        bootcfg.update(USE_GRUB)
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         grub_name = 'grub-efi-amd64'
@@ -1770,7 +1780,7 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_uefi_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(devices, self.target, uefi, bootcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1781,7 +1791,7 @@ class TestInstallGrub(CiTestCase):
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
         self.m_prepare_grub_dir.assert_called_with(self.target, grub_conf)
-        self.m_write_grub_config.assert_called_with(self.target, grubcfg,
+        self.m_write_grub_config.assert_called_with(self.target, bootcfg,
                                                     grub_conf, new_params)
         self.m_get_grub_install_command.assert_called_with(
             uefi, self.distroinfo, self.target)
diff --git a/tests/unittests/test_config.py b/tests/unittests/test_config.py
index af7f251..d7fec5f 100644
--- a/tests/unittests/test_config.py
+++ b/tests/unittests/test_config.py
@@ -139,4 +139,39 @@ def _replace_consts(cfgstr):
         cfgstr = cfgstr.replace(k, v)
     return cfgstr
 
+
+class TestBootCfg(CiTestCase):
+    def test_empty(self):
+        with self.assertRaises(ValueError) as exc:
+            config.check_bootcfg({})
+        self.assertIn("missing required key: 'bootloaders'", str(exc.exception))
+
+    def test_not_list(self):
+        with self.assertRaises(ValueError) as exc:
+            config.check_bootcfg({'bootloaders': 'invalid'})
+        self.assertIn("bootloaders must be a list: invalid", str(exc.exception))
+
+    def test_empty_list(self):
+        with self.assertRaises(ValueError) as exc:
+            config.check_bootcfg({'bootloaders': []})
+        self.assertIn("Empty bootloaders list:", str(exc.exception))
+
+    def test_duplicate(self):
+        with self.assertRaises(ValueError) as exc:
+            config.check_bootcfg({'bootloaders': ['grub', 'grub']})
+        self.assertIn("bootloaders list contains duplicates: ['grub', 'grub']",
+                      str(exc.exception))
+
+    def test_invalid(self):
+        with self.assertRaises(ValueError) as exc:
+            config.check_bootcfg({'bootloaders': ['fred']})
+        self.assertIn("Unknown bootloader fred: ['fred']", str(exc.exception))
+
+    def test_valid(self):
+        config.check_bootcfg({'bootloaders': ['grub']})
+        config.check_bootcfg({'bootloaders': ['extlinux']})
+        config.check_bootcfg({'bootloaders': ['grub', 'extlinux']})
+        config.check_bootcfg({'bootloaders': ['extlinux', 'grub']})
+
+
 # vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_curthooks.py b/tests/unittests/test_curthooks.py
index d0243b3..bb0ea82 100644
--- a/tests/unittests/test_curthooks.py
+++ b/tests/unittests/test_curthooks.py
@@ -13,6 +13,8 @@ from curtin import config
 from curtin.reporter import events
 from .helpers import CiTestCase, dir2dict, populate_dir, random
 
+USE_GRUB = {'bootloaders': ['grub']}
+
 
 class TestGetFlashKernelPkgs(CiTestCase):
     def setUp(self):
@@ -280,10 +282,10 @@ class TestUpdateInitramfs(CiTestCase):
             call(['update-initramfs', '-c', '-k', kversion3],
                  target=self.target))
         subp_calls += self._subp_calls(
-            call(['update-initramfs', '-c', '-k', self.kversion],
+            call(['update-initramfs', '-c', '-k', kversion2],
                  target=self.target))
         subp_calls += self._subp_calls(
-            call(['update-initramfs', '-c', '-k', kversion2],
+            call(['update-initramfs', '-c', '-k', self.kversion],
                  target=self.target))
         self.mock_subp.assert_has_calls(subp_calls)
         self.assertEqual(24, self.mock_subp.call_count)
@@ -302,10 +304,10 @@ class TestUpdateInitramfs(CiTestCase):
         subp_calls = self._subp_calls(
             call(['dpkg-divert', '--list'], capture=True, target=self.target))
         subp_calls += self._subp_calls(
-            call(['update-initramfs', '-u', '-k', kversion2],
+            call(['update-initramfs', '-c', '-k', self.kversion],
                  target=self.target))
         subp_calls += self._subp_calls(
-            call(['update-initramfs', '-c', '-k', self.kversion],
+            call(['update-initramfs', '-u', '-k', kversion2],
                  target=self.target))
         self.mock_subp.assert_has_calls(subp_calls)
         self.assertEqual(18, self.mock_subp.call_count)
@@ -644,26 +646,74 @@ class TestSetupGrub(CiTestCase):
         updated_cfg = {
             'install_devices': ['/dev/vdb']
         }
-        curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family,
-                             variant=self.variant)
+        updated_cfg.update(USE_GRUB)
+
+        curthooks.setup_boot(cfg, self.target, machine='amd64',
+                             stack_prefix='stack_prefix',
+                             osfamily=self.distro_family, variant=self.variant)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=False, grubcfg=updated_cfg)
+            ['/dev/vdb'], self.target, uefi=False, bootcfg=updated_cfg)
 
-    def test_uses_install_devices_in_grubcfg(self):
+    def test_uses_install_devices_in_bootcfg(self):
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
             },
         }
         curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=False, grubcfg=cfg.get('grub'))
+            ['/dev/vdb'], self.target, uefi=False, bootcfg=cfg.get('boot'))
+
+    def test_uses_old_schema_install_devices_in_grubcfg(self):
+        cfg = {
+            'grub': {
+                'install_devices': ['/dev/vdb'],
+            },
+        }
+        updated_cfg = {
+            'install_devices': ['/dev/vdb']
+        }
+        updated_cfg.update(USE_GRUB)
+        curthooks.setup_boot(
+            cfg, self.target, machine='amd64', stack_prefix='stack_prefix',
+            osfamily=self.distro_family, variant=self.variant)
+        self.m_install_grub.assert_called_with(
+            ['/dev/vdb'], self.target, uefi=False, bootcfg=updated_cfg)
+
+    def test_calls_install_grub(self):
+        cfg = {
+            'boot': {
+                'bootloaders': ['grub'],
+                'install_devices': ['/dev/vdb'],
+            },
+        }
+        curthooks.setup_boot(
+            cfg, self.target, 'amd64', '/testing',
+            osfamily=self.distro_family, variant=self.variant)
+        self.m_install_grub.assert_called_with(
+            ['/dev/vdb'], self.target, uefi=False, bootcfg=cfg.get('boot'))
+
+    def test_skips_install_grub(self):
+        cfg = {
+            'boot': {
+                'bootloaders': ['grub'],
+                'install_devices': ['/dev/vdb'],
+            },
+        }
+        curthooks.setup_boot(
+            cfg, self.target, 'aarch64', '/testing',
+            osfamily=self.distro_family, variant=self.variant)
+        self.assertEqual(0, self.m_install_grub.call_count)
 
     @patch('curtin.commands.block_meta.multipath')
     @patch('curtin.commands.curthooks.os.path.exists')
     def test_uses_grub_install_on_storage_config(self, m_exists, m_multipath):
         m_multipath.is_mpath_member.return_value = False
         cfg = {
+            'boot': {
+                'bootloaders': ['grub'],
+            },
             'storage': {
                 'version': 1,
                 'config': [
@@ -676,12 +726,16 @@ class TestSetupGrub(CiTestCase):
                 ]
             },
         }
+        updated_cfg = {
+            'bootloaders': ['grub'],
+            'install_devices': ['/dev/vdb']
+        }
         m_exists.return_value = True
         curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family,
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
             ['/dev/vdb'], self.target, uefi=False,
-            grubcfg={'install_devices': ['/dev/vdb']})
+            bootcfg=updated_cfg)
 
     @patch('curtin.commands.block_meta.multipath')
     @patch('curtin.block.is_valid_device')
@@ -722,7 +776,8 @@ class TestSetupGrub(CiTestCase):
                     },
                 ]
             },
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'update_nvram': False,
             },
         }
@@ -732,12 +787,14 @@ class TestSetupGrub(CiTestCase):
                              variant='centos')
         self.m_install_grub.assert_called_with(
             ['/dev/vdb1'], self.target, uefi=True,
-            grubcfg={'update_nvram': False, 'install_devices': ['/dev/vdb1']}
+            bootcfg={'update_nvram': False, 'bootloaders': ['grub'],
+                     'install_devices': ['/dev/vdb1']}
         )
 
     def test_grub_install_installs_to_none_if_install_devices_None(self):
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': None,
             },
         }
@@ -745,7 +802,7 @@ class TestSetupGrub(CiTestCase):
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
             ['none'], self.target, uefi=False,
-            grubcfg={'install_devices': None}
+            bootcfg={'bootloaders': ['grub'], 'install_devices': None}
         )
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
@@ -755,7 +812,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': False,
@@ -776,7 +834,7 @@ class TestSetupGrub(CiTestCase):
         curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family,
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=True, grubcfg=cfg.get('grub')
+            ['/dev/vdb'], self.target, uefi=True, bootcfg=cfg.get('boot')
         )
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
@@ -786,7 +844,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': True,
@@ -833,7 +892,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': False,
@@ -869,7 +929,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': False,
@@ -915,7 +976,8 @@ class TestSetupGrub(CiTestCase):
                        'mock_remove_old_loaders')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': False,
@@ -966,7 +1028,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': True,
@@ -1016,7 +1079,8 @@ class TestSetupGrub(CiTestCase):
         self.add_patch('curtin.util.get_efibootmgr', 'mock_efibootmgr')
         self.mock_is_uefi_bootable.return_value = True
         cfg = {
-            'grub': {
+            'boot': {
+                'bootloaders': ['grub'],
                 'install_devices': ['/dev/vdb'],
                 'update_nvram': True,
                 'remove_old_uefi_loaders': True,
@@ -1106,8 +1170,8 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries(self):
-        grubcfg = {}
-        curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
+        bootcfg = USE_GRUB
+        curthooks.uefi_remove_duplicate_entries(bootcfg, self.target)
         self.assertEqual([
             call(['efibootmgr', '--bootnum=0001', '--delete-bootnum'],
                  target=self.target),
@@ -1117,11 +1181,11 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_no_bootcurrent(self):
-        grubcfg = {}
+        bootcfg = USE_GRUB
         efiout = copy.deepcopy(self.efibootmgr_output)
         del efiout['current']
         self.m_efibootmgr.return_value = efiout
-        curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
+        curthooks.uefi_remove_duplicate_entries(bootcfg, self.target)
         self.assertEqual([
             call(['efibootmgr', '--bootnum=0001', '--delete-bootnum'],
                  target=self.target),
@@ -1131,19 +1195,20 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_disabled(self):
-        grubcfg = {
+        bootcfg = {
             'remove_duplicate_entries': False,
         }
-        curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
+        bootcfg.update(USE_GRUB)
+        curthooks.uefi_remove_duplicate_entries(bootcfg, self.target)
         self.assertEqual([], self.m_subp.call_args_list)
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_skip_bootcurrent(self):
-        grubcfg = {}
+        bootcfg = USE_GRUB
         efiout = copy.deepcopy(self.efibootmgr_output)
         efiout['current'] = '0003'
         self.m_efibootmgr.return_value = efiout
-        curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
+        curthooks.uefi_remove_duplicate_entries(bootcfg, self.target)
         self.assertEqual([
             call(['efibootmgr', '--bootnum=0000', '--delete-bootnum'],
                  target=self.target),
@@ -1153,7 +1218,7 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_no_change(self):
-        grubcfg = {}
+        bootcfg = USE_GRUB
         self.m_efibootmgr.return_value = {
             'current': '0000',
             'entries': {
@@ -1174,7 +1239,7 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
                 },
             }
         }
-        curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
+        curthooks.uefi_remove_duplicate_entries(bootcfg, self.target)
         self.assertEqual([], self.m_subp.call_args_list)
 
 
diff --git a/tests/vmtests/test_reuse_uefi_esp.py b/tests/vmtests/test_reuse_uefi_esp.py
index 3d4d3e0..1faf811 100644
--- a/tests/vmtests/test_reuse_uefi_esp.py
+++ b/tests/vmtests/test_reuse_uefi_esp.py
@@ -14,7 +14,7 @@ class TestUefiReuseEspAbs(TestBasicAbs):
         efi_output = util.parse_efibootmgr(
             self.load_collect_file("efibootmgr.out"))
         duplicates = uefi_find_duplicate_entries(
-            grubcfg=None, target=None, efi_output=efi_output)
+            bootcfg=None, target=None, efi_output=efi_output)
         print(duplicates)
         self.assertEqual(0, len(duplicates))
 
diff --git a/tools/vmtest-system-setup b/tools/vmtest-system-setup
index c9c1231..7105f76 100755
--- a/tools/vmtest-system-setup
+++ b/tools/vmtest-system-setup
@@ -24,6 +24,7 @@ DEPS=(
   python3-jsonschema
   python3-nose
   python3-oauthlib
+  python3-packaging
   python3-parameterized
   python3-pep8
   python3-pip

Follow ups