From 621e1419daad07e469e2487c4f77a4b8812b2fe7 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 6 Mar 2026 22:47:44 +0000 Subject: [PATCH 1/3] build-sys: Re-seal upgrade image for composefs UKI builds The upgrade test image (localhost/bootc-upgrade) was previously a simple one-layer addition on top of localhost/bootc that did not go through the sealing pipeline. This meant sealed composefs builds could not properly test upgrades, since the upgrade image lacked a signed UKI with the correct composefs digest. Rework Dockerfile.upgrade into a multi-stage build that mirrors the main Dockerfile sealing flow: when boot_type=uki, it computes the composefs digest of the upgrade rootfs, generates and optionally signs a UKI via seal-uki, and finalizes it with finalize-uki. For non-UKI builds, the extra stages are effectively no-ops and the image remains a simple derived layer. Update _build-upgrade-image in the Justfile to pass the required build arguments (boot_type, seal_state, filesystem) and build secrets (secureboot keys). Extra container capabilities (CAP_ALL, fuse device) are only added for UKI builds that need composefs support. Assisted-by: OpenCode (claude-opus-4) Signed-off-by: Colin Walters --- Justfile | 21 ++++++++++-- tmt/tests/Dockerfile.upgrade | 65 ++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index 76b63389a..f326612ef 100644 --- a/Justfile +++ b/Justfile @@ -141,7 +141,7 @@ test-composefs bootloader filesystem boot_type seal_state *ARGS: --seal-state={{seal_state}} \ --boot-type={{boot_type}} \ {{ARGS}} \ - $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly"; else echo "integration"; fi) + $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly composefs-upgrade"; else echo "integration"; fi) # Run upgrade test: boot VM from published base image (with tmt deps added), # upgrade to locally-built image, reboot, then run readonly tests to verify. @@ -362,7 +362,24 @@ _keygen: ./hack/generate-secureboot-keys _build-upgrade-image: - cat tmt/tests/Dockerfile.upgrade | podman build -t {{upgrade_img}} --from={{base_img}} - + #!/bin/bash + set -xeuo pipefail + # Secrets are always available (test-tmt depends on build which runs _keygen). + # Extra capabilities are only needed for UKI builds (composefs + fuse). + extra_args=() + if [ "{{boot_type}}" = "uki" ]; then + extra_args+=(--cap-add=all --security-opt=label=type:container_runtime_t --device /dev/fuse) + fi + podman build \ + --build-arg "boot_type={{boot_type}}" \ + --build-arg "seal_state={{seal_state}}" \ + --build-arg "filesystem={{filesystem}}" \ + --secret=id=secureboot_key,src=target/test-secureboot/db.key \ + --secret=id=secureboot_cert,src=target/test-secureboot/db.crt \ + "${extra_args[@]}" \ + -t {{upgrade_img}} \ + -f tmt/tests/Dockerfile.upgrade \ + . # Build the upgrade source image: base image + tmt dependencies (rsync, nu, cloud-init) _build-upgrade-source-image: diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index a9e36ba50..561e2e0a7 100644 --- a/tmt/tests/Dockerfile.upgrade +++ b/tmt/tests/Dockerfile.upgrade @@ -1,3 +1,62 @@ -# Just creates a file as a new layer for a synthetic upgrade test -FROM localhost/bootc -RUN touch --reference=/usr/bin/bash /usr/share/testing-bootc-upgrade-apply +# Creates a synthetic upgrade image for testing. +# For non-UKI builds, this just adds a marker file on top of localhost/bootc. +# For UKI builds (boot_type=uki), the image is re-sealed with a new composefs +# digest and (optionally signed) UKI. +# +# Build secrets required (for sealed builds): +# secureboot_key, secureboot_cert +ARG boot_type=bls +ARG seal_state=unsealed +ARG filesystem=ext4 + +# Capture contrib/packaging scripts for use in later stages +FROM scratch AS packaging +COPY contrib/packaging / + +# Create the upgrade content (a simple marker file). +# For UKI builds, we also remove the existing UKI so that seal-uki can +# regenerate it with the correct composefs digest for this derived image. +FROM localhost/bootc AS upgrade-base +ARG boot_type +RUN touch --reference=/usr/bin/bash /usr/share/testing-bootc-upgrade-apply && \ + if test "${boot_type}" = "uki"; then rm -rf /boot/EFI/Linux/*.efi; fi + +# Tools for sealing (only meaningfully used for UKI builds) +FROM localhost/bootc AS tools +RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=bind,from=packaging,src=/,target=/run/packaging \ + /run/packaging/initialize-sealing-tools + +# Generate a sealed UKI for the upgrade image. +# bootc is already installed in localhost/bootc (our tools base); the +# container ukify command it provides is needed for seal-uki. +FROM tools AS sealed-upgrade-uki +ARG boot_type seal_state filesystem +RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=secret,id=secureboot_key \ + --mount=type=secret,id=secureboot_cert \ + --mount=type=bind,from=packaging,src=/,target=/run/packaging \ + --mount=type=bind,from=upgrade-base,src=/,target=/run/target < Date: Fri, 6 Mar 2026 22:47:56 +0000 Subject: [PATCH 2/3] tests: Add sealed composefs case for upgrades The goal is ensuring we have upgrade coverage also for sealed UKIs; most of the other update code paths (because tmt doesn't make it easy to have a registry) do on-machine synthetic updates. Assisted-by: OpenCode (claude-opus-4) Signed-off-by: Colin Walters --- Justfile | 2 +- tmt/tests/booted/test-image-upgrade-reboot.nu | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index f326612ef..836501ca1 100644 --- a/Justfile +++ b/Justfile @@ -141,7 +141,7 @@ test-composefs bootloader filesystem boot_type seal_state *ARGS: --seal-state={{seal_state}} \ --boot-type={{boot_type}} \ {{ARGS}} \ - $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly composefs-upgrade"; else echo "integration"; fi) + $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly image-upgrade-reboot"; else echo "integration"; fi) # Run upgrade test: boot VM from published base image (with tmt deps added), # upgrade to locally-built image, reboot, then run readonly tests to verify. diff --git a/tmt/tests/booted/test-image-upgrade-reboot.nu b/tmt/tests/booted/test-image-upgrade-reboot.nu index 676605658..8e812880c 100644 --- a/tmt/tests/booted/test-image-upgrade-reboot.nu +++ b/tmt/tests/booted/test-image-upgrade-reboot.nu @@ -11,6 +11,10 @@ # bootc switch --apply # Verify we boot into the new image # +# For composefs builds, it additionally verifies that composefs is +# still active after upgrade. For sealed UKI builds, it checks that +# both the original and upgrade UKIs exist on the ESP. +# use std assert use tap.nu @@ -21,6 +25,7 @@ journalctl --list-boots let st = bootc status --json | from json let booted = $st.status.booted.image +let is_composefs = (tap is_composefs) # Parse the kernel commandline into a list. # This is not a proper parser, but good enough @@ -50,6 +55,12 @@ RUN touch /usr/share/testing-bootc-upgrade-apply podman build -t $imgsrc . } + # For composefs, save state so we can verify it's preserved after upgrade. + if $is_composefs { + "true" | save /var/was-composefs + $st.status.booted.composefs.verity | save /var/original-verity + } + # Now, switch into the new image print $"Applying ($imgsrc)" bootc switch --transport containers-storage ($imgsrc) @@ -63,7 +74,47 @@ def second_boot [] { assert equal $booted.image.image $"(imgsrc)" # Verify the new file exists - "/usr/share/testing-bootc-upgrade-apply" | path exists + assert ("/usr/share/testing-bootc-upgrade-apply" | path exists) "upgrade marker file should exist" + + # If the previous boot was composefs, verify composefs survived the upgrade + let was_composefs = ("/var/was-composefs" | path exists) + if $was_composefs { + assert $is_composefs "composefs should still be active after upgrade" + + let composefs_info = $st.status.booted.composefs + print $"composefs info: ($composefs_info)" + + assert (($composefs_info.verity | str length) > 0) "composefs verity digest should be present" + + # For UKI boot type, verify both the original and upgrade UKIs exist on the ESP + if ($composefs_info.bootType | str downcase) == "uki" { + let bootloader = ($composefs_info.bootloader | str downcase) + + let boot_dir = if $bootloader == "systemd" { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + "/var/tmp/efi/EFI/Linux/bootc" + } else { + "/sysroot/boot/EFI/Linux/bootc" + } + + let original_verity = (open /var/original-verity | str trim) + let upgrade_verity = $composefs_info.verity + + print $"boot_dir: ($boot_dir)" + print $"original verity: ($original_verity)" + print $"upgrade verity: ($upgrade_verity)" + + # The two verities must differ since the upgrade image has different content + assert ($original_verity != $upgrade_verity) "upgrade should produce a different verity digest" + + # There should be two .efi UKI files on the ESP: one for the booted + # deployment (upgrade) and one for the rollback (original) + let efi_files = (glob $"($boot_dir)/*.efi") + print $"EFI files: ($efi_files)" + assert (($efi_files | length) >= 2) $"expected at least 2 UKIs on ESP, found ($efi_files | length)" + } + } tap ok } From cb07c2e0d9decc5fefaf629d64c399fd3d6c4b6a Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 30 Mar 2026 19:33:12 +0000 Subject: [PATCH 3/3] install: Add discoverable-partitions config option Right now we default to DPS for composefs + systemd-boot. In Fedora 43+, GRUB has the `bli` module and supports this, so it *can* be used there. Make this configurable (mainly intended for base image builders) so that those with new enough GRUB can flip it on by default. We had a hacky thing here that removed the auto-injected `root=` arg if we detected composefs + sdboot; that can now instead flip on this flag, and then we ensure we don't inject it at all. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- contrib/packaging/install-rpm-and-setup | 11 +++++ crates/lib/src/bootc_composefs/boot.rs | 8 +--- crates/lib/src/install/baseline.rs | 41 +++++++++++++++++-- crates/lib/src/install/config.rs | 39 ++++++++++++++++++ docs/src/man/bootc-install-config.5.md | 13 ++++++ docs/src/man/bootc-install-to-disk.8.md | 28 +++++++++---- .../booted/readonly/052-test-bli-detection.nu | 30 ++++++++++++++ 7 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 tmt/tests/booted/readonly/052-test-bli-detection.nu diff --git a/contrib/packaging/install-rpm-and-setup b/contrib/packaging/install-rpm-and-setup index 91dab9796..02531bdb4 100755 --- a/contrib/packaging/install-rpm-and-setup +++ b/contrib/packaging/install-rpm-and-setup @@ -21,5 +21,16 @@ env DRACUT_NO_XATTR=1 dracut --add bootc -vf /usr/lib/modules/$kver/initramfs.im # tests to know we're doing upstream CI. touch /usr/lib/.bootc-dev-stamp +# Fedora 43+ ships a GRUB with the BLI module, so enable DPS +# auto-discovery for root. This must run after our RPM is installed +# since older bootc doesn't recognize the discoverable-partitions key. +. /usr/lib/os-release +if [ "${ID}" = "fedora" ] && [ "${VERSION_ID}" -ge 43 ] 2>/dev/null; then + cat > /usr/lib/bootc/install/20-discoverable-partitions.toml <<'EOF' +[install] +discoverable-partitions = true +EOF +fi + # Workaround for https://github.com/bootc-dev/bootc/issues/1546 rm -rf /root/buildinfo /var/roothome/buildinfo diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 646df9ff0..5db2005d6 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -67,7 +67,7 @@ use std::io::Write; use std::path::Path; use anyhow::{Context, Result, anyhow, bail}; -use bootc_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; +use bootc_kernel_cmdline::utf8::{Cmdline, Parameter}; use bootc_mount::tempmount::TempMount; use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::{ @@ -582,12 +582,6 @@ pub(crate) fn setup_composefs_bls_boot( } }; - // Remove "root=" from kernel cmdline as systemd-auto-gpt-generator should use DPS - // UUID - if bootloader == Bootloader::Systemd { - cmdline_refs.remove(&ParameterKey::from("root")); - } - let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); let current_root = if is_upgrade { diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 9b393b467..8a32e98c9 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -34,6 +34,31 @@ use bootc_kernel_cmdline::utf8::Cmdline; #[cfg(feature = "install-to-disk")] use bootc_mount::is_mounted_in_pid1_mountns; +/// Check whether DPS auto-discovery is enabled. When `true`, +/// `root=UUID=` is omitted and `systemd-gpt-auto-generator` discovers +/// the root partition via its DPS type GUID instead. +/// +/// Defaults to `true` for systemd-boot (which always implements BLI). +/// For GRUB the default is `false` because we cannot know at install +/// time whether the GRUB build includes the `bli` module — the module +/// is baked into the signed EFI binary with no external manifest. +/// Distros shipping a BLI-capable GRUB should set +/// `discoverable-partitions = true` in their install config. +#[cfg(feature = "install-to-disk")] +fn use_discoverable_partitions(state: &State) -> bool { + // Explicit config takes priority + if let Some(ref config) = state.install_config { + if let Some(v) = config.discoverable_partitions { + return v; + } + } + // systemd-boot always supports BLI + matches!( + state.config_opts.bootloader, + Some(crate::spec::Bootloader::Systemd) + ) +} + // This ensures we end up under 512 to be small-sized. pub(crate) const BOOTPN_SIZE_MB: u32 = 510; pub(crate) const EFIPN_SIZE_MB: u32 = 512; @@ -226,10 +251,15 @@ pub(crate) fn install_create_rootfs( }; let serial = device.serial.as_deref().unwrap_or(""); let model = device.model.as_deref().unwrap_or(""); + let discoverable = use_discoverable_partitions(state); println!("Block setup: {block_setup}"); println!(" Size: {}", device.size); println!(" Serial: {serial}"); println!(" Model: {model}"); + println!( + " Partitions: {}", + if discoverable { "Discoverable" } else { "UUID" } + ); let root_size = opts .root_size @@ -415,7 +445,6 @@ pub(crate) fn install_create_rootfs( opts.wipe, mkfs_options.iter().copied(), )?; - let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}")); let boot = bootsrc.map(|bootsrc| MountSpec { @@ -434,8 +463,14 @@ pub(crate) fn install_create_rootfs( } } - // Add root= and rw argument - kargs.extend(&Cmdline::from(format!("{rootarg} {RW_KARG}"))); + // When discoverable-partitions is enabled, omit root= so that + // systemd-gpt-auto-generator discovers root by its DPS type GUID. + if discoverable { + kargs.extend(&Cmdline::from(RW_KARG)); + } else { + let rootarg = format!("root=UUID={root_uuid}"); + kargs.extend(&Cmdline::from(format!("{rootarg} {RW_KARG}"))); + } // Add boot= argument if present if let Some(bootarg) = bootarg { diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 07acd2b5e..f9eff96c0 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -119,6 +119,13 @@ pub(crate) struct InstallConfiguration { pub(crate) bootupd: Option, /// Bootloader to use (grub, systemd, none) pub(crate) bootloader: Option, + /// Use the Discoverable Partitions Specification for root partition + /// discovery. When true, the `root=` kernel argument is omitted + /// and `systemd-gpt-auto-generator` discovers root via its DPS + /// type GUID. Requires the bootloader to implement the Boot Loader + /// Interface (systemd-boot always does, GRUB needs the `bli` module). + /// Defaults to false for broad compatibility. + pub(crate) discoverable_partitions: Option, } fn merge_basic(s: &mut Option, o: Option, _env: &EnvProperties) { @@ -203,6 +210,11 @@ impl Mergeable for InstallConfiguration { merge_basic(&mut self.boot_mount_spec, other.boot_mount_spec, env); self.bootupd.merge(other.bootupd, env); merge_basic(&mut self.bootloader, other.bootloader, env); + merge_basic( + &mut self.discoverable_partitions, + other.discoverable_partitions, + env, + ); if let Some(other_kargs) = other.kargs { self.kargs .get_or_insert_with(Default::default) @@ -876,3 +888,30 @@ bootloader = "grub" install.merge(other, &env); assert_eq!(install.bootloader, Some(Bootloader::None)); } + +#[test] +fn test_parse_discoverable_partitions() { + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +discoverable-partitions = true +"##, + ) + .unwrap(); + assert_eq!(c.install.unwrap().discoverable_partitions, Some(true)); + + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +discoverable-partitions = false +"##, + ) + .unwrap(); + assert_eq!(c.install.unwrap().discoverable_partitions, Some(false)); + + let c: InstallConfigurationToplevel = toml::from_str( + r##"[install] +root-fs-type = "xfs" +"##, + ) + .unwrap(); + assert_eq!(c.install.unwrap().discoverable_partitions, None); +} diff --git a/docs/src/man/bootc-install-config.5.md b/docs/src/man/bootc-install-config.5.md index a6f5156c5..2840a5422 100644 --- a/docs/src/man/bootc-install-config.5.md +++ b/docs/src/man/bootc-install-config.5.md @@ -33,6 +33,12 @@ The `install` section supports these subfields: - `boot-mount-spec`: A string specifying the /boot filesystem mount specification. If not provided and /boot is a separate mount, its UUID will be used. An empty string signals to omit boot mount kargs entirely. +- `discoverable-partitions`: Boolean. When `true`, root discovery uses the + Discoverable Partitions Specification via `systemd-gpt-auto-generator` and + the `root=` kernel argument is omitted. This requires the bootloader to + implement the Boot Loader Interface (BLI); systemd-boot always does, GRUB + needs the `bli` module (available in newer builds). Defaults to `true` + when using systemd-boot, `false` otherwise. # filesystem @@ -78,6 +84,13 @@ boot-mount-spec = "UUID=abcd-1234" bls-append-except-default = 'grub_users=""' ``` +Enable DPS auto-discovery for root (requires a BLI-capable bootloader): + +```toml +[install] +discoverable-partitions = true +``` + # SEE ALSO **bootc(1)** diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 3ee99e0d8..3811ad566 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -41,15 +41,25 @@ use `install to-filesystem` if you need precise control over the partition layou ### Root filesystem discovery -Note that by default when used with "type 1" bootloader setups (i.e. non-UKI) -a kernel argument `root=UUID=` is injected by default. -This provides compatibility with existing initramfs implementations. - -When used with the composefs backend and UKIs, it's recommended that -a bootloader implementing the DPS specification is used and that the root -partition is auto-discovered. In this configuration, `systemd-gpt-auto-generator` -in the initramfs will automatically find and mount the root partition based on -its DPS type GUID, without requiring an explicit `root=` kernel argument. +The root partition can be discovered at boot time in two ways: + +- **UUID mode** (default): A kernel argument `root=UUID=` is + injected, providing broad compatibility with all initramfs + implementations and bootloaders. + +- **DPS auto-discovery**: The `root=` kernel argument is omitted + entirely. `systemd-gpt-auto-generator` in the initramfs discovers + the root partition by its DPS type GUID. This enables transparent + block-layer changes (such as adding LUKS encryption) without + updating kernel arguments. DPS auto-discovery requires the + bootloader to implement the Boot Loader Interface (BLI). + systemd-boot always supports this; GRUB supports it only with + newer builds that include the `bli` module. + +When using systemd-boot, DPS auto-discovery is enabled by default. +For GRUB, container base images that ship a BLI-capable build should +set `discoverable-partitions = true` in their install configuration +(see **bootc-install-config**(5)). # OPTIONS diff --git a/tmt/tests/booted/readonly/052-test-bli-detection.nu b/tmt/tests/booted/readonly/052-test-bli-detection.nu new file mode 100644 index 000000000..9da40a10a --- /dev/null +++ b/tmt/tests/booted/readonly/052-test-bli-detection.nu @@ -0,0 +1,30 @@ +use std assert +use tap.nu + +tap begin "DPS root discovery when partition-uuids is false" + +# Parse os-release +let os = open /usr/lib/os-release + | lines + | filter {|l| $l != "" and not ($l | str starts-with "#") } + | parse "{key}={value}" + | reduce -f {} {|it, acc| $acc | upsert $it.key ($it.value | str trim -c '"') } + +let os_id = ($os.ID? | default "unknown") +let version_id = ($os.VERSION_ID? | default "0" | into int) + +# We inject this in our builds, but hopefully C10S gets this too at some point +if not ($os_id == "fedora" and $version_id >= 43) { + print $"# skip: only applies to Fedora 43+ \(found ($os_id) ($version_id)\)" + tap ok + exit 0 +} + +print $"Running on ($os_id) ($version_id), checking DPS root discovery" + +let cmdline = (open /proc/cmdline) +let has_root_karg = ($cmdline | str contains "root=") + +assert (not $has_root_karg) "Fedora 43+ should use DPS auto-discovery (no root= in cmdline)" + +tap ok