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..1e90336bd7 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 @@ -80,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