From c0c585c48a43eec5abc4ab508124d019c7c6f213 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 26 Aug 2026 19:51:07 +0200 Subject: [PATCH 01/11] bootloader: Separate probing bootupd's options from parsing them bootupd_supports_filesystem fused running `bootupctl backend install --help` inside a chroot with the substring check on its output, so the check could not be unit tested, and each further flag to probe for would have meant another copy of it spawning its own process. Split it the way systemd_version/parse_systemd_version already are in this file: bootupd_install_help runs the command and returns its output, help_advertises_flag is a pure predicate over that text, and install_via_bootupd probes once up front and asks the predicate per flag. The predicate also anchors on the flag beginning an option line rather than matching anywhere, so that `--boot` no longer matches `--bootloader`, and a flag named in another option's description is not mistaken for support for it. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/bootloader.rs | 74 +++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 08a208d28..dbc99742a 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -95,12 +95,27 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } -/// Check whether the target bootupd supports `--filesystem`. +/// Whether `bootupctl backend install --help` advertises `flag`. /// -/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the -/// output. When `chroot_target` is set the command runs inside a chroot -/// (via [`ChrootCmd`]) so we probe the binary from the target image. -fn bootupd_supports_filesystem(chroot_target: Option<&Utf8Path>) -> Result { +/// clap renders an option as `--flag `, or `-f, --flag ` when it has a +/// short form, always ahead of the description. Match only in that leading +/// option column, and on a whole token: a flag named inside another option's +/// prose is not support for it, and `--boot` is not `--bootloader`. +fn help_advertises_flag(help: &str, flag: &str) -> bool { + help.lines().any(|line| { + line.split_whitespace() + .take_while(|token| token.starts_with('-')) + .any(|token| token.trim_end_matches(',') == flag) + }) +} + +/// The output of `bootupctl backend install --help` from the target bootupd. +/// +/// When `chroot_target` is set the command runs inside a chroot (via +/// [`ChrootCmd`]) so we probe the binary from the target image rather than the +/// buildroot. +#[context("Querying bootupd install options")] +fn bootupd_install_help(chroot_target: Option<&Utf8Path>) -> Result { let help_args = ["bootupctl", "backend", "install", "--help"]; let output = if let Some(target_root) = chroot_target { ChrootCmd::new(target_root) @@ -112,16 +127,7 @@ fn bootupd_supports_filesystem(chroot_target: Option<&Utf8Path>) -> Result .log_debug() .run_get_string()? }; - - let use_filesystem = output.contains("--filesystem"); - - if use_filesystem { - tracing::debug!("bootupd supports --filesystem"); - } else { - tracing::debug!("bootupd does not support --filesystem, falling back to --device"); - } - - Ok(use_filesystem) + Ok(output) } /// Install the bootloader via bootupd. @@ -156,6 +162,9 @@ pub(crate) fn install_via_bootupd( // bootc defaults to only targeting the platform boot method. let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); + // Probe the target bootupd's install options once, up front. + let help = bootupd_install_help(chroot_target)?; + // When not running inside the target container (through `--src-imgref`) we // run bootupctl from the deployment via a chroot ([`ChrootCmd`]). // This makes sure we use binaries from the target image rather than the buildroot. @@ -190,9 +199,7 @@ pub(crate) fn install_via_bootupd( // parent via require_single_root(). (Older bootupd doesn't support // multiple backing devices anyway.) // Computed before building bootupd_args so the String lives long enough. - let root_device_path = if bootupd_supports_filesystem(chroot_target) - .context("Probing bootupd --filesystem support")? - { + let root_device_path = if help_advertises_flag(&help, "--filesystem") { None } else { Some(device.require_single_root()?.path()) @@ -475,6 +482,37 @@ pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) mod tests { use super::*; + #[test] + fn test_help_advertises_flag() { + // Excerpted from `bootupctl backend install --help` of a recent and + // an old release. + const NEW: &str = " --filesystem \n --bootloader \n"; + const OLD: &str = " --device \n"; + let cases = [ + (NEW, "--filesystem", true), + (NEW, "--bootloader", true), + (OLD, "--filesystem", false), + (OLD, "--bootloader", false), + // An option rendered with a short form still counts. + (" -f, --filesystem \n", "--filesystem", true), + // A flag must not match a longer one that starts with it. + (" --bootloader \n", "--boot", false), + // Nor a mention inside another option's description. + ( + " --device ignored when --filesystem is given\n", + "--filesystem", + false, + ), + ]; + for (help, flag, expected) in cases { + assert_eq!( + help_advertises_flag(help, flag), + expected, + "{flag} in {help:?}" + ); + } + } + #[test] fn test_parse_systemd_version() { // The first line of `bootctl --version`. the trailing feature line is ignored. From 296ad877c47e2e41bbf2e5fbccb31fe8ad10fc64 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 20:41:20 +0200 Subject: [PATCH 02/11] bootloader: Split Secure Boot key staging out of install_systemd_boot Prep for installing systemd-boot through bootupd: the keys from usr/lib/bootc/install/secureboot-keys belong to systemd-boot, which reads them from loader/keys on the ESP, not to bootctl. A later commit needs to stage them after bootupd has installed systemd-boot as well. Move the staging into write_autoenroll_keys, with no change in behaviour. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/bootloader.rs | 79 +++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index dbc99742a..e87fa363f 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -328,41 +328,54 @@ pub(crate) fn install_systemd_boot( // Capture stderr so bootctl error messages appear in our error chain. .run_capture_stderr()?; - if let Some(SecurebootKeys { dir, keys }) = autoenroll { - let esp_dir = prepared_root.open_esp_dir()?; - let keys_path = prepared_root - .root_path() - .join(prepared_root.esp_subdir) - .join(SYSTEMD_KEY_DIR); - create_dir_all(&keys_path).with_context(|| { - format!("Creating secureboot key directory {}", keys_path.display()) - })?; - - let keys_dir = esp_dir - .open_dir(SYSTEMD_KEY_DIR) - .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?; - - for filename in keys.iter() { - // Each key lives in a subdirectory, e.g. "PK/PK.auth". - // Create the per-key subdirectory before copying the file into it. - if let Some(parent) = filename.parent() { - if !parent.as_str().is_empty() { - keys_dir - .create_dir_all(parent) - .with_context(|| format!("Creating key subdirectory {parent}"))?; - } + write_autoenroll_keys(prepared_root, autoenroll) +} + +/// Stage Secure Boot keys on the ESP for systemd-boot's setup-mode enrollment. +/// +/// This is systemd-boot specific: the keys go in `loader/keys`, which only +/// systemd-boot reads. +#[context("Writing Secure Boot enrollment keys")] +fn write_autoenroll_keys( + prepared_root: &MountedImageRoot, + autoenroll: Option, +) -> Result<()> { + let Some(SecurebootKeys { dir, keys }) = autoenroll else { + return Ok(()); + }; + + let esp_dir = prepared_root.open_esp_dir()?; + let keys_path = prepared_root + .root_path() + .join(prepared_root.esp_subdir) + .join(SYSTEMD_KEY_DIR); + create_dir_all(&keys_path) + .with_context(|| format!("Creating secureboot key directory {}", keys_path.display()))?; + + let keys_dir = esp_dir + .open_dir(SYSTEMD_KEY_DIR) + .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?; + + for filename in keys.iter() { + // Each key lives in a subdirectory, e.g. "PK/PK.auth". + // Create the per-key subdirectory before copying the file into it. + if let Some(parent) = filename.parent() { + if !parent.as_str().is_empty() { + keys_dir + .create_dir_all(parent) + .with_context(|| format!("Creating key subdirectory {parent}"))?; } - dir.copy(filename, &keys_dir, filename) - .with_context(|| format!("Copying secure boot key {filename:?}"))?; - println!( - "Wrote Secure Boot key: {}/{}", - keys_path.display(), - filename.as_str() - ); - } - if keys.is_empty() { - tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment"); } + dir.copy(filename, &keys_dir, filename) + .with_context(|| format!("Copying secure boot key {filename:?}"))?; + println!( + "Wrote Secure Boot key: {}/{}", + keys_path.display(), + filename.as_str() + ); + } + if keys.is_empty() { + tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment"); } Ok(()) From ad925aae7659abfc6cb60d967ff19cc277a102e4 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 20:44:54 +0200 Subject: [PATCH 03/11] bootloader: Pass --bootloader to bootupd when supported install_via_bootupd never told bootupd which bootloader to install, leaving the choice to bootupd. bootupd 0.3.0 and newer take the default bootloader recorded in the EFI update metadata, or the only candidate, and otherwise fail with "Multiple bootloaders found as install candidates" when only the EFI component is targeted. Versions 0.2.30 to 0.2.35, and 0.2.36 builds from the published crate, do not choose at all: they copy every component under usr/lib/efi, so an image that also ships, say, a systemd-boot component gets it on top of GRUB's second stage without any error. Pass the caller's bootloader as --bootloader when the target bootupd accepts it. Probe the accepted values rather than the flag: the help lists them as [possible values: ...], and packaged 0.2.36 builds, made from a crate without build.rs, advertise the flag but accept only grub. Probe with -h, whose short help always lists the values inline, while clap's long help switches to a list once the values get help text of their own, and match with whitespace collapsed, because clap wraps long lines. The values are the names Bootloader's Display already produces (grub, grub-cc, systemd). The parser is tested against the real help of CentOS Stream 10's 0.2.35, Fedora's 0.2.36 and Fedora's 0.3.2, which also pins those names. A bootupd that cannot be asked for GRUB is left to its own choice as before; asking one for anything else is an error rather than a silent GRUB install. Only a bootupd that also accepts grub-cc or systemd leaves the other bootloaders' components out; one that accepts only grub still copies them all. Let callers target bootupd's EFI component instead of passing --auto, and do so for grub-cc. On x86_64, --auto picks the BIOS component when the installing host itself booted in BIOS/CSM mode, and bootupd then installs nothing on the ESP and exits 0, so the composefs path's grub-cc swap found no GRUB on the ESP to replace. grub-cc is still requested as grub, because the grub-cc packages available today put the binary inside the grub2 component, so the composefs path installs GRUB and then swaps the binary in, as bootupd's own choice did before this change. Note the ostree path now always asks for grub when bootupd accepts it, also when nothing was requested and Grub was only picked because bootupd is present. That takes precedence over a default_bootloader recorded in the image's bootupd metadata, deliberately: bootc's own choice already governs the boot layout it writes, and the ostree backend supports nothing else. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/bootc_composefs/boot.rs | 11 + crates/lib/src/bootloader.rs | 284 +++++++++++++++++- .../bootupctl-install-help-0.2.35.txt | 16 + .../bootupctl-install-help-0.2.36.txt | 45 +++ .../fixtures/bootupctl-install-help-0.3.2.txt | 22 ++ crates/lib/src/install.rs | 2 + 6 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 crates/lib/src/fixtures/bootupctl-install-help-0.2.35.txt create mode 100644 crates/lib/src/fixtures/bootupctl-install-help-0.2.36.txt create mode 100644 crates/lib/src/fixtures/bootupctl-install-help-0.3.2.txt diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da..b5e28b077 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -101,6 +101,7 @@ use serde::{Deserialize, Serialize}; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_kargs::compute_new_kargs; +use crate::bootloader::BootupdComponents; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; use crate::spec::BootloaderKind; @@ -2150,12 +2151,22 @@ pub(crate) async fn setup_composefs_boot( // an empty `boot/efi` directory for its EFI component to discover // and mount the real ESP into, exactly as it would on ostree. let bind_boot_path = root_setup.physical_root_path.join(BOOT); + // The grub-cc packages available today put the binary inside the grub2 + // component, so bootupd cannot install grub-cc from them: ask it for + // GRUB and swap the binary in afterwards (the FIXME below). grub-cc + // only boots from EFI, so install only that component either way. + let (bootupd_bootloader, components) = match postfetch.detected_bootloader { + Bootloader::GrubCC => (Bootloader::Grub, BootupdComponents::Efi), + bootloader => (bootloader, BootupdComponents::Auto), + }; crate::bootloader::install_via_bootupd( &root_setup.device_info, &root_setup.physical_root_path, &state.config_opts, Some(chroot_target), Some(bind_boot_path.as_path()), + bootupd_bootloader, + components, )?; // FIXME: Remove this hack once we have support in bootupd diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index e87fa363f..df2f9fe55 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -95,28 +95,57 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } -/// Whether `bootupctl backend install --help` advertises `flag`. +/// The flags in a clap help line's leading option column: `--flag` for +/// `--flag `, or `-f` and `--flag` for `-f, --flag `. Description +/// lines have none. +fn help_line_flags(line: &str) -> impl Iterator { + line.split_whitespace() + .take_while(|token| token.starts_with('-')) + .map(|token| token.trim_end_matches(',')) +} + +/// Whether the help of `bootupctl backend install` advertises `flag`. /// /// clap renders an option as `--flag `, or `-f, --flag ` when it has a /// short form, always ahead of the description. Match only in that leading /// option column, and on a whole token: a flag named inside another option's /// prose is not support for it, and `--boot` is not `--bootloader`. fn help_advertises_flag(help: &str, flag: &str) -> bool { - help.lines().any(|line| { - line.split_whitespace() - .take_while(|token| token.starts_with('-')) - .any(|token| token.trim_end_matches(',') == flag) - }) + help.lines() + .any(|line| help_line_flags(line).any(|f| f == flag)) } -/// The output of `bootupctl backend install --help` from the target bootupd. +/// The values clap lists for `flag` as `[possible values: a, b]`, anywhere in +/// that option's entry: on the option line itself in short help, or on a +/// description line below it in long help. clap wraps long lines, so the +/// entry is matched with its whitespace collapsed. `None` when `flag` is not +/// advertised or lists no values. +fn help_flag_values(help: &str, flag: &str) -> Option> { + let mut lines = help + .lines() + .skip_while(|line| !help_line_flags(line).any(|f| f == flag)); + let first = lines.next()?; + let entry = std::iter::once(first) + .chain(lines.take_while(|line| help_line_flags(line).next().is_none())) + .flat_map(str::split_whitespace) + .collect::>() + .join(" "); + let (_, rest) = entry.split_once("[possible values: ")?; + let (values, _) = rest.split_once(']')?; + Some(values.split(',').map(|v| v.trim().to_owned()).collect()) +} + +/// The short help (`-h`) of `bootupctl backend install` from the target +/// bootupd. Unlike `--help`, which switches to a list once the values have +/// help text of their own, short help always lists an option's values inline +/// as `[possible values: ...]`. /// /// When `chroot_target` is set the command runs inside a chroot (via /// [`ChrootCmd`]) so we probe the binary from the target image rather than the /// buildroot. #[context("Querying bootupd install options")] fn bootupd_install_help(chroot_target: Option<&Utf8Path>) -> Result { - let help_args = ["bootupctl", "backend", "install", "--help"]; + let help_args = ["bootupctl", "backend", "install", "-h"]; let output = if let Some(target_root) = chroot_target { ChrootCmd::new(target_root) .set_default_path() @@ -130,6 +159,106 @@ fn bootupd_install_help(chroot_target: Option<&Utf8Path>) -> Result { Ok(output) } +/// What the target bootupd's `backend install` accepts, from its help. +#[derive(Debug, Clone, PartialEq, Eq)] +struct BootupdInstallSupport { + /// `--filesystem`, which lets bootupd find the backing devices itself. + filesystem: bool, + /// The bootloaders `--bootloader` accepts. Empty when bootupd has no such + /// option (before 0.2.36) or lists no values for it. Packaged 0.2.36 + /// builds accept only GRUB, although they advertise the option. + bootloaders: Vec, +} + +impl BootupdInstallSupport { + /// Parse the help of `bootupctl backend install`. bootupd's `--bootloader` + /// values are the names [`crate::spec::Bootloader`]'s `Display` produces. + fn parse(help: &str) -> Self { + use crate::spec::Bootloader; + let values = help_flag_values(help, "--bootloader").unwrap_or_default(); + let bootloaders = [Bootloader::Grub, Bootloader::GrubCC, Bootloader::Systemd] + .into_iter() + .filter(|bootloader| values.contains(&bootloader.to_string())) + .collect(); + Self { + filesystem: help_advertises_flag(help, "--filesystem"), + bootloaders, + } + } + + /// Probe the target bootupd, see [`bootupd_install_help`]. + fn probe(chroot_target: Option<&Utf8Path>) -> Result { + Ok(Self::parse(&bootupd_install_help(chroot_target)?)) + } +} + +/// The `--bootloader` value to pass to bootupd for `bootloader`, if any. +/// +/// A bootupd that cannot be asked for GRUB, such as one from before 0.2.36, +/// which has no `--bootloader`, is left to install what it finds, as before. +/// Asking such a bootupd for anything else is an error rather than a silent +/// GRUB install. +/// +/// Only a bootupd that also accepts grub-cc or systemd limits the install to +/// the bootloader it is asked for. Versions 0.2.30 to 0.2.35, which have no +/// `--bootloader`, and a packaged 0.2.36 build that accepts only grub copy +/// every component under usr/lib/efi, so another bootloader's component can +/// overwrite GRUB's second stage. +fn bootupd_bootloader_arg( + support: &BootupdInstallSupport, + bootloader: crate::spec::Bootloader, +) -> Result> { + use crate::spec::Bootloader; + if support.bootloaders.contains(&bootloader) { + return Ok(Some(bootloader.to_string())); + } + match bootloader { + Bootloader::Grub => Ok(None), + Bootloader::None => bail!("BUG: bootupd invoked to install no bootloader"), + _ if support.bootloaders.is_empty() => { + bail!("bootupd in the image cannot be asked to install {bootloader}") + } + _ => { + let accepted = support + .bootloaders + .iter() + .map(|b| b.to_string()) + .collect::>() + .join(", "); + bail!("bootupd in the image cannot install {bootloader}, only {accepted}") + } + } +} + +/// Which of bootupd's components to install, unless the image is generic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BootupdComponents { + /// The ones for how the installing host booted, as bootupd's `--auto` + /// picks them: EFI or BIOS. + Auto, + /// Only EFI, for a bootloader that only boots from there. + Efi, +} + +/// The bootupd arguments that select the components to install. +/// +/// Generic images get every component, and bootupd skips those that cannot +/// install the bootloader. Otherwise bootc targets only what this machine +/// boots. A bootloader that only boots from EFI needs +/// [`BootupdComponents::Efi`]: on an x86_64 host booted in BIOS/CSM mode, +/// `--auto` picks BIOS, and bootupd then installs nothing on the ESP and still +/// succeeds. +fn bootupd_target_args( + generic_image: bool, + components: BootupdComponents, +) -> &'static [&'static str] { + match components { + _ if generic_image => &[], + BootupdComponents::Auto => &["--update-firmware", "--auto"], + BootupdComponents::Efi => &["--update-firmware", "--component", "EFI"], + } +} + /// Install the bootloader via bootupd. /// /// When the target bootupd supports `--filesystem` we pass it pointing at a @@ -150,6 +279,15 @@ fn bootupd_install_help(chroot_target: Option<&Utf8Path>) -> Result { /// `--write-uuid` from whatever filesystem is mounted at `/boot`, /// and looks for an empty `boot/efi` directory there to discover and mount /// the real ESP into. +/// +/// `bootloader` is passed on as `--bootloader` when the target bootupd accepts +/// it, see [`bootupd_bootloader_arg`] for what that guarantees. It takes +/// precedence over any `default_bootloader` recorded in the image's bootupd +/// metadata, deliberately: bootc's own choice already governs the boot layout +/// it writes (see [`crate::spec::BootloaderKind`]), so letting bootupd pick a +/// different one would leave the two disagreeing. +/// +/// `components` selects what to install, see [`bootupd_target_args`]. #[context("Installing bootloader")] pub(crate) fn install_via_bootupd( device: &bootc_blockdev::Device, @@ -157,13 +295,14 @@ pub(crate) fn install_via_bootupd( configopts: &crate::install::InstallConfigOpts, chroot_target: Option<&Utf8Path>, bind_boot_path: Option<&Utf8Path>, + bootloader: crate::spec::Bootloader, + components: BootupdComponents, ) -> Result<()> { let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv"); - // bootc defaults to only targeting the platform boot method. - let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); // Probe the target bootupd's install options once, up front. - let help = bootupd_install_help(chroot_target)?; + let support = BootupdInstallSupport::probe(chroot_target)?; + let bootloader_arg = bootupd_bootloader_arg(&support, bootloader)?; // When not running inside the target container (through `--src-imgref`) we // run bootupctl from the deployment via a chroot ([`ChrootCmd`]). @@ -189,8 +328,11 @@ pub(crate) fn install_via_bootupd( bootupd_args.push(v); } - if let Some(ref opts) = bootupd_opts { - bootupd_args.extend(opts.iter().copied()); + bootupd_args.extend(bootupd_target_args(configopts.generic_image, components)); + if let Some(name) = &bootloader_arg { + bootupd_args.extend(["--bootloader", name.as_str()]); + } else { + tracing::debug!("bootupd cannot be asked for {bootloader}, relying on its own choice"); } // When the target bootupd lacks --filesystem support, fall back to the @@ -199,7 +341,7 @@ pub(crate) fn install_via_bootupd( // parent via require_single_root(). (Older bootupd doesn't support // multiple backing devices anyway.) // Computed before building bootupd_args so the String lives long enough. - let root_device_path = if help_advertises_flag(&help, "--filesystem") { + let root_device_path = if support.filesystem { None } else { Some(device.require_single_root()?.path()) @@ -497,8 +639,8 @@ mod tests { #[test] fn test_help_advertises_flag() { - // Excerpted from `bootupctl backend install --help` of a recent and - // an old release. + // Excerpted from the help of `bootupctl backend install` of a recent + // and an old release. const NEW: &str = " --filesystem \n --bootloader \n"; const OLD: &str = " --device \n"; let cases = [ @@ -526,6 +668,116 @@ mod tests { } } + #[test] + fn test_parse_install_help() { + use crate::spec::Bootloader::{Grub, GrubCC, Systemd}; + // The help of real builds: `bootupctl backend install -h` of CentOS + // Stream 10's 0.2.35 and of Fedora's 0.3.2, which wraps at 100 + // columns, and the long `--help` of Fedora's 0.2.36-3.fc46, with + // trailing whitespace trimmed. That 0.2.36 build was made from a crate + // without build.rs, so only GRUB is compiled in. + let cases = [ + ( + include_str!("fixtures/bootupctl-install-help-0.2.35.txt"), + true, + vec![], + ), + ( + include_str!("fixtures/bootupctl-install-help-0.2.36.txt"), + true, + vec![Grub], + ), + ( + include_str!("fixtures/bootupctl-install-help-0.3.2.txt"), + true, + vec![Grub, GrubCC, Systemd], + ), + // Short help renders the values on the option line. + ( + " --bootloader The bootloader to use [possible values: grub, systemd]\n", + false, + vec![Grub, Systemd], + ), + // Values that clap wrapped onto the following lines. + ( + " --bootloader The bootloader to use [possible\n values: grub, grub-cc,\n systemd]\n", + false, + vec![Grub, GrubCC, Systemd], + ), + // An option without listed values, and values that belong to the + // next option, accept nothing. + ( + " --bootloader \n The bootloader to use\n --component \n [possible values: grub]\n", + false, + vec![], + ), + (" --device \n", false, vec![]), + ]; + for (help, filesystem, bootloaders) in cases { + assert_eq!( + BootupdInstallSupport::parse(help), + BootupdInstallSupport { + filesystem, + bootloaders, + }, + "{help:?}" + ); + } + } + + #[test] + fn test_bootupd_bootloader_arg() { + use crate::spec::Bootloader::{self, Grub, GrubCC, None as NoBootloader, Systemd}; + let support = |bootloaders: &[Bootloader]| BootupdInstallSupport { + filesystem: true, + bootloaders: bootloaders.to_vec(), + }; + let current = support(&[Grub, GrubCC, Systemd]); + let grub_only = support(&[Grub]); + let too_old = support(&[]); + // Ok(Some(value)), Ok(None) for bootupd's own choice, or Err(()). + let cases: [(&BootupdInstallSupport, Bootloader, Result, ()>); 10] = [ + (¤t, Grub, Ok(Some("grub"))), + (¤t, GrubCC, Ok(Some("grub-cc"))), + (¤t, Systemd, Ok(Some("systemd"))), + (¤t, NoBootloader, Err(())), + (&grub_only, Grub, Ok(Some("grub"))), + (&grub_only, Systemd, Err(())), + (&grub_only, GrubCC, Err(())), + (&too_old, Grub, Ok(None)), + (&too_old, Systemd, Err(())), + (&too_old, GrubCC, Err(())), + ]; + for (support, bootloader, expected) in cases { + let arg = bootupd_bootloader_arg(support, bootloader); + assert_eq!( + arg.as_ref().map(|v| v.as_deref()).map_err(|_| ()), + expected, + "{bootloader} with {support:?}" + ); + } + } + + #[test] + fn test_bootupd_target_args() { + use BootupdComponents::{Auto, Efi}; + let auto: &[&str] = &["--update-firmware", "--auto"]; + let efi: &[&str] = &["--update-firmware", "--component", "EFI"]; + let cases = [ + (false, Auto, auto), + (false, Efi, efi), + (true, Auto, &[][..]), + (true, Efi, &[][..]), + ]; + for (generic_image, components, expected) in cases { + assert_eq!( + bootupd_target_args(generic_image, components), + expected, + "{components:?}, generic image: {generic_image}" + ); + } + } + #[test] fn test_parse_systemd_version() { // The first line of `bootctl --version`. the trailing feature line is ignored. diff --git a/crates/lib/src/fixtures/bootupctl-install-help-0.2.35.txt b/crates/lib/src/fixtures/bootupctl-install-help-0.2.35.txt new file mode 100644 index 000000000..874746499 --- /dev/null +++ b/crates/lib/src/fixtures/bootupctl-install-help-0.2.35.txt @@ -0,0 +1,16 @@ +Usage: bootupctl backend install [OPTIONS] + +Arguments: + Target root + +Options: + --src-root Source root [default: /] + -v... Verbosity level (higher is more verbose) + --device Target device(s) for bootloader installation. Can be specified multiple times to install to multiple devices (e.g., for multi-disk RAID/LVM setups) + --filesystem Filesystem path to inspect for backing devices. Bootupd will walk up the device hierarchy to find physical disks and install to all ESPs found + --with-static-configs Enable installation of the built-in static config files + --write-uuid Implies `--with-static-configs`. When present, this also writes a file with the UUID of the target filesystems + --update-firmware On EFI systems, invoke `efibootmgr` to update the firmware + --component Only install these components + --auto Automatically choose components based on booted host state + -h, --help Print help (see more with '--help') diff --git a/crates/lib/src/fixtures/bootupctl-install-help-0.2.36.txt b/crates/lib/src/fixtures/bootupctl-install-help-0.2.36.txt new file mode 100644 index 000000000..9ecc48e69 --- /dev/null +++ b/crates/lib/src/fixtures/bootupctl-install-help-0.2.36.txt @@ -0,0 +1,45 @@ +Usage: bootupctl backend install [OPTIONS] + +Arguments: + + Target root + +Options: + --src-root + Source root + + [default: /] + + -v... + Verbosity level (higher is more verbose) + + --device + Target device(s) for bootloader installation. Can be specified multiple times to install to multiple devices (e.g., for multi-disk RAID/LVM setups) + + --filesystem + Filesystem path to inspect for backing devices. Bootupd will walk up the device hierarchy to find physical disks and install to all ESPs found + + --with-static-configs + Enable installation of the built-in static config files + + --write-uuid + Implies `--with-static-configs`. When present, this also writes a file with the UUID of the target filesystems + + --update-firmware + On EFI systems, invoke `efibootmgr` to update the firmware + + --component + Only install these components + + --auto + Automatically choose components based on booted host state. + + For example on x86_64, if the host system is booted via EFI, then only enable installation to the ESP. + + --bootloader + The bootloader to use + + [possible values: grub] + + -h, --help + Print help (see a summary with '-h') diff --git a/crates/lib/src/fixtures/bootupctl-install-help-0.3.2.txt b/crates/lib/src/fixtures/bootupctl-install-help-0.3.2.txt new file mode 100644 index 000000000..eb5fb31ae --- /dev/null +++ b/crates/lib/src/fixtures/bootupctl-install-help-0.3.2.txt @@ -0,0 +1,22 @@ +Usage: bootupctl backend install [OPTIONS] + +Arguments: + Target root + +Options: + --src-root Source root [default: /] + -v... Verbosity level (higher is more verbose) + --device Target device(s) for bootloader installation. Can be specified + multiple times to install to multiple devices (e.g., for multi-disk + RAID/LVM setups) + --filesystem Filesystem path to inspect for backing devices. Bootupd will walk + up the device hierarchy to find physical disks and install to all + ESPs found + --with-static-configs Enable installation of the built-in static config files + --write-uuid Implies `--with-static-configs`. When present, this also writes a + file with the UUID of the target filesystems + --update-firmware On EFI systems, invoke `efibootmgr` to update the firmware + --component Only install these components + --auto Automatically choose components based on booted host state + --bootloader The bootloader to use [possible values: grub, grub-cc, systemd] + -h, --help Print help (see more with '--help') diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 8f9f80fc4..1b57754cc 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2012,6 +2012,8 @@ async fn install_with_sysroot( &state.config_opts, Some(chroot_target.as_path()), Some(bind_boot_path.as_path()), + postfetch.detected_bootloader, + crate::bootloader::BootupdComponents::Auto, )?; } Bootloader::Systemd | Bootloader::GrubCC => { From 02569ee4348c8ad61ae830618a1f0b8a35fe4caa Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 20:46:03 +0200 Subject: [PATCH 04/11] install: Remove bootupd's ESP state file when cleaning the ESP bootupd keeps its state in bootupd-state.json at the root of the ESP when it installs grub-cc or systemd-boot (for GRUB it lives in /boot), and checks every bootloader's state file before installing: an existing one fails any later install, GRUB included, with "invalid re-install attempted: bootupd-state.json already exists in the ESP". Since 54078f2b ("install: Only remove bootloader dirs from the ESP"), cleaning the ESP for to-existing-root keeps everything outside EFI/ and loader/, so reinstalling over a system whose bootloader bootupd installed that way would fail. Remove the state file along with the bootloader directories; bootupd writes a new one. The multi-device ESP integration test now seeds the file before its single-disk to-existing-root install and checks that it is gone. Prep for having bootupd install grub-cc and systemd-boot. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/install.rs | 12 +++++++++++- tmt/tests/booted/test-multi-device-esp.nu | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 1b57754cc..a98e04fcf 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2506,7 +2506,12 @@ fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> { Ok(()) } -/// Remove the bootloader dirs (bootupd/grub, systemd-boot) from the ESP. +/// bootupd's state file, which it keeps at the root of the ESP when it installs +/// grub-cc or systemd-boot (for GRUB it lives in `/boot`). +const BOOTUPD_ESP_STATE_FILE: &str = "bootupd-state.json"; + +/// Remove the bootloader dirs (bootupd/grub, systemd-boot) from the ESP, and +/// bootupd's state file, since bootupd refuses to install over an existing one. /// Other content, e.g. Asahi's `m1n1/` and `vendorfw/`, may be firmware /// or earlier boot stages we cannot recreate, so it is preserved. // TODO: be more selective, e.g. keep other OSes' `EFI/` and @@ -2519,6 +2524,9 @@ fn clean_esp_bootloader_dirs(efidir: &Dir) -> Result<()> { .with_context(|| format!("Removing directory: {name}"))?; } } + efidir + .remove_file_optional(BOOTUPD_ESP_STATE_FILE) + .with_context(|| format!("Removing {BOOTUPD_ESP_STATE_FILE}"))?; Ok(()) } @@ -3278,6 +3286,7 @@ mod tests { td.write("EFI/BOOT/BOOTAA64.EFI", b"shim")?; td.create_dir_all("loader/entries")?; td.write("loader/entries/foo.conf", b"entry")?; + td.write(BOOTUPD_ESP_STATE_FILE, b"{}")?; // Asahi content which must survive td.create_dir_all("m1n1")?; td.write("m1n1/boot.bin", b"m1n1")?; @@ -3286,6 +3295,7 @@ mod tests { clean_esp_bootloader_dirs(&td)?; assert!(!td.exists("EFI")); assert!(!td.exists("loader")); + assert!(!td.exists(BOOTUPD_ESP_STATE_FILE)); assert_eq!(td.read("m1n1/boot.bin")?, b"m1n1"); assert_eq!(td.read("ubootefi.var")?, b"efivars"); diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu index acf5bff4f..3cb578880 100644 --- a/tmt/tests/booted/test-multi-device-esp.nu +++ b/tmt/tests/booted/test-multi-device-esp.nu @@ -371,11 +371,15 @@ def test_single_device_no_lvm [] { # Create /boot/efi so the ESP gets mounted and cleaned mkdir $"($mountpoint)/boot/efi" - # Seed non-bootloader content (as on Asahi) and a stale bootloader dir + # Seed non-bootloader content (as on Asahi), a stale bootloader dir, + # and the state file a grub-cc or systemd-boot install through bootupd + # leaves at the ESP root. bootupd refuses to install while any such + # file exists, whatever its content. with_esp $"($loop1)p1" {|esp| mkdir $"($esp)/m1n1" $"($esp)/EFI/stale" "m1n1" | save $"($esp)/m1n1/boot.bin" "stale" | save $"($esp)/EFI/stale/x.efi" + "{}" | save $"($esp)/bootupd-state.json" } # Show block device hierarchy @@ -388,9 +392,11 @@ def test_single_device_no_lvm [] { let r = (with_esp $"($loop1)p1" {|esp| { m1n1: ($"($esp)/m1n1/boot.bin" | path exists) stale: ($"($esp)/EFI/stale" | path exists) + state: ($"($esp)/bootupd-state.json" | path exists) }}) assert $r.m1n1 "m1n1/boot.bin was removed from the ESP" assert (not $r.stale) "EFI/stale was not removed from the ESP" + assert (not $r.state) "bootupd-state.json was not removed from the ESP" } catch {|e| cleanup_simple $loop1 $mountpoint rm -f $disk1 From bbfb182d188a6ffe4890b613cb21d2eb41973eda Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 22:07:21 +0200 Subject: [PATCH 05/11] blockdev: List an ESP shared by several disks once find_colocated_esps looks for an ESP on every disk backing the device. On a firmware RAID such as Intel VROC, the ESP is a partition of the array, which find_partition_of_esp_optional reaches through each member disk, so the one ESP was listed once per disk. A caller that counts the ESPs, as the composefs install is about to, would take it for several. List each ESP once, by device path, and test that with the existing software and firmware RAID fixtures. find_first_colocated_esp, the only caller so far, is unaffected. Generated-by: AI Signed-off-by: Christian Glombek --- crates/blockdev/src/blockdev.rs | 43 ++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 1906efa02..75e63585f 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -197,12 +197,7 @@ impl Device { /// Calls find_all_roots() to discover physical disks, then searches each for an ESP. /// Returns None if no ESPs are found. pub fn find_colocated_esps(&self) -> Result>> { - let mut esps = Vec::new(); - for root in &self.find_all_roots()? { - if let Some(esp) = root.find_partition_of_esp_optional()? { - esps.push(esp.clone()); - } - } + let esps = esps_of_roots(&self.find_all_roots()?)?; Ok((!esps.is_empty()).then_some(esps)) } @@ -456,6 +451,20 @@ impl Device { } } +/// The ESPs found on `roots`, each listed once: an ESP on a firmware RAID +/// array (such as Intel VROC) is found through every disk in the array. +fn esps_of_roots(roots: &[Device]) -> Result> { + let mut esps: Vec = Vec::new(); + for root in roots { + if let Some(esp) = root.find_partition_of_esp_optional()? { + if !esps.iter().any(|known| known.path() == esp.path()) { + esps.push(esp.clone()); + } + } + } + Ok(esps) +} + #[context("Listing device {dev}")] pub fn list_dev(dev: &Utf8Path) -> Result { let mut devs: DevicesOutput = Command::new("lsblk") @@ -991,6 +1000,28 @@ mod test { } } + #[test] + fn test_esps_of_roots() { + let cases = [ + // Each disk of a software RAID has an ESP of its own. + ( + include_str!("../tests/fixtures/lsblk-swraid.json"), + &["sda1", "sdb1"][..], + ), + // Both NVMe disks of a firmware RAID lead to the array's one ESP. + ( + include_str!("../tests/fixtures/lsblk-vroc.json"), + &["md126p1"][..], + ), + ]; + for (fixture, expected) in cases { + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + let esps = esps_of_roots(&devs.blockdevices).unwrap(); + let names = esps.iter().map(|esp| esp.name.as_str()).collect::>(); + assert_eq!(names, expected); + } + } + #[test] fn test_parse_lsblk_swraid() { let fixture = include_str!("../tests/fixtures/lsblk-swraid.json"); From 1c0f05c223dc82a15a17546c672280250bc5b360 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 22:09:29 +0200 Subject: [PATCH 06/11] composefs: Let bootupd install grub-cc itself when the image allows The composefs path installs grub-cc with a workaround from before bootupd could: it asks bootupd for GRUB and then swaps in a grub-cc binary the image stages at usr/lib/grub-cc/grub-cc.efi, as bootc's own test images do. bootupd can install grub-cc itself since 0.3.0, but only from an image that ships grub-cc as a bootupd component of its own, under usr/lib/efi/grub-cc. The grub-cc packages available today (grub2-efi-x64-cc 2.12-82 in ELN) put the binary inside the grub2 component instead, where bootupd cannot tell it apart from GRUB. bootc cannot ask bootupd which bootloaders it could install from an unbooted image: there, `bootupctl status --json` only names the BIOS and EFI components. bootupd derives that from its EFI update metadata, usr/lib/bootupd/updates/EFI.json, which names every component it found since 0.2.30. Read that metadata in bootc with the same name mapping, with a FIXME to replace it by a bootupd query once one exists. Ask bootupd for grub-cc directly when the metadata lists a grub-cc component and bootupd accepts grub-cc for --bootloader; otherwise keep the swap. Both are needed: bootupd 0.2.30 to 0.2.35 list components but cannot be asked for one, and packaged 0.2.36 builds accept only GRUB. bootupd also installs on every ESP of the target, while bootc writes the BLS entries grub-cc reads to the first ESP only, so roots with several ESPs keep the swap. Only consider the image's bootupd when the image itself ships bootupctl, since that is the one bootc runs. CI keeps exercising the swap: no available package ships a grub-cc component yet, so the native path is untested for now. The Dockerfile comment that explains the swap in bootc's test images now gives that reason. Generated-by: AI Signed-off-by: Christian Glombek --- Dockerfile | 12 +- crates/lib/src/bootc_composefs/boot.rs | 176 +++++++++++++++++++++++-- crates/lib/src/bootloader.rs | 125 +++++++++++++++++- crates/lib/src/install.rs | 1 + 4 files changed, 286 insertions(+), 28 deletions(-) diff --git a/Dockerfile b/Dockerfile index a36bc5638..287e06904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -172,13 +172,11 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ dnf install -y "${pkgs_to_install[@]}" fi - # Currently dnf installs grub-cc at /usr/lib/efi/grub2/1:2.12-60.eln156/EFI/eln/cc/grubx64-cc.efi - # which is less than ideal because: - # - the "cc" subdirectory - # - no support for installing grub-cc in bootupd - # - # So we move the binary to /usr/lib/grub-cc/grub-cc.efi so we have a predictale location from which - # we can copy the EFI binary to the ESP + # The grub-cc package ships its binary inside the grub2 component, e.g. + # /usr/lib/efi/grub2//EFI/fedora/cc/grubx64-cc.efi, and bootupd only + # installs grub-cc from a component of its own. So bootc asks bootupd for + # GRUB and then swaps in the binary, which we stage at the predictable + # /usr/lib/grub-cc/grub-cc.efi (BootloaderInstallMethod::BootupdGrubCcSwap). if [[ "$bootloader" == "grub-cc" ]]; then mkdir /var/grub-cc rpm2archive /var/grub-cc.rpm | tar -xvz -C /var/grub-cc diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index b5e28b077..6ec5555d7 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2088,6 +2088,94 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } +/// How the composefs install puts the bootloader on the ESP. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BootloaderInstallMethod { + /// `bootupctl backend install`, asked for this bootloader. + Bootupd(Bootloader), + /// bootupd installs GRUB, and bootc then swaps in the grub-cc binary the + /// image stages at `usr/lib/grub-cc/grub-cc.efi` (bootc's own test images + /// do). This covers images whose bootupd cannot install grub-cc itself; the + /// grub-cc packages available today ship the binary inside the grub2 + /// component. + BootupdGrubCcSwap, + /// A bare `bootctl install` of systemd-boot. + Bootctl, +} + +impl BootloaderInstallMethod { + /// The bootloader to ask bootupd for and which of its components to + /// install, or `None` when bootupd is not used. Only GRUB also boots from + /// BIOS; grub-cc does not, also when it is swapped in after bootupd + /// installed GRUB. + fn bootupd_request(self) -> Option<(Bootloader, BootupdComponents)> { + match self { + Self::Bootupd(Bootloader::Grub) => Some((Bootloader::Grub, BootupdComponents::Auto)), + Self::Bootupd(bootloader) => Some((bootloader, BootupdComponents::Efi)), + Self::BootupdGrubCcSwap => Some((Bootloader::Grub, BootupdComponents::Efi)), + Self::Bootctl => None, + } + } +} + +/// What the image's bootupd can install. +#[derive(Debug)] +struct BootupdCapabilities { + /// What its `backend install` accepts. + support: crate::bootloader::BootupdInstallSupport, + /// The bootloaders with a component in its update metadata. + available: Vec, +} + +/// Probe what the image's bootupd can install; `None` when the image ships no +/// usable bootupd. +#[context("Probing bootupd in the image")] +fn probe_bootupd(mounted_root: &MountedImageRoot) -> Result> { + if !crate::bootloader::supports_bootupd(mounted_root.dir())? + || !crate::utils::have_executable_in_root(mounted_root.dir(), "bootupctl")? + { + return Ok(None); + } + let chroot_target = Utf8Path::from_path(mounted_root.root_path()) + .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?; + Ok(Some(BootupdCapabilities { + support: crate::bootloader::BootupdInstallSupport::probe(Some(chroot_target))?, + available: crate::bootloader::bootupd_available_bootloaders(mounted_root.dir())?, + })) +} + +/// Choose how to install the requested bootloader, given what the image's +/// bootupd can do (`None` when the image ships none) and how many ESPs the +/// target has. +/// +/// bootupd can only be relied on for a bootloader other than GRUB when it +/// accepts that bootloader for `--bootloader` and its metadata lists a +/// component for it: bootupd 0.2.30 to 0.2.35 list components but cannot be +/// asked for one, and packaged 0.2.36 builds accept only GRUB. bootupd also +/// installs on every ESP of the target, while bootc writes the entries these +/// bootloaders read to the first ESP only, so several ESPs keep the previous +/// behaviour. +fn choose_install_method( + requested: Bootloader, + bootupd: Option<&BootupdCapabilities>, + esps: usize, +) -> BootloaderInstallMethod { + let bootupd_installs = |bootloader: Bootloader| { + esps == 1 + && bootupd + .is_some_and(|c| c.support.accepts(bootloader) && c.available.contains(&bootloader)) + }; + match requested { + Bootloader::GrubCC if bootupd_installs(Bootloader::GrubCC) => { + BootloaderInstallMethod::Bootupd(Bootloader::GrubCC) + } + Bootloader::GrubCC => BootloaderInstallMethod::BootupdGrubCcSwap, + Bootloader::Grub => BootloaderInstallMethod::Bootupd(Bootloader::Grub), + // composefs installs reject `none` before getting here. + Bootloader::Systemd | Bootloader::None => BootloaderInstallMethod::Bootctl, + } +} + #[context("Setting up composefs boot")] pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, @@ -2131,16 +2219,27 @@ pub(crate) async fn setup_composefs_boot( .or(root_setup.rootfs_uuid.as_deref()) .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + // Only grub-cc has more than one way to be installed, and none of them + // applies to s390x, which always uses zipl. + let (bootupd, esps) = match postfetch.detected_bootloader { + Bootloader::GrubCC if !cfg!(target_arch = "s390x") => ( + probe_bootupd(&mounted_root)?, + root_setup + .device_info + .find_colocated_esps()? + .map_or(0, |esps| esps.len()), + ), + _ => (None, 0), + }; + let method = choose_install_method(postfetch.detected_bootloader, bootupd.as_ref(), esps); + if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd crate::bootloader::install_via_zipl( &root_setup.device_info.require_single_root()?, boot_uuid, )?; - } else if matches!( - postfetch.detected_bootloader, - Bootloader::Grub | Bootloader::GrubCC - ) { + } else if let Some((bootupd_bootloader, components)) = method.bootupd_request() { let chroot_target = Utf8Path::from_path(mounted_root.root_path()) .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?; // Like the ostree backend, bind the physical root's real /boot (an @@ -2151,14 +2250,6 @@ pub(crate) async fn setup_composefs_boot( // an empty `boot/efi` directory for its EFI component to discover // and mount the real ESP into, exactly as it would on ostree. let bind_boot_path = root_setup.physical_root_path.join(BOOT); - // The grub-cc packages available today put the binary inside the grub2 - // component, so bootupd cannot install grub-cc from them: ask it for - // GRUB and swap the binary in afterwards (the FIXME below). grub-cc - // only boots from EFI, so install only that component either way. - let (bootupd_bootloader, components) = match postfetch.detected_bootloader { - Bootloader::GrubCC => (Bootloader::Grub, BootupdComponents::Efi), - bootloader => (bootloader, BootupdComponents::Auto), - }; crate::bootloader::install_via_bootupd( &root_setup.device_info, &root_setup.physical_root_path, @@ -2167,10 +2258,12 @@ pub(crate) async fn setup_composefs_boot( Some(bind_boot_path.as_path()), bootupd_bootloader, components, + bootupd.as_ref().map(|c| &c.support), )?; - // FIXME: Remove this hack once we have support in bootupd - if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) { + // FIXME: Drop this, and BootloaderInstallMethod::BootupdGrubCcSwap, + // once grub-cc packages ship the binary as a bootupd component. + if method == BootloaderInstallMethod::BootupdGrubCcSwap { // bootupctl wrote this under the physical root's real /boot (via // the bind mount above), not under the composefs root. root_setup @@ -2301,6 +2394,61 @@ mod tests { use super::*; use composefs::erofs::format::FormatVersion; + #[test] + fn test_choose_install_method() { + use crate::spec::Bootloader::{Grub, GrubCC, Systemd}; + use BootloaderInstallMethod::{Bootctl, Bootupd, BootupdGrubCcSwap as Swap}; + let support = |help| crate::bootloader::BootupdInstallSupport::parse(help); + let current = support(include_str!("../fixtures/bootupctl-install-help-0.3.2.txt")); + let grub_only = support(include_str!( + "../fixtures/bootupctl-install-help-0.2.36.txt" + )); + let too_old = support(include_str!( + "../fixtures/bootupctl-install-help-0.2.35.txt" + )); + let caps = |support: &crate::bootloader::BootupdInstallSupport, + available: &[Bootloader]| { + BootupdCapabilities { + support: support.clone(), + available: available.to_vec(), + } + }; + // A current bootupd with and without a grub-cc component, a packaged + // 0.2.36 that accepts only GRUB, and 0.2.35, which lists components but + // cannot be asked for one. + let with_cc = caps(¤t, &[GrubCC, Grub]); + let without_cc = caps(¤t, &[Grub]); + let grub_only = caps(&grub_only, &[GrubCC, Grub]); + let too_old = caps(&too_old, &[GrubCC, Grub]); + let cases = [ + (Grub, None, 1, Bootupd(Grub)), + (Grub, Some(&with_cc), 1, Bootupd(Grub)), + (Grub, Some(&too_old), 2, Bootupd(Grub)), + (GrubCC, Some(&with_cc), 1, Bootupd(GrubCC)), + (GrubCC, Some(&with_cc), 2, Swap), + (GrubCC, Some(&without_cc), 1, Swap), + (GrubCC, Some(&grub_only), 1, Swap), + (GrubCC, Some(&too_old), 1, Swap), + (GrubCC, None, 1, Swap), + (Systemd, Some(&with_cc), 1, Bootctl), + (Systemd, None, 1, Bootctl), + ]; + for (requested, bootupd, esps, expected) in cases { + assert_eq!( + choose_install_method(requested, bootupd, esps), + expected, + "{requested} with {bootupd:?} and {esps} ESPs" + ); + } + // What each method asks bootupd for. The swap asks for GRUB, but like + // grub-cc itself only on EFI. + use crate::bootloader::BootupdComponents::{Auto, Efi}; + assert_eq!(Bootupd(Grub).bootupd_request(), Some((Grub, Auto))); + assert_eq!(Bootupd(GrubCC).bootupd_request(), Some((GrubCC, Efi))); + assert_eq!(Swap.bootupd_request(), Some((Grub, Efi))); + assert_eq!(Bootctl.bootupd_request(), None); + } + #[test] fn test_grub_bls_abs_entries_path() -> Result<()> { let td = tempfile::tempdir()?; diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index df2f9fe55..38ecb91b0 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -1,4 +1,5 @@ use std::fs::create_dir_all; +use std::io::Read; use std::process::Command; use std::sync::OnceLock; @@ -8,6 +9,7 @@ use camino::Utf8Path; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; +use serde::Deserialize; use bootc_mount as mount; @@ -95,6 +97,66 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } +/// bootupd's update metadata for its EFI component, written by +/// `bootupctl backend generate-update-metadata`. +const BOOTUPD_EFI_METADATA: &str = "usr/lib/bootupd/updates/EFI.json"; + +/// The part of bootupd's EFI update metadata that bootc reads: since bootupd +/// 0.2.30 it names every EFI component found in the image. +#[derive(Debug, Deserialize)] +struct BootupdEfiMetadata { + #[serde(default)] + versions: Option>, +} + +#[derive(Debug, Deserialize)] +struct BootupdComponentVersion { + name: String, +} + +/// The bootloaders with a component in bootupd's EFI update metadata. +/// +/// Component names map to bootloaders the way bootupd itself maps them: +/// `grub2`, `grub-cc` and `systemd-boot`. Anything else, such as shim, belongs +/// to no bootloader. Metadata written by bootupd before 0.2.30 names no +/// components at all, which yields an empty list. +fn parse_bootupd_bootloaders(metadata: &str) -> Result> { + use crate::spec::Bootloader; + let metadata: BootupdEfiMetadata = + serde_json::from_str(metadata).context("Parsing bootupd EFI update metadata")?; + Ok(metadata + .versions + .into_iter() + .flatten() + .filter_map(|component| match component.name.as_str() { + "grub2" => Some(Bootloader::Grub), + "grub-cc" => Some(Bootloader::GrubCC), + "systemd-boot" => Some(Bootloader::Systemd), + _ => None, + }) + .collect()) +} + +/// The bootloaders with a component in the image's bootupd update metadata. +/// +/// bootc cannot ask bootupd for this from an unbooted image: there, +/// `bootupctl status --json` only names the BIOS and EFI components. So it +/// reads the metadata bootupd itself derives its install candidates from. +/// Empty when there is no EFI metadata (no bootupd, or a BIOS-only payload) +/// or when it predates bootupd naming its components. +// FIXME: This reads a bootupd-internal file, and parse_bootupd_bootloaders +// mirrors bootupd's Bootloader::try_from_efi_component_name. Replace both with +// a bootupd query once one can answer this for an unbooted root. +#[context("Reading bootupd EFI update metadata")] +pub(crate) fn bootupd_available_bootloaders(root: &Dir) -> Result> { + let Some(mut file) = root.open_optional(BOOTUPD_EFI_METADATA)? else { + return Ok(Vec::new()); + }; + let mut metadata = String::new(); + file.read_to_string(&mut metadata)?; + parse_bootupd_bootloaders(&metadata) +} + /// The flags in a clap help line's leading option column: `--flag` for /// `--flag `, or `-f` and `--flag` for `-f, --flag `. Description /// lines have none. @@ -161,7 +223,7 @@ fn bootupd_install_help(chroot_target: Option<&Utf8Path>) -> Result { /// What the target bootupd's `backend install` accepts, from its help. #[derive(Debug, Clone, PartialEq, Eq)] -struct BootupdInstallSupport { +pub(crate) struct BootupdInstallSupport { /// `--filesystem`, which lets bootupd find the backing devices itself. filesystem: bool, /// The bootloaders `--bootloader` accepts. Empty when bootupd has no such @@ -173,7 +235,7 @@ struct BootupdInstallSupport { impl BootupdInstallSupport { /// Parse the help of `bootupctl backend install`. bootupd's `--bootloader` /// values are the names [`crate::spec::Bootloader`]'s `Display` produces. - fn parse(help: &str) -> Self { + pub(crate) fn parse(help: &str) -> Self { use crate::spec::Bootloader; let values = help_flag_values(help, "--bootloader").unwrap_or_default(); let bootloaders = [Bootloader::Grub, Bootloader::GrubCC, Bootloader::Systemd] @@ -187,9 +249,14 @@ impl BootupdInstallSupport { } /// Probe the target bootupd, see [`bootupd_install_help`]. - fn probe(chroot_target: Option<&Utf8Path>) -> Result { + pub(crate) fn probe(chroot_target: Option<&Utf8Path>) -> Result { Ok(Self::parse(&bootupd_install_help(chroot_target)?)) } + + /// Whether bootupd can be asked to install `bootloader`. + pub(crate) fn accepts(&self, bootloader: crate::spec::Bootloader) -> bool { + self.bootloaders.contains(&bootloader) + } } /// The `--bootloader` value to pass to bootupd for `bootloader`, if any. @@ -209,7 +276,7 @@ fn bootupd_bootloader_arg( bootloader: crate::spec::Bootloader, ) -> Result> { use crate::spec::Bootloader; - if support.bootloaders.contains(&bootloader) { + if support.accepts(bootloader) { return Ok(Some(bootloader.to_string())); } match bootloader { @@ -280,6 +347,9 @@ fn bootupd_target_args( /// and looks for an empty `boot/efi` directory there to discover and mount /// the real ESP into. /// +/// `support` is what the target bootupd accepts, when the caller has already +/// probed it; otherwise it is probed here. +/// /// `bootloader` is passed on as `--bootloader` when the target bootupd accepts /// it, see [`bootupd_bootloader_arg`] for what that guarantees. It takes /// precedence over any `default_bootloader` recorded in the image's bootupd @@ -297,12 +367,21 @@ pub(crate) fn install_via_bootupd( bind_boot_path: Option<&Utf8Path>, bootloader: crate::spec::Bootloader, components: BootupdComponents, + support: Option<&BootupdInstallSupport>, ) -> Result<()> { let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv"); - // Probe the target bootupd's install options once, up front. - let support = BootupdInstallSupport::probe(chroot_target)?; - let bootloader_arg = bootupd_bootloader_arg(&support, bootloader)?; + // Probe the target bootupd's install options once, up front, unless the + // caller already has. + let probed; + let support = match support { + Some(support) => support, + None => { + probed = BootupdInstallSupport::probe(chroot_target)?; + &probed + } + }; + let bootloader_arg = bootupd_bootloader_arg(support, bootloader)?; // When not running inside the target container (through `--src-imgref`) we // run bootupctl from the deployment via a chroot ([`ChrootCmd`]). @@ -668,6 +747,38 @@ mod tests { } } + #[test] + fn test_parse_bootupd_bootloaders() { + use crate::spec::Bootloader; + // As written by `bootupctl backend generate-update-metadata`. + let cases = [ + // bootupd 0.2.30 and newer name every component; shim belongs to + // no bootloader. + ( + r#"{"timestamp":"2026-09-30T10:07:13Z","version":"grub2-1:2.12-64.fc44,shim-16.1-5,systemd-boot-259.9-1.fc44","versions":[{"name":"grub2","rpm_evr":"1:2.12-64.fc44"},{"name":"shim","rpm_evr":"16.1-5"},{"name":"systemd-boot","rpm_evr":"259.9-1.fc44"}],"default-bootloader":null}"#, + vec![Bootloader::Grub, Bootloader::Systemd], + ), + ( + r#"{"timestamp":"2026-06-10T09:52:58Z","version":"grub-cc-1:2.12-59.fc45,grub2-1:2.12-58.fc44,shim-16.1-5","versions":[{"name":"grub-cc","rpm_evr":"1:2.12-59.fc45"},{"name":"grub2","rpm_evr":"1:2.12-58.fc44"},{"name":"shim","rpm_evr":"16.1-5"}]}"#, + vec![Bootloader::GrubCC, Bootloader::Grub], + ), + // bootupd's own type is optional, so it may serialize as null. + ( + r#"{"timestamp":"2026-01-01T00:00:00Z","version":"grub2-1:2.12-1.fc43","versions":null}"#, + vec![], + ), + // bootupd before 0.2.30 wrote no component list. + ( + r#"{"timestamp":"2025-01-01T00:00:00Z","version":"grub2-1:2.06-1.fc40,shim-15.8-1"}"#, + vec![], + ), + ]; + for (metadata, expected) in cases { + assert_eq!(parse_bootupd_bootloaders(metadata).unwrap(), expected); + } + assert!(parse_bootupd_bootloaders("not json").is_err()); + } + #[test] fn test_parse_install_help() { use crate::spec::Bootloader::{Grub, GrubCC, Systemd}; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index a98e04fcf..3dab451c2 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2014,6 +2014,7 @@ async fn install_with_sysroot( Some(bind_boot_path.as_path()), postfetch.detected_bootloader, crate::bootloader::BootupdComponents::Auto, + None, )?; } Bootloader::Systemd | Bootloader::GrubCC => { From ffe60ee3371074954df91f73eb60bbc3108b9ce6 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 20:52:00 +0200 Subject: [PATCH 07/11] composefs: Install systemd-boot via bootupd to keep shim in boot path Bootloader::Systemd fell through to install_systemd_boot(), which runs a plain `bootctl install`. That writes systemd-boot to EFI/BOOT/BOOT.EFI, so firmware loads it directly and shim is never in the boot path, even when the image ships bootupd and shim. Such an install boots under Secure Boot only if the firmware's db trusts systemd-boot's signer, and it loses the shim that fwupd chains its UEFI capsule updates through. bootupd installs shim alongside whichever bootloader it is asked for, and Fedora ships systemd-boot under the second-stage name baked into shim (systemd-boot-x64 since 262), so routing Systemd through the path GRUB already takes yields shim + systemd-boot with no bootc-side special casing. Shim then lets systemd-boot boot with the firmware's stock keys, provided shim trusts systemd-boot's signer. Fedora's shim does not yet: Fedora signs systemd-boot only with its 2025 CA, while its shim embeds the 2020 CA (rhbz#2268695), so for now that certificate has to be enrolled as a MOK; the docs show how. Take that route only when the decision from the previous commit finds that the image's bootupd accepts --bootloader systemd and its update metadata lists a systemd-boot component, and the target has a single ESP; otherwise keep today's bare `bootctl install` and say that shim is left out, rather than silently changing what lands on the ESP. The metadata check matters because a systemd-boot package laid out where bootupd does not look is invisible to it (Fedora shipped it that way until https://src.fedoraproject.org/rpms/systemd-boot/pull-request/4), and bootupd up to 0.3.2 then installs shim alone and reports success; coreos/bootupd#1144, still open, would make that an error on bootupd's side. Several ESPs stay with bootctl because bootupd installs on every ESP and points the firmware at the last one, while bootc writes the entries systemd-boot reads to the first one only. Secure Boot key enrollment belongs to systemd-boot rather than to whatever installed it, so the keys from usr/lib/bootc/install/secureboot-keys are staged on this path too. They are read before bootupd runs, so a malformed key directory still fails the install before anything is written. Note this makes a new combination reachable: keys staged for an install that boots via shim. Enrolling a db without shim's signer leaves shim unverifiable, and systemd-boot enrolls a key set named `auto` by itself in a VM in setup mode. A db that keeps shim's signer is legitimate though, so warn visibly rather than refuse, and say so in bootc-install(8). Other differences from `bootctl install`: bootupd writes no loader/random-seed, loader/entries.srel or loader/loader.conf, so UKI installs through bootupd get the 5-second menu timeout bootc writes when loader.conf is missing (UKI through bootupd is untested); and without --generic-image, bootupd replaces the firmware boot entries carrying the OS name with one for shim, where bootctl --root leaves EFI variables alone. Note this path is not exercised by the pre-existing CI legs: contrib/packaging/switch-to-sdboot removes bootupd and shim from every bootloader=systemd test image, so the bootctl path is taken. A later commit adds a leg for it. Generated-by: AI Signed-off-by: Christian Glombek --- crates/lib/src/bootc_composefs/boot.rs | 74 +++++++++++++++++++--- crates/lib/src/bootloader.rs | 5 +- docs/src/bootc-bootloaders.7.md | 72 ++++++++++++++++++++- docs/src/bootc-experimental-composefs.7.md | 2 +- docs/src/bootc-installation.7.md | 5 +- docs/src/man/bootc-install.8.md | 9 +++ tmt/tests/booted/test-multi-device-esp.nu | 5 +- 7 files changed, 154 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 6ec5555d7..4f7cc521f 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2099,7 +2099,9 @@ enum BootloaderInstallMethod { /// grub-cc packages available today ship the binary inside the grub2 /// component. BootupdGrubCcSwap, - /// A bare `bootctl install` of systemd-boot. + /// A bare `bootctl install` of systemd-boot. That writes systemd-boot to + /// `EFI/BOOT/BOOT.EFI`, so firmware loads it directly and shim is + /// never in the boot path. Bootctl, } @@ -2155,6 +2157,13 @@ fn probe_bootupd(mounted_root: &MountedImageRoot) -> Result, @@ -2170,6 +2179,9 @@ fn choose_install_method( BootloaderInstallMethod::Bootupd(Bootloader::GrubCC) } Bootloader::GrubCC => BootloaderInstallMethod::BootupdGrubCcSwap, + Bootloader::Systemd if bootupd_installs(Bootloader::Systemd) => { + BootloaderInstallMethod::Bootupd(Bootloader::Systemd) + } Bootloader::Grub => BootloaderInstallMethod::Bootupd(Bootloader::Grub), // composefs installs reject `none` before getting here. Bootloader::Systemd | Bootloader::None => BootloaderInstallMethod::Bootctl, @@ -2219,10 +2231,10 @@ pub(crate) async fn setup_composefs_boot( .or(root_setup.rootfs_uuid.as_deref()) .ok_or_else(|| anyhow!("No uuid for boot/root"))?; - // Only grub-cc has more than one way to be installed, and none of them - // applies to s390x, which always uses zipl. + // Only grub-cc and systemd-boot have more than one way to be installed, + // and none of them applies to s390x, which always uses zipl. let (bootupd, esps) = match postfetch.detected_bootloader { - Bootloader::GrubCC if !cfg!(target_arch = "s390x") => ( + Bootloader::GrubCC | Bootloader::Systemd if !cfg!(target_arch = "s390x") => ( probe_bootupd(&mounted_root)?, root_setup .device_info @@ -2232,6 +2244,11 @@ pub(crate) async fn setup_composefs_boot( _ => (None, 0), }; let method = choose_install_method(postfetch.detected_bootloader, bootupd.as_ref(), esps); + if method == BootloaderInstallMethod::Bootctl && bootupd.is_some() { + println!( + "Installing systemd-boot with bootctl, without shim: bootupd in the image cannot install it here (that needs a bootupd that accepts --bootloader systemd, a systemd-boot component in its update metadata, and a single ESP)" + ); + } if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd @@ -2250,6 +2267,18 @@ pub(crate) async fn setup_composefs_boot( // an empty `boot/efi` directory for its EFI component to discover // and mount the real ESP into, exactly as it would on ostree. let bind_boot_path = root_setup.physical_root_path.join(BOOT); + // Secure Boot key enrollment belongs to systemd-boot, whichever tool + // installs it. Read the keys before bootupd writes anything, so a + // malformed key directory fails the install early. A directory + // without any .auth files in it is not worth a warning, or an ESP + // mount. + let autoenroll_keys = match method { + BootloaderInstallMethod::Bootupd(Bootloader::Systemd) => { + get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)? + .filter(|keys| !keys.keys.is_empty()) + } + _ => None, + }; crate::bootloader::install_via_bootupd( &root_setup.device_info, &root_setup.physical_root_path, @@ -2305,6 +2334,27 @@ pub(crate) async fn setup_composefs_boot( Ok(()) })?; } + + if let Some(keys) = autoenroll_keys { + // Staging keys behind shim is newly possible, and the two can + // disagree: enrolling a db that does not trust shim's signer + // leaves shim unverifiable. systemd-boot enrolls a key set named + // `auto` by itself in a VM in setup mode (secure-boot-enroll + // defaults to if-safe) and offers any other set in its menu. A db + // that keeps shim's signer is a legitimate combination, so warn + // rather than refuse. + crate::utils::medium_visibility_warning( + "Staging Secure Boot enrollment keys for an install that boots through shim: \ + the enrolled db must also contain the Microsoft UEFI CA 2023, which signs \ + shim, or the firmware may refuse to start shim, and bootupd 0.3.2 and newer \ + refuse all bootloader updates", + ); + // install_via_bootupd above has already returned, so it's safe to + // mount the ESP here. + mounted_root.with_esp(|_esp_dir| { + crate::bootloader::write_autoenroll_keys(&mounted_root, Some(keys)) + })?; + } } else { mounted_root.with_esp(|_esp_dir| { crate::bootloader::install_systemd_boot( @@ -2413,13 +2463,14 @@ mod tests { available: available.to_vec(), } }; - // A current bootupd with and without a grub-cc component, a packaged - // 0.2.36 that accepts only GRUB, and 0.2.35, which lists components but - // cannot be asked for one. + // A current bootupd with and without grub-cc and systemd-boot + // components, a packaged 0.2.36 that accepts only GRUB, and 0.2.35, + // which lists components but cannot be asked for one. let with_cc = caps(¤t, &[GrubCC, Grub]); + let with_sd = caps(¤t, &[Grub, Systemd]); let without_cc = caps(¤t, &[Grub]); - let grub_only = caps(&grub_only, &[GrubCC, Grub]); - let too_old = caps(&too_old, &[GrubCC, Grub]); + let grub_only = caps(&grub_only, &[GrubCC, Grub, Systemd]); + let too_old = caps(&too_old, &[GrubCC, Grub, Systemd]); let cases = [ (Grub, None, 1, Bootupd(Grub)), (Grub, Some(&with_cc), 1, Bootupd(Grub)), @@ -2430,7 +2481,11 @@ mod tests { (GrubCC, Some(&grub_only), 1, Swap), (GrubCC, Some(&too_old), 1, Swap), (GrubCC, None, 1, Swap), + (Systemd, Some(&with_sd), 1, Bootupd(Systemd)), + (Systemd, Some(&with_sd), 2, Bootctl), (Systemd, Some(&with_cc), 1, Bootctl), + (Systemd, Some(&grub_only), 1, Bootctl), + (Systemd, Some(&too_old), 1, Bootctl), (Systemd, None, 1, Bootctl), ]; for (requested, bootupd, esps, expected) in cases { @@ -2445,6 +2500,7 @@ mod tests { use crate::bootloader::BootupdComponents::{Auto, Efi}; assert_eq!(Bootupd(Grub).bootupd_request(), Some((Grub, Auto))); assert_eq!(Bootupd(GrubCC).bootupd_request(), Some((GrubCC, Efi))); + assert_eq!(Bootupd(Systemd).bootupd_request(), Some((Systemd, Efi))); assert_eq!(Swap.bootupd_request(), Some((Grub, Efi))); assert_eq!(Bootctl.bootupd_request(), None); } diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 38ecb91b0..47ed367d6 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -555,9 +555,10 @@ pub(crate) fn install_systemd_boot( /// Stage Secure Boot keys on the ESP for systemd-boot's setup-mode enrollment. /// /// This is systemd-boot specific: the keys go in `loader/keys`, which only -/// systemd-boot reads. +/// systemd-boot reads. It is independent of *how* systemd-boot was installed, +/// so it runs for both the `bootctl` and the bootupd install paths. #[context("Writing Secure Boot enrollment keys")] -fn write_autoenroll_keys( +pub(crate) fn write_autoenroll_keys( prepared_root: &MountedImageRoot, autoenroll: Option, ) -> Result<()> { diff --git a/docs/src/bootc-bootloaders.7.md b/docs/src/bootc-bootloaders.7.md index 6cd22cb17..3d7572972 100644 --- a/docs/src/bootc-bootloaders.7.md +++ b/docs/src/bootc-bootloaders.7.md @@ -5,7 +5,7 @@ ## bootupd [bootupd](https://github.com/coreos/bootupd/) is a project explicitly designed to abstract over and manage bootloader installation and configuration. -Today it primarily supports GRUB+shim. There are pending patches for it to support systemd-boot as well. +On EFI, it installs GRUB with shim in front of it, and since 0.3.0 can install grub-cc and systemd-boot the same way. When you run `bootc install`, it invokes `bootupctl backend install` to install the bootloader to the target disk or filesystem. The specific bootloader configuration is determined by the container image and the target system's hardware. @@ -16,7 +16,75 @@ Currently, `bootc` only runs `bootupd` during the installation process. It does NOTE: systemd-boot is only supported for Composefs Backend and not for Ostree If bootupd is not present in the input container image, then systemd-boot will be used -by default (except on s390x). +by default (except on s390x). When bootupd is present, GRUB is the default; request +systemd-boot with `--bootloader systemd`, or with `bootloader = "systemd"` in the +`[install]` section of the install configuration. + +systemd-boot is installed one of two ways: + +- Through bootupd, with shim in front of it, when the image's bootupd can install it: + its `--bootloader` accepts `systemd` (bootupd 0.3.0 and newer), its update metadata + lists a systemd-boot component, and the target has a single ESP. The systemd-boot + package must ship the binary as a bootupd component, as Fedora's `systemd-boot-x64` + does since 262, and the metadata must be regenerated after installing it, because the + metadata of the base image does not list packages added later (see the example below). + The image must also ship shim; to install systemd-boot without shim, remove bootupd. +- Through `bootctl install` otherwise. This writes systemd-boot directly to + `EFI/BOOT/BOOT.EFI`, with no shim. bootc does not run `bootctl update` later. + Unless the image enables `systemd-boot-update.service` (Fedora and CentOS images + disable it), nothing updates that copy of systemd-boot. + +For example, a Fedora image for x86_64 adds the component with: + +```dockerfile +RUN dnf -y install systemd-boot-x64 && bootupctl backend generate-update-metadata +``` + +With shim in front of it, systemd-boot boots with the firmware's stock Secure Boot keys +as long as shim trusts the key systemd-boot is signed with, and fwupd can chain its UEFI +capsule updates through shim. Fedora's shim does not trust the key Fedora's +systemd-boot is signed with yet ([rhbz#2268695](https://bugzilla.redhat.com/show_bug.cgi?id=2268695)). +Until it does, either boot without Secure Boot, or enroll the certificate systemd-boot is +signed with (`fedora-signer-20250530` for systemd-boot 262) as a Machine Owner Key. +bootc images ship `mokutil` but not sbsigntools: run the first two commands in a container +of the image with sbsigntools installed, and the last one on the system itself. + +``` +sbattach --detach systemd-boot.p7 /usr/lib/efi/systemd-boot/*/EFI/fedora/grubx64.efi +openssl pkcs7 -inform DER -in systemd-boot.p7 -print_certs | openssl x509 -outform DER -out systemd-boot.der +mokutil --import systemd-boot.der +``` + +`mokutil --import` asks for a one-time password, which MokManager asks for again on the +next boot to complete the enrollment. This trusts only that certificate: should Fedora +sign a later systemd-boot with a different one, the system stops booting at shim once +`bootloader-update.service` installs it, unless that one is enrolled too. Without shim, +the firmware's `db` must trust the signers of systemd-boot and of the kernels or UKIs it +boots. + +Once bootupd has installed systemd-boot, it owns shim and systemd-boot on the ESP: +images that enable `bootloader-update.service` update them at boot, and every image +the system later updates or switches to must keep shipping the systemd-boot component. +The first bootloader update from an image without it that carries a newer shim removes +systemd-boot from the ESP, and the system stops booting at shim. + +Adding a systemd-boot component to an image also affects GRUB installs from it: + +- A bootupd that does not accept `--bootloader systemd` copies every component, so + systemd-boot replaces GRUB's second stage. Only add the component together with a + bootupd that accepts it. +- With both GRUB and systemd-boot components, the metadata has no default bootloader. + Tools that run `bootupctl backend install` without `--bootloader`, such as older + releases of bootc and the installer in bootc-image-builder's ISOs, then fail whenever + only bootupd's EFI component is targeted: on EFI-booted x86_64 machines unless + `--generic-image` is used, and on every aarch64 install. Run + `bootupctl backend set-default-bootloader grub` after generating the metadata to keep + GRUB their default; regenerating the metadata clears it again. bootc itself ignores + that default and installs the bootloader it is asked for. + +Secure Boot keys from `/usr/lib/bootc/install/secureboot-keys` are staged on the ESP on +both paths, as described under Secure Boot Keys in +[the install reference](man/bootc-install.8.md). ## s390x diff --git a/docs/src/bootc-experimental-composefs.7.md b/docs/src/bootc-experimental-composefs.7.md index d99217cb0..c3e8e9af9 100644 --- a/docs/src/bootc-experimental-composefs.7.md +++ b/docs/src/bootc-experimental-composefs.7.md @@ -270,7 +270,7 @@ See [CONTRIBUTING.md](https://github.com/bootc-dev/bootc/blob/main/CONTRIBUTING. Whenever the container image has a UKI, bootc automatically selects the composefs backend during installation (see [Prerequisites](#prerequisites) above for the currently-supported UKI + systemd-boot configuration for building sealed images). Note that having a UKI does not by itself make an install sealed — that also depends on whether fs-verity enforcement is on, per [Overview](#overview) above. -Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead of a UKI can enforce fs-verity, but are never sealed, since nothing authenticates the root digest. They can use either `bootupd` (GRUB) or systemd-boot, the same as the ostree backend. See [bootloaders.md](bootc-bootloaders.7.md) for the general bootloader selection rules. Under the hood, bootc writes standard BLS boot entries for both UKI and traditional kernels; see the [composefs boot module documentation](https://github.com/bootc-dev/bootc/blob/main/crates/lib/src/bootc_composefs/boot.rs) for details on how entry filenames and sort-keys are chosen to sort correctly on both GRUB and systemd-boot. +Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead of a UKI can enforce fs-verity, but are never sealed, since nothing authenticates the root digest. They can use GRUB, which bootupd installs as on the ostree backend, or systemd-boot, which bootupd installs with shim in front of it when the image's bootupd can and `bootctl` installs otherwise. See [bootloaders.md](bootc-bootloaders.7.md) for the general bootloader selection rules. Under the hood, bootc writes standard BLS boot entries for both UKI and traditional kernels; see the [composefs boot module documentation](https://github.com/bootc-dev/bootc/blob/main/crates/lib/src/bootc_composefs/boot.rs) for details on how entry filenames and sort-keys are chosen to sort correctly on both GRUB and systemd-boot. ## Installation diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index dbd576da3..cecac7cab 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -11,8 +11,9 @@ or virtualized), one needs a few key components: Bootloader installation depends on the platform and selected bootloader. For example, GRUB installation uses [bootupd](https://github.com/coreos/bootupd/), -while systemd-boot uses `bootctl` and s390x uses `zipl`. Bootloader installation -can also be disabled. The default expectation is that bootloader contents +and so does systemd-boot when the image's bootupd can install it, with `bootctl` +used otherwise, as described in [Bootloaders](bootc-bootloaders.7.md); s390x uses +`zipl`. Bootloader installation can also be disabled. The default expectation is that bootloader contents and install logic come from the container image in a `bootc` based system. The Linux kernel (and optionally initramfs) is embedded in the container image; the canonical location diff --git a/docs/src/man/bootc-install.8.md b/docs/src/man/bootc-install.8.md index 9b747f2f9..b2c183ecf 100644 --- a/docs/src/man/bootc-install.8.md +++ b/docs/src/man/bootc-install.8.md @@ -24,6 +24,15 @@ documents the command and its subcommands. When installing with `systemd-boot`, bootc can let `systemd-boot` can handle enrollment of Secure Boot keys by putting signed EFI signature lists in `/usr/lib/bootc/install/secureboot-keys` which will copy over into `ESP/loader/keys` after bootloader installation. The keys will be copied to `loader/keys` subdirectory of the ESP. after installing `systemd-boot` to the system. More information on how key enrollment works with `systemd-boot` is available in the [systemd-boot](https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392) man page. +The keys are staged whether bootc installs systemd-boot with `bootctl` or through +bootupd. Through bootupd, shim sits in front of systemd-boot, so the enrolled `db` must +also contain the Microsoft UEFI CA 2023, which signs shim, or the firmware may refuse to +start shim. The older Microsoft Corporation UEFI CA 2011 also starts current shim builds, +but bootupd 0.3.2 and newer refuse every bootloader update under Secure Boot without the +2023 CA, so neither shim nor systemd-boot is updated and `bootloader-update.service` fails. +Note that systemd-boot enrolls a key set named `auto` without asking when it runs in a +virtual machine in setup mode. + diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu index 3cb578880..1e69832e4 100644 --- a/tmt/tests/booted/test-multi-device-esp.nu +++ b/tmt/tests/booted/test-multi-device-esp.nu @@ -477,8 +477,9 @@ def main [] { return } - # This test exercises bootupd-based bootloader installation which only - # supports GRUB today. Skip when the image uses systemd-boot. + # systemd-boot and grub-cc read their entries from the ESP, and bootc writes + # those to the first ESP only, so the layout this test checks on every ESP + # does not apply to them. if (tap is_composefs) { let st = bootc status --json | from json let bootloader = $st.status.booted.composefs.bootloader | str downcase From 7eb4cacf02afa3d0bd0fe35ad6abc3e2949eaedd Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Tue, 29 Sep 2026 01:38:00 +0200 Subject: [PATCH 08/11] docs: Note bootupd images must generate update metadata bootc decides whether an image has bootupd by looking for the payload that `bootupctl backend generate-update-metadata` writes to /usr/lib/bootupd/updates, not only for the bootupctl binary. An image that installs bootupd but skips that build step is treated as having none: auto-detection picks systemd-boot, and an explicit --bootloader grub cannot install GRUB either, because bootupd has no payload to install. Depending on the bootupd version and the firmware, bootupd then fails, or skips its components and reports success without installing a bootloader. Neither outcome points at the missing build step, and the bootloader docs did not mention it. Say so where the bootupd flow is described. Closes: #2265 Generated-by: AI Signed-off-by: Christian Glombek --- docs/src/bootc-bootloaders.7.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/bootc-bootloaders.7.md b/docs/src/bootc-bootloaders.7.md index 3d7572972..2ad3bb239 100644 --- a/docs/src/bootc-bootloaders.7.md +++ b/docs/src/bootc-bootloaders.7.md @@ -9,6 +9,8 @@ On EFI, it installs GRUB with shim in front of it, and since 0.3.0 can install g When you run `bootc install`, it invokes `bootupctl backend install` to install the bootloader to the target disk or filesystem. The specific bootloader configuration is determined by the container image and the target system's hardware. +`bootc` considers bootupd present only when `bootupctl` is available to the installing environment and the image carries the update payload that `bootupctl backend generate-update-metadata` writes to `/usr/lib/bootupd/updates`, so an image that installs bootupd must run that command as part of its build. Without it, bootupd is not detected at all. Automatic selection then picks systemd-boot, and an explicit `--bootloader grub` cannot install GRUB either: depending on the bootupd version and the firmware, bootupd fails, or skips its components and reports success without installing a bootloader. + Currently, `bootc` only runs `bootupd` during the installation process. It does **not** automatically run `bootupctl update` to update the bootloader after installation. This means that bootloader updates must be handled separately, typically by the user or an automated system update process. ## systemd-boot From da09f66f800e25e3e2863593d864e9ac9dda4d9f Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 30 Sep 2026 12:31:06 +0200 Subject: [PATCH 09/11] tests: Check the ESP layout of systemd-boot installs The readonly suite verified that a composefs systemd-boot install boots, but not what ended up on the ESP, so the difference between a bare `bootctl install` and systemd-boot installed through bootupd -- whether shim is in the boot path -- went untested. Add one readonly test per layout, keyed on whether the image ships bootupd: the test images that ship it also ship a systemd-boot component and a bootupd that can install it, so bootc takes the bootupd path there and the bootctl path everywhere else. Without bootupd, systemd-boot itself must be the removable-media fallback and no shim may exist on the ESP. With it, the fallback must be shim, systemd-boot must sit under shim's second-stage name in the vendor directory, bootctl's own copy must be absent, and `bootupctl status` must report what bootupd installed. Both check that the running loader really is systemd-boot. The binaries are told apart by their embedded identifiers: systemd-boot's LoaderInfo string and shim's SBAT entry. Generated-by: AI Signed-off-by: Christian Glombek --- .../booted/readonly/055-test-sdboot-shim.nu | 61 +++++++++++++++++++ .../booted/readonly/056-test-sdboot-noshim.nu | 49 +++++++++++++++ tmt/tests/booted/tap.nu | 41 +++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tmt/tests/booted/readonly/055-test-sdboot-shim.nu create mode 100644 tmt/tests/booted/readonly/056-test-sdboot-noshim.nu diff --git a/tmt/tests/booted/readonly/055-test-sdboot-shim.nu b/tmt/tests/booted/readonly/055-test-sdboot-shim.nu new file mode 100644 index 000000000..fd63a7ccb --- /dev/null +++ b/tmt/tests/booted/readonly/055-test-sdboot-shim.nu @@ -0,0 +1,61 @@ +use std assert +use tap.nu + +# On a composefs install with an explicit request for systemd-boot, bootc +# installs it through bootupd when the image's bootupd can install it, which +# puts shim in front of systemd-boot under the second-stage name baked into +# shim. The test images that ship bootupd along with systemd-boot (the +# systemd-shim CI leg) guarantee such a bootupd. Verify that layout, and that +# bootupd recorded what it installed. + +tap begin "systemd-boot via bootupd keeps shim in the boot path" + +let st = bootc status --json | from json +let bootloader = ($st.status.booted.composefs?.bootloader? | default "" | str downcase) +if $bootloader != "systemd" { + print "Not a composefs systemd-boot install, skipping" + exit 0 +} +if not (tap image_ships_bootupd) { + print "Image ships no bootupd, so systemd-boot was installed by bootctl, skipping" + exit 0 +} + +let arch = (tap efi_arch) +let esp = (tap esp_mountpoint) +print $"ESP is mounted at ($esp)" + +# The removable-media fallback path is shim, the first stage. +let fallback = $"($esp)/EFI/BOOT/BOOT($arch | str upcase).EFI" +assert ($fallback | path exists) $"missing ($fallback)" +assert (tap is_shim $fallback) $"($fallback) is not shim" + +# shim lives in the vendor directory, next to the second stage it loads. +let shims = (glob $"($esp)/EFI/*/shim($arch).efi") +assert (($shims | length) == 1) $"expected exactly one vendor shim on the ESP, found ($shims)" +let vendor_dir = ($shims | first | path dirname) +print $"Vendor directory is ($vendor_dir)" + +# The second stage carries grub's name because that is what shim looks for, +# but it must be systemd-boot. +let second_stage = $"($vendor_dir)/grub($arch).efi" +assert ($second_stage | path exists) $"missing second stage ($second_stage)" +assert (tap is_systemd_boot $second_stage) $"($second_stage) is not systemd-boot" + +# bootctl install was not involved: it would have left its own copy behind. +let bootctl_copy = $"($esp)/EFI/systemd/systemd-boot($arch).efi" +assert (not ($bootctl_copy | path exists)) $"($bootctl_copy) exists, systemd-boot was installed by bootctl" + +# We actually booted through systemd-boot. +let bootctl = (do { ^bootctl } | complete) +assert ($bootctl.exit_code == 0) $"bootctl failed: ($bootctl.stderr)" +assert ($bootctl.stdout | str contains "Product: systemd-boot") "the running boot loader is not systemd-boot" + +# bootupd recorded the components it installed. +let status = (do { ^bootupctl status } | complete) +print $status.stdout +print $status.stderr +assert ($status.exit_code == 0) "bootupctl status failed" +assert ($status.stdout | str contains "systemd-boot") "bootupctl status does not report systemd-boot" + +tap ok diff --git a/tmt/tests/booted/readonly/056-test-sdboot-noshim.nu b/tmt/tests/booted/readonly/056-test-sdboot-noshim.nu new file mode 100644 index 000000000..56527d76e --- /dev/null +++ b/tmt/tests/booted/readonly/056-test-sdboot-noshim.nu @@ -0,0 +1,49 @@ +use std assert +use tap.nu + +# On a composefs install with systemd-boot and no bootupd in the image, +# bootctl installs systemd-boot directly as the removable-media fallback, with +# no shim anywhere in the boot path. Verify that layout. + +tap begin "systemd-boot without bootupd boots directly, without shim" + +let st = bootc status --json | from json +let bootloader = ($st.status.booted.composefs?.bootloader? | default "" | str downcase) +if $bootloader != "systemd" { + print "Not a composefs systemd-boot install, skipping" + exit 0 +} +if (tap image_ships_bootupd) { + print "Image ships bootupd, which the test images pair with a systemd-boot component, skipping" + exit 0 +} + +let arch = (tap efi_arch) +let esp = (tap esp_mountpoint) +print $"ESP is mounted at ($esp)" + +# Firmware loads systemd-boot itself from the removable-media fallback path. +let fallback = $"($esp)/EFI/BOOT/BOOT($arch | str upcase).EFI" +assert ($fallback | path exists) $"missing ($fallback)" +assert (tap is_systemd_boot $fallback) $"($fallback) is not systemd-boot" + +# bootctl also keeps its own copy under EFI/systemd. +let bootctl_copy = $"($esp)/EFI/systemd/systemd-boot($arch).efi" +assert ($bootctl_copy | path exists) $"missing ($bootctl_copy)" +assert (tap is_systemd_boot $bootctl_copy) $"($bootctl_copy) is not systemd-boot" + +# No shim, and no bootloader hiding under shim's second-stage name. vfat +# preserves case, so match either spelling of the suffix. +let shims = (glob $"($esp)/EFI/**/shim*.[eE][fF][iI]") +assert (($shims | length) == 0) $"found shim on the ESP: ($shims)" +let second_stages = (glob $"($esp)/EFI/*/grub($arch).[eE][fF][iI]") +assert (($second_stages | length) == 0) $"found a second stage binary on the ESP: ($second_stages)" +let binaries = (glob $"($esp)/EFI/**/*.[eE][fF][iI]" | where {|p| tap is_shim $p }) +assert (($binaries | length) == 0) $"found shim binaries on the ESP: ($binaries)" + +# We actually booted through systemd-boot. +let bootctl = (do { ^bootctl } | complete) +assert ($bootctl.exit_code == 0) $"bootctl failed: ($bootctl.stderr)" +assert ($bootctl.stdout | str contains "Product: systemd-boot") "the running boot loader is not systemd-boot" + +tap ok diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index cbfc52da7..ba695c3e0 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -19,6 +19,47 @@ export def is_composefs [] { $st.status.booted.composefs? != null } +# The architecture suffix used in EFI boot binary names, e.g. the "x64" in +# BOOTX64.EFI, shimx64.efi and grubx64.efi. +export def efi_arch [] { + let machine = (^uname -m | str trim) + match $machine { + "x86_64" => "x64", + "aarch64" => "aa64", + "riscv64" => "riscv64", + _ => { error make { msg: $"Unsupported EFI architecture: ($machine)" } } + } +} + +# Where the EFI system partition is mounted on the booted system: /boot for +# the systemd-boot layout, /boot/efi for GRUB. +export def esp_mountpoint [] { + ^bootctl --print-esp-path | str trim +} + +# Whether a (binary) file contains the given marker string. +export def file_contains [path: string, needle: string] { + (do { ^grep -qa $needle $path } | complete).exit_code == 0 +} + +# Whether an EFI binary is systemd-boot, by the marker bootctl itself keys on. +export def is_systemd_boot [path: string] { + file_contains $path "LoaderInfo: systemd-boot" +} + +# Whether an EFI binary is shim, by its SBAT entry. +export def is_shim [path: string] { + file_contains $path "UEFI shim" +} + +# Whether the image ships bootupd's update payload. bootc also needs that +# bootupd to accept --bootloader systemd and list a systemd-boot component +# before it installs systemd-boot through it; the test images that ship +# bootupd along with systemd-boot guarantee both. +export def image_ships_bootupd [] { + "/usr/lib/bootupd/updates" | path exists +} + # Return the EROFS format selected by the tmt configuration. Keep this in the # harness so derived UKI test images use the same default as the source image. export def selected_erofs_version [] { From 063ed3d80351ea576ed7786b789c8f9f272bcc46 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 2 Oct 2026 22:14:27 +0200 Subject: [PATCH 10/11] packaging: Let switch-to-sdboot keep bootupd and shim Every bootloader=systemd test image strips bootupd and shim, so the systemd-boot-through-bootupd path added earlier in this series has no image to run on. Add an SDBOOT_SHIM mode to switch-to-sdboot that keeps both packages and registers the distribution's signed systemd-boot with bootupd instead of installing our test-signed binary, since the distribution's binary is what an image would put behind shim. The package has to ship the binary laid out as a bootupd component under shim's second-stage name, which Fedora's systemd-boot- does since 262. The mode refuses to proceed otherwise, and refuses a bootupd that does not accept --bootloader systemd, rather than letting the image silently end up with shim alone or on the bootctl path; it probes the value because packaged 0.2.36 builds advertise the option but accept only grub. Thread it through the Justfile and Dockerfile as BOOTC_sdboot_shim. The fetch stage installs the systemd-boot package for the build's architecture and upgrades bootupd when it is set, also from updates-testing where that repository exists, because a stable release may carry the new layout only there at first (Fedora 45's main repository still has systemd-boot 261 with the old layout). In the default mode, also remove the distribution's signed systemd-boot- package when present, because bootctl would install its .efi.signed instead of our test-signed binary, and correct the mode's stated reason for removing bootupd, which the systemd-boot-through-bootupd change made stale. Generated-by: AI Signed-off-by: Christian Glombek --- CONTRIBUTING.md | 5 ++++ Dockerfile | 23 ++++++++++++++- Justfile | 7 +++++ contrib/packaging/switch-to-sdboot | 47 +++++++++++++++++++++++++----- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d8b5e6ea..9751d680c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,6 +154,7 @@ bootc has two storage backends: `ostree` (default, production) and `composefs` | `boot_type` | `bls`, `uki` | UKI embeds the composefs digest | | `seal_state` | `unsealed`, `sealed` | Sealed signs the UKI for Secure Boot | | `filesystem` | `ext4`, `btrfs`, `xfs` | xfs lacks fsverity, incompatible with sealed | +| `sdboot_shim` | unset, `1` | Keep bootupd and shim; the distro-signed systemd-boot is installed through bootupd | These are controlled via `BOOTC_`-prefixed environment variables. Using environment variables (rather than `just` command-line overrides) @@ -176,6 +177,10 @@ The constraints are: - `sealed` requires `boot_type=uki` (the digest lives in the UKI cmdline) - `sealed` requires `filesystem` with fsverity support (`ext4` or `btrfs`) - `uki` requires `bootloader=systemd` +- `sdboot_shim=1` requires `bootloader=systemd`, `seal_state=unsealed` and a + base whose bootupd accepts `--bootloader systemd` (0.3.0 or newer) and whose + signed `systemd-boot-` package is laid out as a bootupd component, such + as Fedora 45 and rawhide Common workflows: diff --git a/Dockerfile b/Dockerfile index 287e06904..70dc65bdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -141,6 +141,7 @@ ARG SKIP_CONFIGS ARG boot_type ARG seal_state ARG bootloader +ARG sdboot_shim="" # All network-fetching operations: package installs from distro repos, Copr, Koji. # Separated so `just build-fetch --target=fetch` can be retried independently on # transient network failures without re-running the configuration phase. @@ -172,6 +173,25 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ dnf install -y "${pkgs_to_install[@]}" fi + # systemd-boot behind shim goes through bootupd, which has to accept + # `--bootloader systemd` (bootupd 0.3.0 and newer), and needs the + # distribution's signed systemd-boot laid out as a bootupd component + # (Fedora's systemd-boot- since 262). A stable release may carry + # those only in updates-testing at first, so enable it where it exists. + if [[ -n "${sdboot_shim}" ]]; then + case "$(uname -m)" in + x86_64) sdboot_pkg=systemd-boot-x64 ;; + aarch64) sdboot_pkg=systemd-boot-aa64 ;; + *) echo "sdboot_shim is not supported on $(uname -m)" >&2; exit 1 ;; + esac + testing=() + if dnf repolist --all 2>/dev/null | grep -q '^updates-testing '; then + testing=(--enablerepo=updates-testing) + fi + dnf -y "${testing[@]}" install "${sdboot_pkg}" + dnf -y "${testing[@]}" upgrade bootupd + fi + # The grub-cc package ships its binary inside the grub2 component, e.g. # /usr/lib/efi/grub2//EFI/fedora/cc/grubx64-cc.efi, and bootupd only # installs grub-cc from a component of its own. So bootc asks bootupd for @@ -331,6 +351,7 @@ ARG variant ARG bootloader ARG boot_type ARG baseconfigs="" +ARG sdboot_shim="" # Switch to a signed systemd-boot, if configured RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ @@ -339,7 +360,7 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp set -xeuo pipefail if [[ "${bootloader}" == "systemd" ]]; then - /run/packaging/switch-to-sdboot /run/sdboot-signed + SDBOOT_SHIM="${sdboot_shim}" /run/packaging/switch-to-sdboot /run/sdboot-signed fi # Composefs test images are installed without --composefs-backend (see diff --git a/Justfile b/Justfile index afcb3a198..9eb696a7b 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,9 @@ filesystem := env("BOOTC_filesystem", "ext4") boot_type := env("BOOTC_boot_type", "bls") # Only used for composefs tests seal_state := env("BOOTC_seal_state", "unsealed") +# Only used for composefs systemd-boot tests: set to keep bootupd and shim in +# the image, so systemd-boot is installed through bootupd with shim in front +sdboot_shim := env("BOOTC_sdboot_shim", "") # Only used for composefs UKI tests: "v1" or "v2" erofs_version := env("BOOTC_erofs_version", "v1") # Baseconfigs to inject into the image for testing (e.g. "etc-transient" or "root-transient") @@ -78,6 +81,7 @@ base_buildargs := generic_buildargs + " " + _extra_src_args \ + " --build-arg=seal_state=" + seal_state \ + " --build-arg=filesystem=" + filesystem \ + " --build-arg=erofs_version=" + erofs_version \ + + " --build-arg=sdboot_shim=" + sdboot_shim \ + " --build-arg=baseconfigs=" + baseconfigs buildargs := base_buildargs \ + " --cap-add=all --security-opt=label=type:container_runtime_t --device /dev/fuse" \ @@ -170,6 +174,9 @@ list-variants: - The specified boot type (BLS/UKI) - The specified seal state (sealed/unsealed) determining whether we sign the UKI and use secure boot or not + - With BOOTC_sdboot_shim=1 (systemd-boot, unsealed, e.g. Fedora 45 and rawhide): keep + bootupd and shim, so the distro-signed systemd-boot is installed through bootupd with + shim in front Use `just build-sealed` as shortcut to build a sealed composefs image with systemd-boot as the bootloader diff --git a/contrib/packaging/switch-to-sdboot b/contrib/packaging/switch-to-sdboot index 6a6c130bc..c35bd8be3 100755 --- a/contrib/packaging/switch-to-sdboot +++ b/contrib/packaging/switch-to-sdboot @@ -3,23 +3,54 @@ # SRC should contain an "out" subdirectory with: # - systemd-boot-unsigned RPM (*.rpm) # - signed systemd-boot binary (systemd-boot*.efi) +# +# By default bootupd and shim are removed, so bootc installs systemd-boot +# with a bare `bootctl install`. With SDBOOT_SHIM=1 both are kept and the +# distribution's signed systemd-boot, which must already be laid out as a +# bootupd component (Fedora's systemd-boot-x64 since 262), is registered with +# bootupd, so that `bootc install --bootloader systemd` goes through bootupd +# and ends up with shim in front of systemd-boot. SRC is unused in that mode. set -xeuo pipefail src=$1/out shift -# systemd-boot (and, when sealed, the UKI) is signed and booted directly -# with our own Secure Boot key, so shim is never in the trust chain -# regardless of sealing. Uninstall bootupd (managed differently for -# sd-boot) and shim together so neither lingers in the image. case "$(uname -m)" in - x86_64) shim_pkg=shim-x64 ;; - aarch64) shim_pkg=shim-aa64 ;; - *) shim_pkg="" ;; + x86_64) efi_arch=x64 ;; + aarch64) efi_arch=aa64 ;; + *) efi_arch="" ;; esac +if [ -n "${SDBOOT_SHIM:-}" ]; then + [ -n "${efi_arch}" ] || { echo "SDBOOT_SHIM is not supported on $(uname -m)" >&2; exit 1; } + # bootc only hands systemd-boot to bootupd when bootupd accepts + # --bootloader systemd; with any other bootupd the image would silently take + # the bootctl path and this variant would test nothing. Packaged 0.2.36 + # builds advertise --bootloader but accept only grub, so probe the value: + # clap rejects it before acting on the --help after it. + command -v bootupctl >/dev/null || { echo "bootupd is not installed" >&2; exit 1; } + bootupctl backend install --bootloader systemd --help >/dev/null 2>&1 \ + || { echo "bootupd does not accept --bootloader systemd" >&2; exit 1; } + # The signed systemd-boot has to sit under the second-stage name baked into + # shim, inside the component's EFI directory; a package that ships it + # anywhere else is invisible to bootupd, which then installs shim alone. + found="" + for f in /usr/lib/efi/systemd-boot/*/EFI/*/grub"${efi_arch}".efi; do + [ -f "${f}" ] && found=${f} + done + [ -n "${found}" ] || { echo "no signed systemd-boot laid out as a bootupd component under /usr/lib/efi/systemd-boot" >&2; exit 1; } + bootupctl backend generate-update-metadata + exit 0 +fi + +# systemd-boot (and, when sealed, the UKI) is signed and booted directly +# with our own Secure Boot key, so shim is never in the trust chain +# regardless of sealing. Uninstall bootupd and shim together, so that bootc +# takes the bare `bootctl install` path (the shim-less layout this mode +# tests) and neither lingers in the image. Also uninstall the distribution's +# signed systemd-boot, if any: bootctl would prefer its .efi.signed over ours. pkgs_to_remove=() -for pkg in bootupd "${shim_pkg}"; do +for pkg in bootupd "${efi_arch:+shim-${efi_arch}}" "${efi_arch:+systemd-boot-${efi_arch}}"; do [ -n "${pkg}" ] || continue rpm -q "${pkg}" &>/dev/null && pkgs_to_remove+=("${pkg}") done From d7a899d8fdbbb7ad363f37f0470ebcf63e6d14db Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 30 Sep 2026 15:08:30 +0200 Subject: [PATCH 11/11] ci: Add a systemd-boot+shim integration leg No CI leg installs systemd-boot through bootupd: every bootloader=systemd test image drops bootupd and shim, so the bootctl path is all that runs. Add one composefs leg, unsealed BLS on ext4, whose image keeps bootupd and shim and registers the distribution's signed systemd-boot with bootupd. Its matrix value, systemd-shim, is the only one that is not a bootc bootloader: the job maps it to BOOTC_bootloader=systemd, which the image's install config hands to bootc, plus BOOTC_sdboot_shim=1. Fedora 45 and rawhide are the only tested OSes that ship a signed systemd-boot laid out as a bootupd component, so both join the integration OS list for this leg alone: everything else stays excluded there, the leg stays excluded everywhere else, and test-baseconfigs, which reads the same OS list, excludes them too. Rawhide is allowed to fail, as it already is in the package job; Fedora 45 is the leg that gates. Generated-by: AI Signed-off-by: Christian Glombek --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256f1a992..74386b4b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,8 @@ jobs: || echo "$LABELS" | jq -e 'index("ci/merge")' > /dev/null; }; then # Full suite: all OSes echo 'package_os_matrix=["fedora-44","fedora-45","fedora-46","centos-9","centos-10"]' >> "$GITHUB_OUTPUT" - echo 'integration_os_matrix=["fedora-44","centos-9","centos-10"]' >> "$GITHUB_OUTPUT" + # fedora-45 and fedora-46 only run the systemd-shim leg, see the excludes below + echo 'integration_os_matrix=["fedora-44","fedora-45","fedora-46","centos-9","centos-10"]' >> "$GITHUB_OUTPUT" echo 'upgrade_os_matrix=["fedora-44","centos-10"]' >> "$GITHUB_OUTPUT" echo 'run_heavy=true' >> "$GITHUB_OUTPUT" elif [[ "$DOCS_ONLY" != "true" ]] && echo "$LABELS" | jq -e 'index("ci/tier-1")' > /dev/null; then @@ -301,7 +302,7 @@ jobs: test_os: ${{ fromJson(needs.compute-ci-level.outputs.integration_os_matrix) }} variant: [ostree, composefs] filesystem: ["ext4", "xfs"] - bootloader: ["grub", "grub-cc", "systemd"] + bootloader: ["grub", "grub-cc", "systemd", "systemd-shim"] boot_type: ["bls", "uki"] seal_state: ["sealed", "unsealed"] @@ -349,7 +350,38 @@ jobs: - bootloader: grub-cc seal_state: sealed + # systemd-boot behind shim, installed through bootupd: one composefs + # leg, unsealed BLS on ext4, on Fedora 45 and rawhide only, the + # tested OSes that ship a signed systemd-boot laid out as a bootupd + # component (rawhide is allowed to fail, as in the package job). + - variant: ostree + bootloader: systemd-shim + - bootloader: systemd-shim + seal_state: sealed + - bootloader: systemd-shim + boot_type: uki + - bootloader: systemd-shim + filesystem: xfs + - test_os: fedora-44 + bootloader: systemd-shim + - test_os: centos-9 + bootloader: systemd-shim + - test_os: centos-10 + bootloader: systemd-shim + # and those two run nothing else + - test_os: fedora-45 + bootloader: grub + - test_os: fedora-45 + bootloader: systemd + - test_os: fedora-46 + bootloader: grub + - test_os: fedora-46 + bootloader: grub-cc + - test_os: fedora-46 + bootloader: systemd + runs-on: ubuntu-26.04 + continue-on-error: ${{ matrix.test_os == 'fedora-46' }} steps: - uses: actions/checkout@v7 @@ -371,7 +403,14 @@ jobs: echo "BOOTC_variant=${{ matrix.variant }}" >> $GITHUB_ENV echo "BOOTC_filesystem=${{ matrix.filesystem }}" >> $GITHUB_ENV - echo "BOOTC_bootloader=${{ matrix.bootloader }}" >> $GITHUB_ENV + bootloader="${{ matrix.bootloader }}" + if [[ "${bootloader}" == systemd-shim ]]; then + # Not a bootc bootloader: systemd-boot, which the image's install + # config requests, installed through bootupd with shim in front of it + bootloader=systemd + echo "BOOTC_sdboot_shim=1" >> $GITHUB_ENV + fi + echo "BOOTC_bootloader=${bootloader}" >> $GITHUB_ENV echo "BOOTC_boot_type=${{ matrix.boot_type }}" >> $GITHUB_ENV echo "BOOTC_seal_state=${{ matrix.seal_state }}" >> $GITHUB_ENV @@ -405,7 +444,7 @@ jobs: - name: Run TMT integration tests run: | if [[ "${{ matrix.variant }}" = composefs ]]; then - just test-composefs "${{ matrix.bootloader }}" "${{ matrix.filesystem }}" "${{ matrix.boot_type }}" "${{ matrix.seal_state }}" + just test-composefs "$BOOTC_bootloader" "${{ matrix.filesystem }}" "${{ matrix.boot_type }}" "${{ matrix.seal_state }}" else just test-tmt integration fi @@ -513,6 +552,9 @@ jobs: exclude: # centos-9 ships an older dracut that lacks the auto-install of setup-root-conf.toml - test_os: centos-9 + # fedora-45 and fedora-46 are in the integration OS list only for the systemd-shim leg + - test_os: fedora-45 + - test_os: fedora-46 runs-on: ubuntu-26.04