diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256f1a9922..74386b4b4c 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d8b5e6ea1..9751d680cc 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 a36bc56388..70dc65bdbc 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,13 +173,30 @@ 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 + # 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 + # 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 @@ -333,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 \ @@ -341,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 afcb3a1984..9eb696a7b1 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 6a6c130bcf..c35bd8be3a 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 diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 1906efa025..75e63585f2 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"); diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da8..4f7cc521fc 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; @@ -2087,6 +2088,106 @@ 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. That writes systemd-boot to + /// `EFI/BOOT/BOOT.EFI`, so firmware loads it directly and shim is + /// never in the boot path. + 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. +/// +/// systemd-boot goes through bootupd whenever it can, since bootupd installs +/// shim in front of it. That lets it boot with the firmware's stock Secure Boot +/// keys, provided shim trusts systemd-boot's signer, and gives fwupd's UEFI +/// capsule updates the shim they chain through. Note `requested` is only +/// `Systemd` when it was asked for explicitly: [`PostFetchState::new`] picks +/// `Grub` whenever bootupd is present. +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::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, + } +} + #[context("Setting up composefs boot")] pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, @@ -2130,16 +2231,32 @@ 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 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 | Bootloader::Systemd 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 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 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 @@ -2150,16 +2267,32 @@ 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, &state.config_opts, Some(chroot_target), 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 @@ -2201,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( @@ -2290,6 +2444,67 @@ 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 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, Systemd]); + let too_old = caps(&too_old, &[GrubCC, Grub, Systemd]); + 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_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 { + 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!(Bootupd(Systemd).bootupd_request(), Some((Systemd, 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 08a208d281..47ed367d6b 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,13 +97,117 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } -/// Check whether the target bootupd supports `--filesystem`. +/// 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. +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| help_line_flags(line).any(|f| f == flag)) +} + +/// 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: ...]`. /// -/// 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 { - let help_args = ["bootupctl", "backend", "install", "--help"]; +/// 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", "-h"]; let output = if let Some(target_root) = chroot_target { ChrootCmd::new(target_root) .set_default_path() @@ -112,16 +218,112 @@ fn bootupd_supports_filesystem(chroot_target: Option<&Utf8Path>) -> Result .log_debug() .run_get_string()? }; + Ok(output) +} - let use_filesystem = output.contains("--filesystem"); +/// What the target bootupd's `backend install` accepts, from its help. +#[derive(Debug, Clone, PartialEq, Eq)] +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 + /// 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, +} - if use_filesystem { - tracing::debug!("bootupd supports --filesystem"); - } else { - tracing::debug!("bootupd does not support --filesystem, falling back to --device"); +impl BootupdInstallSupport { + /// Parse the help of `bootupctl backend install`. bootupd's `--bootloader` + /// values are the names [`crate::spec::Bootloader`]'s `Display` produces. + 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] + .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`]. + 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. +/// +/// 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.accepts(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, +} - Ok(use_filesystem) +/// 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. @@ -144,6 +346,18 @@ fn bootupd_supports_filesystem(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. +/// +/// `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 +/// 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, @@ -151,10 +365,23 @@ 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, + support: Option<&BootupdInstallSupport>, ) -> 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, 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`]). @@ -180,8 +407,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 @@ -190,9 +420,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 support.filesystem { None } else { Some(device.require_single_root()?.path()) @@ -321,41 +549,55 @@ 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. 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")] +pub(crate) 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(()) @@ -475,6 +717,179 @@ 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 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 = [ + (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_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}; + // 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 0000000000..874746499d --- /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 0000000000..9ecc48e69c --- /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 0000000000..eb5fb31ae9 --- /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 8f9f80fc47..3dab451c29 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2012,6 +2012,9 @@ 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, + None, )?; } Bootloader::Systemd | Bootloader::GrubCC => { @@ -2504,7 +2507,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 @@ -2517,6 +2525,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(()) } @@ -3276,6 +3287,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")?; @@ -3284,6 +3296,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/docs/src/bootc-bootloaders.7.md b/docs/src/bootc-bootloaders.7.md index 6cd22cb17d..2ad3bb239c 100644 --- a/docs/src/bootc-bootloaders.7.md +++ b/docs/src/bootc-bootloaders.7.md @@ -5,10 +5,12 @@ ## 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. +`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 @@ -16,7 +18,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 d99217cb0d..c3e8e9af96 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 dbd576da34..cecac7cabf 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 9b747f2f96..b2c183ecf9 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/readonly/055-test-sdboot-shim.nu b/tmt/tests/booted/readonly/055-test-sdboot-shim.nu new file mode 100644 index 0000000000..fd63a7ccbf --- /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 0000000000..56527d76ee --- /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 cbfc52da79..ba695c3e03 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 [] { diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu index acf5bff4ff..1e69832e45 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 @@ -471,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