diff --git a/Justfile b/Justfile index 76b63389a..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"; 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. @@ -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/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/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 <= 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 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 }