From fa4ece07de059ab49b2b1d6729de581440b354af Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 21:05:36 -0400 Subject: [PATCH 1/2] composefs: Keep the legacy composefs= karg in BLS entries Since ba10fb16fc ("composefs: Generate both V1 and V2 EROFS images on install"), BLS entries carry only `composefs.digest=v1-sha512-12:`. The initramfs of bootc 1.16 and earlier only starts bootc-root-setup.service when a karg named `composefs` is present (and before 1.16.3 only parses `composefs=`), so a BLS entry written by current bootc for an image that still carries such an initramfs fails switch-root and drops to the emergency shell. Unlike a UKI, whose kargs come from the image's own bootc, a BLS entry is written by the host's bootc, e.g. on `bootc switch` to an image that still ships bootc 1.16. No release has this yet (1.16.14 predates it). Write the legacy `composefs=` karg after `composefs.digest=`, with the same V1 digest. It has to be the deployment's own digest rather than the V2 one the dual-format UKI uses, because the old initramfs also derives the `state/deploy/` directory from it. Current parsers prefer `composefs.digest=`, and 1.16.4 through 1.16.14 already wrote V1 digests under `composefs=`, so this adds no new meaning to the bare karg. Generated-by: AI --- crates/lib/src/bootc_composefs/boot.rs | 34 ++++++------- crates/lib/src/bootc_composefs/status.rs | 63 ++++++++++++++++++++++++ docs/src/experimental-composefs.md | 17 +++++++ 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a10d41bf12..a42e3eab2a 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -92,14 +92,14 @@ use composefs_ctl::composefs; use composefs_ctl::composefs_boot; use composefs_ctl::composefs_oci; use fn_error_context::context; -use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; +use linux_kernel_cmdline::utf8::{Cmdline, ParameterKey}; use ostree_ext::composefs::dumpfile; use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; 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_composefs::status::build_bls_composefs_kargs; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -720,13 +720,11 @@ struct BLSEntryPath { config_path: Utf8PathBuf, } -/// Replace either karg spelling to ensure only the selected EROFS format remains. -fn replace_composefs_karg(cmdline: &mut Cmdline, new_karg: &str) -> Result<()> { +/// Replace both karg spellings with `new_kargs` (see [`build_bls_composefs_kargs`]). +fn replace_composefs_karg(cmdline: &mut Cmdline, new_kargs: &str) { cmdline.remove(&ParameterKey::from(KARG_V2)); cmdline.remove(&ParameterKey::from(KARG_COMPOSEFS_DIGEST)); - let parameter = Parameter::parse(new_karg).context("Parsing composefs kernel parameter")?; - cmdline.add_or_modify(¶meter); - Ok(()) + cmdline.extend(&Cmdline::from(new_kargs)); } /// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk @@ -758,7 +756,7 @@ pub(crate) fn setup_composefs_bls_boot( } let composefs_cmdline = - build_composefs_karg(id.clone(), format_version, allow_missing_fsverity); + build_bls_composefs_kargs(id.clone(), format_version, allow_missing_fsverity); cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); // If there's a separate /boot partition, add a systemd.mount-extra @@ -807,12 +805,12 @@ pub(crate) fn setup_composefs_bls_boot( replace_composefs_karg( &mut cmdline, - &build_composefs_karg( + &build_bls_composefs_kargs( id.clone(), format_version, booted_cfs.cmdline.allow_missing_fsverity, ), - )?; + ); // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; @@ -2277,17 +2275,17 @@ mod tests { #[test] fn test_replace_composefs_karg() { + let hex = "f".repeat(128); let mut cmdline = - Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale"); + Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale rw"); replace_composefs_karg( &mut cmdline, - "composefs.digest=v1-sha512-12:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ) - .unwrap(); - let rendered = cmdline.to_string(); - assert!(!rendered.contains("composefs=old")); - assert!(!rendered.contains(":stale")); - assert!(rendered.contains("root=UUID=abc")); + &format!("composefs.digest=v1-sha512-12:{hex} composefs={hex}"), + ); + assert_eq!( + cmdline.to_string(), + format!("root=UUID=abc rw composefs.digest=v1-sha512-12:{hex} composefs={hex}") + ); } #[test] diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 512608d824..03d780de41 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -142,6 +142,37 @@ pub(crate) fn build_composefs_karg( .to_cmdline_arg() } +/// Render the composefs kargs for a Type 1 BLS entry, which (unlike a UKI) +/// may be written by a newer bootc than the one in the target's initramfs. +/// +/// For a V1 digest this is `composefs.digest=v1-...` followed by the legacy +/// `composefs=` key carrying the *same* digest: the initramfs of bootc 1.16 +/// and earlier only starts `bootc-root-setup.service` when a karg named +/// `composefs` is present, and before 1.16.3 it only parses `composefs=`. +/// The digest must stay the deployment's own, since the initramfs also uses +/// it to find `state/deploy/`. Current parsers (the initramfs and +/// [`ComposefsCmdline::find_in_cmdline`]) prefer `composefs.digest=`, and +/// bootc 1.16.4 through 1.16.14 already wrote V1 digests under `composefs=`. +/// +/// The legacy karg can be dropped once bootc no longer needs to write boot +/// entries for images whose initramfs predates `composefs.digest=` support in +/// `bootc-root-setup.service` (i.e. 1.16.x and older are out of support). +pub(crate) fn build_bls_composefs_kargs( + digest: Sha512HashValue, + format_version: FormatVersion, + allow_missing_fsverity: bool, +) -> String { + let karg = build_composefs_karg(digest.clone(), format_version, allow_missing_fsverity); + match format_version { + FormatVersion::V2 => karg, + FormatVersion::V0 | FormatVersion::V1 => { + let legacy = + BootComposefsCmdline::new_v2(digest, allow_missing_fsverity).to_cmdline_arg(); + format!("{karg} {legacy}") + } + } +} + impl std::fmt::Display for ComposefsCmdline { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" }; @@ -1198,6 +1229,38 @@ mod tests { Ok(()) } + #[test] + fn test_build_bls_composefs_kargs() -> Result<()> { + let hex = "ab".repeat(64); + let digest = || Sha512HashValue::from_hex(&hex).unwrap(); + + let cases = [ + ( + FormatVersion::V1, + false, + format!("composefs.digest=v1-sha512-12:{hex} composefs={hex}"), + ), + ( + FormatVersion::V1, + true, + format!("composefs.digest=?v1-sha512-12:{hex} composefs=?{hex}"), + ), + (FormatVersion::V2, false, format!("composefs={hex}")), + (FormatVersion::V2, true, format!("composefs=?{hex}")), + ]; + for (format_version, insecure, expected) in cases { + let kargs = build_bls_composefs_kargs(digest(), format_version, insecure); + assert_eq!(kargs, expected); + // Current parsers still resolve the deployment from either form. + let parsed = ComposefsCmdline::find_in_cmdline(&Cmdline::from(kargs))? + .expect("composefs argument should be present"); + assert_eq!(parsed.digest.as_ref(), hex); + assert_eq!(parsed.allow_missing_fsverity, insecure); + } + + Ok(()) + } + #[test] fn classify_bootloader_cases() { struct Case { diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index 4218d016f4..f505865682 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -62,6 +62,23 @@ So bootc accepts a bare `composefs=` digest that matches either a V1 or a V2 image, and only enforces the format for the explicit `composefs.digest=v1-…`/`composefs.digest=v2-…` form. +### Boot entries (BLS) + +For a Type 1 (BLS) boot entry, bootc writes the kernel arguments itself, and +the image it writes them for may carry an initramfs from an older bootc, for +example after `bootc switch` to an image that still ships bootc 1.16. That +initramfs only runs its root setup when an argument named `composefs` is +present, and before 1.16.3 it only reads `composefs=`. So for a V1 deployment +bootc writes `composefs.digest=v1-sha512-12:` followed by +`composefs=` with the **same V1 digest**, which also names the +deployment's state directory. + +This differs from UKIs, where the bare `composefs=` argument carries the V2 +digest, and from composefs-rs's documentation, which defines bare +`composefs=` as an alias for V2. Current initramfs versions try both +arguments in order; for a BLS entry both name the same image, and +`composefs.digest=` comes first. + ### Upgrading from bootc 1.16 When you update bootc in an image, **regenerate the initramfs before From 4fc7edead675fe3670e36050c55e5520ed094dc4 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 21:36:26 -0400 Subject: [PATCH 2/2] tests: Boot a BLS entry for an image with a bootc 1.16 initramfs Nothing covered a host bootc writing the boot entry for an image whose initramfs predates `composefs.digest=`, which is how the previous commit's bug went unnoticed until bootupd's e2e test (which updates bootc without regenerating the initramfs) stopped booting. Simulate that initramfs by restoring the 1.16 unit condition and regenerating it, then switch to it and reboot. Generated-by: AI --- docs/src/experimental-composefs.md | 6 +- tmt/plans/integration.fmf | 8 ++ .../booted/test-composefs-legacy-initramfs.nu | 77 +++++++++++++++++++ tmt/tests/tests.fmf | 5 ++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tmt/tests/booted/test-composefs-legacy-initramfs.nu diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index f505865682..1e90336bd7 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -97,8 +97,10 @@ moves the system to V1. bootc 1.16.4 and later already understand The 1.16.0 path is covered by the `test-49-composefs-1-16-bridge` TMT test for both sealed and `--allow-missing-verity` UKIs, including rollback and garbage -collection. Upgrades from other releases, and from BLS (non-UKI) composefs -installs, are not yet tested. +collection. Upgrades from other releases are not yet tested. For BLS +(non-UKI) installs, `test-56-composefs-legacy-initramfs` covers a current +bootc switching to an image whose initramfs has the bootc 1.16 unit +condition; upgrades by a 1.16 client are not yet tested. ## Storage and repository structure diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 06dadb2cf3..98e940f40a 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -331,4 +331,12 @@ execute: enabled: true extra-try_bind_storage: true extra-skip_if_ostree: true + +/plan-56-composefs-legacy-initramfs: + summary: Boot a BLS entry for an image whose initramfs predates composefs.digest= + discover: + how: fmf + test: + - /tmt/tests/tests/test-56-composefs-legacy-initramfs + extra-skip_if_ostree: true # END GENERATED PLANS diff --git a/tmt/tests/booted/test-composefs-legacy-initramfs.nu b/tmt/tests/booted/test-composefs-legacy-initramfs.nu new file mode 100644 index 0000000000..85ad25338e --- /dev/null +++ b/tmt/tests/booted/test-composefs-legacy-initramfs.nu @@ -0,0 +1,77 @@ +# number: 56 +# tmt: +# summary: Boot a BLS entry for an image whose initramfs predates composefs.digest= +# duration: 30m +# extra: +# skip_if_ostree: true +# +# In bootc 1.16 initramfs images, bootc-root-setup.service only starts when +# a karg named `composefs` is present. When a newer bootc writes the BLS +# entry for such an image (e.g. `bootc switch` to an image still shipping +# bootc 1.16), it must emit the legacy `composefs=` karg next to +# `composefs.digest=`, or the target drops to the emergency shell. +# Simulate that initramfs by rebuilding it with the 1.16 unit condition. +use std assert +use tap.nu +use bootc_testlib.nu + +const legacy_image = "localhost/bootc-legacy-initramfs" +const unit = "usr/lib/systemd/system/bootc-root-setup.service" +const legacy_condition = "ConditionKernelCommandLine=composefs" + +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def first_boot [] { + tap begin "switch to an image whose initramfs only knows composefs=" + + bootc image copy-to-storage + + r#' + FROM localhost/bootc + RUN sed -i -e '/^ConditionKernelCommandLine=/d' \ + -e '/^\[Unit\]/a ConditionKernelCommandLine=composefs' \ + /usr/lib/systemd/system/bootc-root-setup.service + RUN set -x; kver=$(cd /usr/lib/modules && echo *); \ + dracut -vf --add bootc /usr/lib/modules/$kver/initramfs.img $kver + '# | podman build -t $legacy_image . -f - + + # Check the simulation took: the initramfs must carry only the 1.16 condition. + let conditions = podman run --rm $legacy_image bash -c $"lsinitrd -f ($unit) /usr/lib/modules/*/initramfs.img" + | lines | where { |l| $l starts-with "ConditionKernelCommandLine=" } + assert equal $conditions [$legacy_condition] + + bootc switch --transport containers-storage $legacy_image + + tmt-reboot +} + +def second_boot [] { + assert equal $booted.image.image $legacy_image + + let cmdline = bootc_testlib parse_cmdline + let verity = $st.status.booted.composefs.verity + assert ($cmdline | any { |k| $k starts-with "composefs.digest=" and ($k | str ends-with $verity) }) + assert ($cmdline | any { |k| $k == $"composefs=($verity)" or $k == $"composefs=?($verity)" }) + + let root = findmnt --json --mountpoint / --output SOURCE | from json + let root_source = ($root.filesystems | first | get source | into string) + assert ($root_source | str contains $verity) $"root not mounted from composefs: ($root_source)" + + tap ok +} + +def main [] { + # A UKI carries its own kargs, built with the image's bootc; only BLS + # entries are written by the (possibly newer) host bootc. + if ($st.status.booted.composefs.bootType | str downcase) == "uki" { + print "# Skipping: UKI boot" + return + } + + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index f27e9addd5..117ec301b5 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -206,3 +206,8 @@ check: - when: composefs_bridge == true enabled: true test: nu booted/test-49-composefs-1-16-bridge.nu + +/test-56-composefs-legacy-initramfs: + summary: Boot a BLS entry for an image whose initramfs predates composefs.digest= + duration: 30m + test: nu booted/test-composefs-legacy-initramfs.nu