From 9fcb15ac6417a122da85d2bf4f0aa16b4b672dac Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 10:19:00 +0530 Subject: [PATCH 1/9] composefs: Handle fs-verity disabled insall/updates Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 30 ++++++++++++++----- crates/lib/src/bootc_composefs/finalize.rs | 12 ++++++-- crates/lib/src/bootc_composefs/repo.rs | 12 ++++++-- crates/lib/src/bootc_composefs/selinux.rs | 2 +- crates/lib/src/bootc_composefs/soft_reboot.rs | 6 +++- crates/lib/src/bootc_composefs/state.rs | 6 +++- crates/lib/src/bootc_composefs/status.rs | 1 - crates/lib/src/bootc_composefs/update.rs | 12 ++++++-- crates/lib/src/install.rs | 6 ++-- crates/lib/src/parsers/bls_config.rs | 10 ++++--- 10 files changed, 72 insertions(+), 25 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 918198029..7c707ba88 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -94,7 +94,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; -use crate::parsers::grub_menuconfig::MenuEntry; use crate::task::Task; use crate::{ bootc_composefs::repo::get_imgref, @@ -119,6 +118,7 @@ use crate::{ }, spec::{Bootloader, Host}, }; +use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs}; use crate::install::{RootSetup, State}; @@ -155,7 +155,14 @@ pub(crate) enum BootSetupType<'a> { ), ), /// For `bootc upgrade` - Upgrade((&'a Storage, &'a ComposefsFilesystem, &'a Host)), + Upgrade( + ( + &'a Storage, + &'a BootedComposefs, + &'a ComposefsFilesystem, + &'a Host, + ), + ), } #[derive( @@ -532,7 +539,7 @@ pub(crate) fn setup_composefs_bls_boot( ) } - BootSetupType::Upgrade((storage, fs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, fs, host)) => { let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -551,7 +558,12 @@ pub(crate) fn setup_composefs_bls_boot( }; // Copy all cmdline args, replacing only `composefs=` - let param = format!("{COMPOSEFS_CMDLINE}={id_hex}"); + let param = if booted_cfs.cmdline.insecure { + format!("{COMPOSEFS_CMDLINE}=?{id_hex}") + } else { + format!("{COMPOSEFS_CMDLINE}={id_hex}") + }; + let param = Parameter::parse(¶m).context("Failed to create 'composefs=' parameter")?; cmdline.add_or_modify(¶m); @@ -1083,7 +1095,7 @@ pub(crate) fn setup_composefs_uki_boot( ) } - BootSetupType::Upgrade((storage, _, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, _, host)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -1092,7 +1104,7 @@ pub(crate) fn setup_composefs_uki_boot( sysroot, get_esp_partition(&sysroot_parent)?.0, bootloader, - false, + booted_cfs.cmdline.insecure, None, ) } @@ -1224,8 +1236,11 @@ pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, image_id: &str, + insecure: bool, ) -> Result<()> { - let repo = open_composefs_repo(&root_setup.physical_root)?; + let mut repo = open_composefs_repo(&root_setup.physical_root)?; + repo.set_insecure(insecure); + let mut fs = create_composefs_filesystem(&repo, image_id, None)?; let entries = fs.transform_for_boot(&repo)?; let id = fs.commit_image(&repo, None)?; @@ -1296,6 +1311,7 @@ pub(crate) async fn setup_composefs_boot( &state.source.imageref.name, )) .await?, + insecure, ) .await?; diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index f140122ed..5a1e2d284 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -24,7 +24,11 @@ pub(crate) async fn get_etc_diff(storage: &Storage, booted_cfs: &BootedComposefs // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; - let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + let composefs_fd = mount_composefs_image( + &sysroot_fd, + &booted_composefs.verity, + booted_cfs.cmdline.insecure, + )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; @@ -68,7 +72,11 @@ pub(crate) async fn composefs_backend_finalize( // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; - let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + let composefs_fd = mount_composefs_image( + &sysroot_fd, + &booted_composefs.verity, + booted_cfs.cmdline.insecure, + )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 7f7ce9777..bc56665ce 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -23,12 +23,14 @@ pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result Result<(String, impl FsVerityHashValue)> { let rootfs_dir = &root_setup.physical_root; crate::store::ensure_composefs_dir(rootfs_dir)?; - let repo = open_composefs_repo(rootfs_dir)?; + let mut repo = open_composefs_repo(rootfs_dir)?; + repo.set_insecure(insecure); let OstreeExtImgRef { name: image_name, @@ -71,6 +73,7 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { pub(crate) async fn pull_composefs_repo( transport: &String, image: &String, + insecure: bool, ) -> Result<( crate::store::ComposefsRepository, Vec>, @@ -79,7 +82,8 @@ pub(crate) async fn pull_composefs_repo( )> { let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; - let repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; + let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; + repo.set_insecure(insecure); let final_imgref = get_imgref(transport, image); @@ -91,7 +95,9 @@ pub(crate) async fn pull_composefs_repo( tracing::info!("ID: {id}, Verity: {}", verity.to_hex()); - let repo = open_composefs_repo(&rootfs_dir)?; + let mut repo = open_composefs_repo(&rootfs_dir)?; + repo.set_insecure(insecure); + let mut fs: crate::store::ComposefsFilesystem = create_composefs_filesystem(&repo, &id, None) .context("Failed to create composefs filesystem")?; diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs index 700275264..744c0ba0f 100644 --- a/crates/lib/src/bootc_composefs/selinux.rs +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -76,7 +76,7 @@ fn get_selinux_policy_for_deployment( let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id { (Dir::open_ambient_dir("/", ambient_authority())?, None) } else { - let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, false)?; + let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, booted_cmdline.insecure)?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt)) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 0a71d15b9..0070e41f2 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -108,7 +108,11 @@ pub(crate) async fn prepare_soft_reboot_composefs( create_dir_all(NEXTROOT).context("Creating nextroot")?; - let cmdline = Cmdline::from(format!("{COMPOSEFS_CMDLINE}={deployment_id}")); + let cmdline = if booted_cfs.cmdline.insecure { + Cmdline::from(format!("{COMPOSEFS_CMDLINE}=?{deployment_id}")) + } else { + Cmdline::from(format!("{COMPOSEFS_CMDLINE}={deployment_id}")) + }; let args = bootc_initramfs_setup::Args { cmd: vec![], diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 517281be0..2236bac3c 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -87,6 +87,7 @@ pub(crate) fn initialize_state( erofs_id: &String, state_path: &Utf8PathBuf, initialize_var: bool, + insecure: bool, ) -> Result<()> { let sysroot_fd = open( sysroot_path.as_std_path(), @@ -95,7 +96,8 @@ pub(crate) fn initialize_state( ) .context("Opening sysroot")?; - let composefs_fd = bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, false)?; + let composefs_fd = + bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, insecure)?; let tempdir = TempMount::mount_fd(composefs_fd)?; @@ -234,6 +236,7 @@ pub(crate) async fn write_composefs_state( boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, + insecure: bool, ) -> Result<()> { let state_path = root_path .join(STATE_DIR_RELATIVE) @@ -256,6 +259,7 @@ pub(crate) async fn write_composefs_state( &deployment_id.to_hex(), &state_path, staged.is_none(), + insecure, )?; let ImageReference { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 1e9444435..276c054fa 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -55,7 +55,6 @@ pub(crate) struct ImgConfigManifest { /// A parsed composefs command line #[derive(Clone)] pub(crate) struct ComposefsCmdline { - #[allow(dead_code)] pub insecure: bool, pub digest: Box, } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index a4558961e..26f10718a 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -251,7 +251,12 @@ pub(crate) async fn do_upgrade( ) -> Result<()> { start_finalize_stated_svc()?; - let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; + let (repo, entries, id, fs) = pull_composefs_repo( + &imgref.transport, + &imgref.image, + booted_cfs.cmdline.insecure, + ) + .await?; let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); @@ -267,7 +272,7 @@ pub(crate) async fn do_upgrade( let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), repo, &id, entry, @@ -275,7 +280,7 @@ pub(crate) async fn do_upgrade( )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), repo, &id, entries, @@ -293,6 +298,7 @@ pub(crate) async fn do_upgrade( boot_type, boot_digest, img_manifest_config, + booted_cfs.cmdline.insecure, ) .await?; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 4994deffc..297d12e53 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1909,10 +1909,12 @@ async fn install_to_filesystem_impl( if state.composefs_options.composefs_backend { // Load a fd for the mounted target physical root - let (id, verity) = initialize_composefs_repository(state, rootfs).await?; + let (id, verity) = + initialize_composefs_repository(state, rootfs, state.composefs_options.insecure) + .await?; tracing::info!("id: {id}, verity: {}", verity.to_hex()); - setup_composefs_boot(rootfs, state, &id).await?; + setup_composefs_boot(rootfs, state, &id, state.composefs_options.insecure).await?; } else { ostree_install(state, rootfs, cleanup).await?; } diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 66716b88e..a783053bc 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::fmt::Display; use uapi_version::Version; +use crate::bootc_composefs::status::ComposefsCmdline; use crate::composefs_consts::COMPOSEFS_CMDLINE; #[derive(Debug, PartialEq, Eq, Default)] @@ -189,15 +190,16 @@ impl BLSConfig { let kv = cmdline .find(COMPOSEFS_CMDLINE) - .ok_or(anyhow::anyhow!("No composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?; let value = kv .value() - .ok_or(anyhow::anyhow!("Empty composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("Empty composefs= param"))?; - let value = value.to_owned(); + let cfs_cmdline = ComposefsCmdline::new(value); - Ok(value) + // TODO(Johan-Liebert1): We lose the info here that this is insecure + Ok(cfs_cmdline.digest.to_string().clone()) } BLSConfigType::Unknown => anyhow::bail!("Unknown config type"), From b88c55627cc88e51e7559a7a40c71b973360dd04 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 10:35:29 +0530 Subject: [PATCH 2/9] composefs: Fix unqueuing rollback We were simply checking to booted system's verity and simply setting the corresponding boot entry as the secondary boot entry, even if rollback was already queued. Update the code to actually consider the bootloader entries as the source of truth, similar to what ostree does Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/rollback.rs | 35 ++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index f8af3a9ae..8bbe0ddc9 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -114,36 +114,34 @@ fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { /// - Grub Type1 boot entries /// - Systemd Typ1 boot entries /// - Systemd UKI (Type2) boot entries [since we use BLS entries for systemd boot] +/// +/// Cases +/// 1. We're actually booted into the deployment that has it's sort_key as 0 +/// a. Just swap the primary and secondary bootloader entries +/// b. If they're already swapped (rollback was queued), re-swap them (unqueue rollback) +/// +/// 2. We're booted into the depl with sort_key 1 (choose the rollback deployment on boot screen) +/// a. Here we assume that rollback is queued as there's no way to differentiate between this +/// case and Case 1-b. This is what ostree does as well #[context("Rolling back {bootloader} entries")] fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result<()> { - use crate::bootc_composefs::state::get_booted_bls; - // Get all boot entries sorted in descending order by sort-key let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?; // TODO(Johan-Liebert): Currently assuming there are only two deployments assert!(all_configs.len() == 2); - // Identify which entry is the currently booted one - let booted_bls = get_booted_bls(&boot_dir)?; - let booted_verity = booted_bls.get_verity()?; - // For rollback: previous gets primary sort-key, booted gets secondary sort-key // Use "bootc" as default os_id for rollback scenarios // TODO: Extract actual os_id from deployment let os_id = "bootc"; - for cfg in &mut all_configs { - let cfg_verity = cfg.get_verity()?; - - if cfg_verity == booted_verity { - // This is the currently booted deployment - it should become secondary - cfg.sort_key = Some(secondary_sort_key(os_id)); - } else { - // This is the previous deployment - it should become primary (rollback target) - cfg.sort_key = Some(primary_sort_key(os_id)); - } - } + // This is the currently booted deployment - it should become secondary + // OR if rollback was queued, it would become primary + all_configs[0].sort_key = Some(primary_sort_key(os_id)); + // This is the previous deployment - it should become primary (rollback target) + // OR if rollback was queued, it would become secondary + all_configs[1].sort_key = Some(secondary_sort_key(os_id)); // Write these boot_dir @@ -156,9 +154,8 @@ fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result< // Write the BLS configs in there for cfg in all_configs { - let cfg_verity = cfg.get_verity()?; // After rollback: previous deployment becomes primary, booted becomes secondary - let priority = if cfg_verity == booted_verity { + let priority = if cfg.sort_key == Some(secondary_sort_key(os_id)) { FILENAME_PRIORITY_SECONDARY } else { FILENAME_PRIORITY_PRIMARY From fce4ff33c4067949460092985202a3a447a024a6 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 12:19:32 +0530 Subject: [PATCH 3/9] tmt: Add test for rollback Signed-off-by: Pragyan Poudyal --- tmt/plans/integration.fmf | 7 ++ tmt/tests/booted/test-rollback.nu | 117 ++++++++++++++++++++++++++++++ tmt/tests/tests.fmf | 5 ++ 3 files changed, 129 insertions(+) create mode 100644 tmt/tests/booted/test-rollback.nu diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 1c8af4881..3aa0f9907 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -181,4 +181,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-34-user-agent + +/plan-36-rollback: + summary: Test bootc rollback functionality through image switch and rollback cycle + discover: + how: fmf + test: + - /tmt/tests/tests/test-36-rollback # END GENERATED PLANS diff --git a/tmt/tests/booted/test-rollback.nu b/tmt/tests/booted/test-rollback.nu new file mode 100644 index 000000000..0f2e2ee89 --- /dev/null +++ b/tmt/tests/booted/test-rollback.nu @@ -0,0 +1,117 @@ +# number: 36 +# tmt: +# summary: Test bootc rollback functionality +# duration: 30m +# +# This test verifies bootc rollback functionality: +# 1. Captures the initial deployment state +# 2. Switches to a different image +# 3. Verifies the switch was successful +# 4. Performs bootc rollback +# 5. Reboots and verifies we're back to the original deployment + +use std assert +use tap.nu +use bootc_testlib.nu + +bootc status +journalctl --list-boots + +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def imgsrc [] { + $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" +} + +# Run on the first boot - capture initial state and switch to new image +def initial_switch [] { + tap begin "bootc rollback test" + + print "=== Initial boot - capturing state and switching image ===" + + # Store initial deployment information for later verification + let initial_st = bootc status --json | from json + let initial_image = $initial_st.status.booted.image + + $initial_image | to json | save /var/bootc-initial-state.json + + let imgsrc = imgsrc + + if ($imgsrc | str ends-with "-local") { + bootc image copy-to-storage + + print "Building derived container" + "FROM localhost/bootc +RUN echo 'This is the rollback target image' > /usr/share/bootc-rollback-marker +" | save Dockerfile + + podman build -t $imgsrc . + print $"Built derived image: ($imgsrc)" + } + + print $"Switching to ($imgsrc)" + bootc switch --transport containers-storage $imgsrc + + print "Switch completed, rebooting to new image..." + tmt-reboot +} + +# Check that we successfully switched to the new image and then rollback +def second_boot_rollback [] { + print "=== Second boot - verifying switch and performing rollback ===" + + # Verify we're running the new image + assert equal $booted.image.image $"(imgsrc)" + print "Successfully switched to new image" + + assert ("/usr/share/bootc-rollback-marker" | path exists) + print "New image artifacts verified" + + print "Performing bootc rollback..." + bootc rollback + + print "Rollback initiated, rebooting to previous deployment..." + tmt-reboot +} + +def back_to_first_depl [boot_count] { + print $"=== ($boot_count) boot - verifying rollback success ===" + + # Load the original state we saved and verify we're back to the original image + let original_state = cat /var/bootc-initial-state.json | from json + + assert equal $booted.image $original_state.image + print $"Successfully rolled back to original image: ($booted.image.image)" + + if ("/usr/share/bootc-rollback-marker" | path exists) { + error make { msg: "Rollback target marker still present - rollback may have failed" } + } +} + +# Verify that rollback was successful and we're back to original deployment +def third_boot_verify [] { + back_to_first_depl Third + + # Finally test a double rollback, to make sure the rollback state is queued then unqueued + bootc rollback + bootc rollback + + tmt-reboot +} + +def fourth_boot_verify [] { + back_to_first_depl Fourth + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_switch, + "1" => second_boot_rollback, + "2" => third_boot_verify, + "3" => fourth_boot_verify, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 4d808880e..a1f5980e2 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -101,3 +101,8 @@ summary: Verify bootc sends correct User-Agent header to registries duration: 10m test: python3 booted/test-user-agent.py + +/test-36-rollback: + summary: Test bootc rollback functionality through image switch and rollback cycle + duration: 30m + test: nu booted/test-rollback.nu From 76e369b3cbf1648087dfc500b70fb9efe888c329 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 13:28:34 +0530 Subject: [PATCH 4/9] composefs/tests: Add tests for filesystems Add filesystems ext4 and xfs in github CI matrix so that we test systems with and without fs-verity enabled Signed-off-by: Pragyan Poudyal --- .github/workflows/ci.yml | 12 ++++++++---- Justfile | 23 ++++++++++++++--------- crates/xtask/src/tmt.rs | 11 ++++++++--- crates/xtask/src/xtask.rs | 3 +++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a87d3ea25..6797e5004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,7 @@ jobs: # No fedora-44 due to https://bugzilla.redhat.com/show_bug.cgi?id=2429501 test_os: [fedora-43, centos-9, centos-10] variant: [ostree, composefs-sealeduki-sdboot, composefs-sdboot, composefs-grub] + filesystem: ["ext4", "xfs"] exclude: # centos-9 UKI is experimental/broken (https://github.com/bootc-dev/bootc/issues/1812) - test_os: centos-9 @@ -172,6 +173,10 @@ jobs: variant: composefs-sdboot - test_os: centos-9 variant: composefs-grub + # We only test filesystems for composefs to test if composefs backend will work on fs + # without fsverity + - variant: ostree + filesystem: ext4 runs-on: ubuntu-24.04 @@ -190,6 +195,7 @@ jobs: echo "BOOTC_base=${BASE}" >> $GITHUB_ENV echo "RUST_BACKTRACE=full" >> $GITHUB_ENV echo "RUST_LOG=trace" >> $GITHUB_ENV + echo "BOOTC_filesystem=${{ matrix.filesystem }}" >> $GITHUB_ENV case "${{ matrix.variant }}" in composefs-grub) @@ -213,8 +219,6 @@ jobs: ;; esac - - if [ "${{ matrix.variant }}" = "composefs-sealeduki-sdboot" ]; then BUILDROOTBASE=$(just pullspec-for-os buildroot-base ${{ matrix.test_os }}) echo "BOOTC_buildroot_base=${BUILDROOTBASE}" >> $GITHUB_ENV @@ -244,7 +248,7 @@ jobs: - name: Run TMT integration tests run: | if [[ "${{ matrix.variant }}" = composefs* ]]; then - just "test-${{ matrix.variant }}" + just "test-${{ matrix.variant }}" "${{ matrix.filesystem }}" else just test-tmt integration fi @@ -255,7 +259,7 @@ jobs: if: always() uses: actions/upload-artifact@v6 with: - name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-${{ matrix.variant }}-${{ env.ARCH }} + name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-${{ matrix.variant }}-${{ matrix.filesystem }}-${{ env.ARCH }} path: /var/tmp/tmt # Test bootc install on Fedora CoreOS (separate job to avoid disk space issues diff --git a/Justfile b/Justfile index 176ff81e3..7b18ad9bf 100644 --- a/Justfile +++ b/Justfile @@ -21,6 +21,8 @@ upgrade_img := base_img + "-upgrade" # Build variant: ostree (default) or composefs-sealeduki-sdboot (sealed UKI) variant := env("BOOTC_variant", "ostree") bootloader := env("BOOTC_bootloader", "grub") +# Only used for composefs tests +filesystem := env("BOOTC_filesystem", "ext4") # Base container image to build from base := env("BOOTC_base", "quay.io/centos-bootc/centos-bootc:stream10") # Buildroot base image @@ -106,23 +108,26 @@ test-container: build build-units # Build and test sealed composefs images [group('core')] -test-composefs-sealeduki-sdboot: - just variant=composefs-sealeduki-sdboot test-tmt readonly local-upgrade-reboot +test-composefs-sealeduki-sdboot filesystem: + just variant=composefs-sealeduki-sdboot filesystem={{filesystem}} test-tmt readonly local-upgrade-reboot [group('core')] -test-composefs bootloader: - just variant=composefs bootloader={{bootloader}} \ - test-tmt --composefs-backend --bootloader {{bootloader}} integration +test-composefs bootloader filesystem: + just variant=composefs bootloader={{bootloader}} filesystem={{filesystem}} \ + test-tmt --composefs-backend \ + --bootloader {{bootloader}} \ + --filesystem {{filesystem}} \ + integration # Build and test composefs images booted using Type1 boot entries and systemd-boot as the bootloader [group('core')] -test-composefs-sdboot: - just test-composefs systemd +test-composefs-sdboot filesystem: + just test-composefs systemd {{filesystem}} # Build and test composefs images booted using Type1 boot entries and grub as the bootloader [group('core')] -test-composefs-grub: - just test-composefs grub +test-composefs-grub filesystem: + just test-composefs grub {{filesystem}} # Run cargo fmt and clippy checks in container [group('core')] diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 5ef546321..c649511d8 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -485,10 +485,15 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { } if args.composefs_backend { - // TODO(Johan-Liebert1): Filesystem should be a parameter and we should test - // insecure with xfs - opts.push("--filesystem=ext4".into()); + let filesystem = args.filesystem.as_deref().unwrap_or("ext4"); + opts.push(format!("--filesystem={}", filesystem)); opts.push("--composefs-backend".into()); + + if filesystem == "xfs" { + // As xfs doesn't support fsverity + opts.push("--allow-missing-verity".into()); + } + opts.extend(COMPOSEFS_KERNEL_ARGS.map(|x| x.into())); } diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index cb6afe29f..056bd780e 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -127,6 +127,9 @@ pub(crate) struct RunTmtArgs { #[arg(long, requires = "composefs_backend")] pub(crate) bootloader: Option, + + #[arg(long, requires = "composefs_backend")] + pub(crate) filesystem: Option, } /// Arguments for tmt-provision command From e2a311deb844385024d613114778e23339ef751d Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 13:40:37 +0530 Subject: [PATCH 5/9] cli: Change `insecure` param to `allow_missing_fsverity` `allow_missing_fsverity` conveys the intent in a much better way than just `insecure` Signed-off-by: Pragyan Poudyal --- crates/initramfs/src/lib.rs | 14 +++++--- crates/lib/src/bootc_composefs/boot.rs | 33 ++++++++++--------- crates/lib/src/bootc_composefs/finalize.rs | 4 +-- crates/lib/src/bootc_composefs/repo.rs | 10 +++--- crates/lib/src/bootc_composefs/selinux.rs | 3 +- crates/lib/src/bootc_composefs/soft_reboot.rs | 2 +- crates/lib/src/bootc_composefs/state.rs | 13 +++++--- crates/lib/src/bootc_composefs/status.rs | 18 ++++++---- crates/lib/src/bootc_composefs/update.rs | 4 +-- crates/lib/src/install.rs | 19 ++++++++--- crates/lib/src/store/mod.rs | 2 +- 11 files changed, 73 insertions(+), 49 deletions(-) diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 1a893b397..5d8decd40 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -258,13 +258,17 @@ fn open_root_fs(path: &Path) -> Result { /// Prepares a floating mount for composefs and returns the fd /// /// # Arguments -/// * sysroot - fd for /sysroot -/// * name - Name of the EROFS image to be mounted -/// * insecure - Whether fsverity is optional or not +/// * sysroot - fd for /sysroot +/// * name - Name of the EROFS image to be mounted +/// * allow_missing_fsverity - Whether to allow mount without fsverity support #[context("Mounting composefs image")] -pub fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Result { +pub fn mount_composefs_image( + sysroot: &OwnedFd, + name: &str, + allow_missing_fsverity: bool, +) -> Result { let mut repo = Repository::::open_path(sysroot, "composefs")?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let rootfs = repo .mount(name) .context("Failed to mount composefs image")?; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7c707ba88..f8720a934 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -519,7 +519,7 @@ pub(crate) fn setup_composefs_bls_boot( cmdline_options.extend(&root_setup.kargs); - let composefs_cmdline = if state.composefs_options.insecure { + let composefs_cmdline = if state.composefs_options.allow_missing_verity { format!("{COMPOSEFS_CMDLINE}=?{id_hex}") } else { format!("{COMPOSEFS_CMDLINE}={id_hex}") @@ -558,7 +558,7 @@ pub(crate) fn setup_composefs_bls_boot( }; // Copy all cmdline args, replacing only `composefs=` - let param = if booted_cfs.cmdline.insecure { + let param = if booted_cfs.cmdline.allow_missing_fsverity { format!("{COMPOSEFS_CMDLINE}=?{id_hex}") } else { format!("{COMPOSEFS_CMDLINE}={id_hex}") @@ -811,7 +811,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, - is_insecure_from_opts: bool, + missing_fsverity_allowed: bool, mounted_efi: impl AsRef, bootloader: &Bootloader, ) -> Result> { @@ -824,17 +824,19 @@ fn write_pe_to_esp( if matches!(pe_type, PEType::Uki) { let cmdline = uki::get_cmdline(&efi_bin).context("Getting UKI cmdline")?; - let (composefs_cmdline, insecure) = + let (composefs_cmdline, missing_verity_allowed_cmdline) = get_cmdline_composefs::(cmdline).context("Parsing composefs=")?; // If the UKI cmdline does not match what the user has passed as cmdline option // NOTE: This will only be checked for new installs and now upgrades/switches - match is_insecure_from_opts { - true if !insecure => { - tracing::warn!("--insecure passed as option but UKI cmdline does not support it"); + match missing_fsverity_allowed { + true if !missing_verity_allowed_cmdline => { + tracing::warn!( + "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + ); } - false if insecure => { + false if missing_verity_allowed_cmdline => { tracing::warn!("UKI cmdline has composefs set as insecure"); } @@ -1080,7 +1082,8 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha512HashValue, entries: Vec>, ) -> Result { - let (root_path, esp_device, bootloader, is_insecure_from_opts, uki_addons) = match setup_type { + let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type + { BootSetupType::Setup((root_setup, state, postfetch, ..)) => { state.require_no_kargs_for_uki()?; @@ -1090,7 +1093,7 @@ pub(crate) fn setup_composefs_uki_boot( root_setup.physical_root_path.clone(), esp_part.node.clone(), postfetch.detected_bootloader.clone(), - state.composefs_options.insecure, + state.composefs_options.allow_missing_verity, state.composefs_options.uki_addon.as_ref(), ) } @@ -1104,7 +1107,7 @@ pub(crate) fn setup_composefs_uki_boot( sysroot, get_esp_partition(&sysroot_parent)?.0, bootloader, - booted_cfs.cmdline.insecure, + booted_cfs.cmdline.allow_missing_fsverity, None, ) } @@ -1155,7 +1158,7 @@ pub(crate) fn setup_composefs_uki_boot( utf8_file_path, entry.pe_type, &id, - is_insecure_from_opts, + missing_fsverity_allowed, esp_mount.dir.path(), &bootloader, )?; @@ -1236,10 +1239,10 @@ pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, image_id: &str, - insecure: bool, + allow_missing_fsverity: bool, ) -> Result<()> { let mut repo = open_composefs_repo(&root_setup.physical_root)?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let mut fs = create_composefs_filesystem(&repo, image_id, None)?; let entries = fs.transform_for_boot(&repo)?; @@ -1311,7 +1314,7 @@ pub(crate) async fn setup_composefs_boot( &state.source.imageref.name, )) .await?, - insecure, + allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 5a1e2d284..9c409eef0 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -27,7 +27,7 @@ pub(crate) async fn get_etc_diff(storage: &Storage, booted_cfs: &BootedComposefs let composefs_fd = mount_composefs_image( &sysroot_fd, &booted_composefs.verity, - booted_cfs.cmdline.insecure, + booted_cfs.cmdline.allow_missing_fsverity, )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; @@ -75,7 +75,7 @@ pub(crate) async fn composefs_backend_finalize( let composefs_fd = mount_composefs_image( &sysroot_fd, &booted_composefs.verity, - booted_cfs.cmdline.insecure, + booted_cfs.cmdline.allow_missing_fsverity, )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index bc56665ce..511a473da 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -23,14 +23,14 @@ pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result Result<(String, impl FsVerityHashValue)> { let rootfs_dir = &root_setup.physical_root; crate::store::ensure_composefs_dir(rootfs_dir)?; let mut repo = open_composefs_repo(rootfs_dir)?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let OstreeExtImgRef { name: image_name, @@ -73,7 +73,7 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { pub(crate) async fn pull_composefs_repo( transport: &String, image: &String, - insecure: bool, + allow_missing_fsverity: bool, ) -> Result<( crate::store::ComposefsRepository, Vec>, @@ -83,7 +83,7 @@ pub(crate) async fn pull_composefs_repo( let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let final_imgref = get_imgref(transport, image); @@ -96,7 +96,7 @@ pub(crate) async fn pull_composefs_repo( tracing::info!("ID: {id}, Verity: {}", verity.to_hex()); let mut repo = open_composefs_repo(&rootfs_dir)?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let mut fs: crate::store::ComposefsFilesystem = create_composefs_filesystem(&repo, &id, None) diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs index 744c0ba0f..733f0897a 100644 --- a/crates/lib/src/bootc_composefs/selinux.rs +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -76,7 +76,8 @@ fn get_selinux_policy_for_deployment( let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id { (Dir::open_ambient_dir("/", ambient_authority())?, None) } else { - let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, booted_cmdline.insecure)?; + let composefs_fd = + mount_composefs_image(&sysroot_fd, depl_id, booted_cmdline.allow_missing_fsverity)?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt)) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 0070e41f2..b9e8a6e53 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -108,7 +108,7 @@ pub(crate) async fn prepare_soft_reboot_composefs( create_dir_all(NEXTROOT).context("Creating nextroot")?; - let cmdline = if booted_cfs.cmdline.insecure { + let cmdline = if booted_cfs.cmdline.allow_missing_fsverity { Cmdline::from(format!("{COMPOSEFS_CMDLINE}=?{deployment_id}")) } else { Cmdline::from(format!("{COMPOSEFS_CMDLINE}={deployment_id}")) diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 2236bac3c..b4350c7f0 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -87,7 +87,7 @@ pub(crate) fn initialize_state( erofs_id: &String, state_path: &Utf8PathBuf, initialize_var: bool, - insecure: bool, + allow_missing_fsverity: bool, ) -> Result<()> { let sysroot_fd = open( sysroot_path.as_std_path(), @@ -96,8 +96,11 @@ pub(crate) fn initialize_state( ) .context("Opening sysroot")?; - let composefs_fd = - bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, insecure)?; + let composefs_fd = bootc_initramfs_setup::mount_composefs_image( + &sysroot_fd, + &erofs_id, + allow_missing_fsverity, + )?; let tempdir = TempMount::mount_fd(composefs_fd)?; @@ -236,7 +239,7 @@ pub(crate) async fn write_composefs_state( boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, - insecure: bool, + allow_missing_fsverity: bool, ) -> Result<()> { let state_path = root_path .join(STATE_DIR_RELATIVE) @@ -259,7 +262,7 @@ pub(crate) async fn write_composefs_state( &deployment_id.to_hex(), &state_path, staged.is_none(), - insecure, + allow_missing_fsverity, )?; let ImageReference { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 276c054fa..a6fe2ada9 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -55,7 +55,7 @@ pub(crate) struct ImgConfigManifest { /// A parsed composefs command line #[derive(Clone)] pub(crate) struct ComposefsCmdline { - pub insecure: bool, + pub allow_missing_fsverity: bool, pub digest: Box, } @@ -68,12 +68,12 @@ struct DeploymentBootInfo<'a> { impl ComposefsCmdline { pub(crate) fn new(s: &str) -> Self { - let (insecure, digest_str) = s + let (allow_missing_fsverity, digest_str) = s .strip_prefix('?') .map(|v| (true, v)) .unwrap_or_else(|| (false, s)); ComposefsCmdline { - insecure, + allow_missing_fsverity, digest: digest_str.into(), } } @@ -81,8 +81,12 @@ impl ComposefsCmdline { impl std::fmt::Display for ComposefsCmdline { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let insecure = if self.insecure { "?" } else { "" }; - write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest) + let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" }; + write!( + f, + "{}={}{}", + COMPOSEFS_CMDLINE, allow_missing_fsverity, self.digest + ) } } @@ -808,10 +812,10 @@ mod tests { fn test_composefs_parsing() { const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; let v = ComposefsCmdline::new(DIGEST); - assert!(!v.insecure); + assert!(!v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); let v = ComposefsCmdline::new(&format!("?{}", DIGEST)); - assert!(v.insecure); + assert!(v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 26f10718a..1ad1903fc 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -254,7 +254,7 @@ pub(crate) async fn do_upgrade( let (repo, entries, id, fs) = pull_composefs_repo( &imgref.transport, &imgref.image, - booted_cfs.cmdline.insecure, + booted_cfs.cmdline.allow_missing_fsverity, ) .await?; @@ -298,7 +298,7 @@ pub(crate) async fn do_upgrade( boot_type, boot_digest, img_manifest_config, - booted_cfs.cmdline.insecure, + booted_cfs.cmdline.allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 297d12e53..adbbfe126 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -390,7 +390,7 @@ pub(crate) struct InstallComposefsOpts { /// Make fs-verity validation optional in case the filesystem doesn't support it #[clap(long, default_value_t, requires = "composefs_backend")] #[serde(default)] - pub(crate) insecure: bool, + pub(crate) allow_missing_verity: bool, /// Name of the UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed. @@ -1909,12 +1909,21 @@ async fn install_to_filesystem_impl( if state.composefs_options.composefs_backend { // Load a fd for the mounted target physical root - let (id, verity) = - initialize_composefs_repository(state, rootfs, state.composefs_options.insecure) - .await?; + let (id, verity) = initialize_composefs_repository( + state, + rootfs, + state.composefs_options.allow_missing_verity, + ) + .await?; tracing::info!("id: {id}, verity: {}", verity.to_hex()); - setup_composefs_boot(rootfs, state, &id, state.composefs_options.insecure).await?; + setup_composefs_boot( + rootfs, + state, + &id, + state.composefs_options.allow_missing_verity, + ) + .await?; } else { ostree_install(state, rootfs, cleanup).await?; } diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 6f3405896..dda245a0d 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -190,7 +190,7 @@ impl BootedStorage { Environment::ComposefsBooted(cmdline) => { let (physical_root, run) = get_physical_root_and_run()?; let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; - if cmdline.insecure { + if cmdline.allow_missing_fsverity { composefs.set_insecure(true); } let composefs = Arc::new(composefs); From 1481c9b417dd7daf9c0526c22e6a4b5a6d864565 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 12 Feb 2026 16:00:54 +0530 Subject: [PATCH 6/9] ukify: Accept `allow-missing-verity` param Signed-off-by: Pragyan Poudyal --- Dockerfile | 10 +++++++++- Justfile | 6 +++++- contrib/packaging/seal-uki | 10 +++++++++- crates/lib/src/cli.rs | 7 ++++++- crates/lib/src/ukify.rs | 11 ++++++++--- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5422d8e06..2d32c49a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -176,6 +176,7 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp # We need our newly-built bootc for the compute-composefs-digest command FROM tools as sealed-uki ARG variant +ARG filesystem # Install our bootc package (only needed for the compute-composefs-digest command) RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packages,src=/,target=/run/packages \ @@ -186,8 +187,15 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=bind,from=packaging,src=/,target=/run/packaging \ --mount=type=bind,from=base-penultimate,src=/,target=/run/target <, + /// Make fs-verity validation optional in case the filesystem doesn't support it + #[clap(long)] + allow_missing_verity: bool, + /// Additional arguments to pass to ukify (after `--`). #[clap(last = true)] args: Vec, @@ -1624,8 +1628,9 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ContainerOpts::Ukify { rootfs, kargs, + allow_missing_verity, args, - } => crate::ukify::build_ukify(&rootfs, &kargs, &args), + } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity), }, Opt::Completion { shell } => { use clap_complete::aot::generate; diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 05e5d86ed..7419de2fb 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -30,6 +30,7 @@ pub(crate) fn build_ukify( rootfs: &Utf8Path, extra_kargs: &[String], args: &[OsString], + allow_missing_fsverity: bool, ) -> Result<()> { // Warn if --karg is used (temporary workaround) if !extra_kargs.is_empty() { @@ -83,7 +84,11 @@ pub(crate) fn build_ukify( let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; // Add the composefs digest - let composefs_param = format!("{COMPOSEFS_CMDLINE}={composefs_digest}"); + let composefs_param = if allow_missing_fsverity { + format!("{COMPOSEFS_CMDLINE}=?{composefs_digest}") + } else { + format!("{COMPOSEFS_CMDLINE}={composefs_digest}") + }; cmdline.extend(&Cmdline::from(composefs_param)); // Add any extra kargs provided via --karg @@ -129,7 +134,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[]); + let result = build_ukify(path, &[], &[], false); assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -147,7 +152,7 @@ mod tests { fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); fs::write(tempdir.path().join("boot/EFI/Linux/test.efi"), b"fake uki").unwrap(); - let result = build_ukify(path, &[], &[]); + let result = build_ukify(path, &[], &[], false); assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( From d2feb4cf7ba1a0b3a9d5cd9fb1dc9ef617a27216 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 24 Feb 2026 13:16:24 +0530 Subject: [PATCH 7/9] composefs: Disable auto fsverity enforcement on unsupported fs On filesystems that do not support fsverity, we now do not auto enforce fsverity. On filesystems that do support it, we look at the `--allow-missing-verity` option, if passed in by the user, and if true, fs-verity enforcement is disabled and vice-versa. In case of UKIs, we take a different approach of looking at the UKI cmdline beforehand and checking if the `composefs=` parameter has `?` or not. This approach, though valid, fails in a few cases, viz, - The cmdline is in a UKI addon - The target image is not the one we're currently running in Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 74 +++++++++++++++++++++++++++++--- crates/lib/src/install/config.rs | 19 ++++++++ crates/lib/src/kernel.rs | 66 ++++++++++++++++++++++------ crates/lib/src/ukify.rs | 6 +-- 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index adbbfe126..c71161906 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -193,6 +193,7 @@ use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{ MergeState, PreparedImportMeta, PreparedPullResult, prepare_for_pull, pull_from_prepared, }; +use crate::install::config::Filesystem as FilesystemEnum; use crate::lsm; use crate::progress_jsonl::ProgressWriter; use crate::spec::{Bootloader, ImageReference}; @@ -200,7 +201,7 @@ use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; -use bootc_mount::Filesystem; +use bootc_mount::{Filesystem, inspect_filesystem}; use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory @@ -1507,6 +1508,7 @@ async fn prepare_install( source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, mut composefs_options: InstallComposefsOpts, + target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1576,12 +1578,15 @@ async fn prepare_install( }; tracing::debug!("Target image reference: {target_imgref}"); - let composefs_required = if let Some(root) = target_rootfs.as_ref() { - crate::kernel::find_kernel(root)? - .map(|k| k.kernel.unified) - .unwrap_or(false) + let (composefs_required, kernel) = if let Some(root) = target_rootfs.as_ref() { + let kernel = crate::kernel::find_kernel(root)?; + + ( + kernel.as_ref().map(|k| k.kernel.unified).unwrap_or(false), + kernel, + ) } else { - false + (false, None) }; tracing::debug!("Composefs required: {composefs_required}"); @@ -1656,6 +1661,59 @@ async fn prepare_install( tracing::debug!("No install configuration found"); } + let root_filesystem = target_fs + .or(install_config + .as_ref() + .and_then(|c| c.filesystem_root()) + .and_then(|r| r.fstype)) + .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; + + let mut is_uki = false; + + // For composefs backend, automatically disable fs-verity hard requirement if the + // filesystem doesn't support it + // + // If we have a sealed UKI on our hands, then we can assume that user wanted fs-verity so + // we hard require it in that particular case + // + // NOTE: This isn't really 100% accurate 100% of the time as the cmdline can be in an addon + match kernel { + Some(k) => match k.k_type { + crate::kernel::KernelType::Uki { + allow_missing_fsverity, + .. + } => { + if !allow_missing_fsverity { + anyhow::ensure!( + root_filesystem.supports_fsverity(), + "Specified filesystem {root_filesystem} does not support fs-verity" + ); + } + + composefs_options.allow_missing_verity = allow_missing_fsverity; + is_uki = true; + } + + crate::kernel::KernelType::Vmlinuz { .. } => {} + }, + + None => {} + } + + // If `--allow-missing-verity` is already passed via CLI, don't modify + if composefs_options.composefs_backend && !composefs_options.allow_missing_verity && !is_uki { + composefs_options.allow_missing_verity = !root_filesystem.supports_fsverity(); + + tracing::debug!( + "Missing fsverity {}", + if composefs_options.allow_missing_verity { + "allowed" + } else { + "not allowed" + } + ); + } + if let Some(crate::spec::Bootloader::None) = config_opts.bootloader { if cfg!(target_arch = "s390x") { anyhow::bail!("Bootloader set to none is not supported for the s390x architecture"); @@ -1994,6 +2052,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.source_opts, opts.target_opts, opts.composefs_opts, + block_opts.filesystem, ) .await?; @@ -2294,6 +2353,8 @@ pub(crate) async fn install_to_filesystem( target_path ); + let fs_inspect = inspect_filesystem(&opts.filesystem_opts.root_path)?; + // Gather global state, destructuring the provided options. // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. @@ -2304,6 +2365,7 @@ pub(crate) async fn install_to_filesystem( opts.source_opts, opts.target_opts, opts.composefs_opts, + Some(fs_inspect.fstype.as_str().try_into()?), ) .await?; diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 2e82533e2..07acd2b5e 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -32,6 +32,25 @@ impl std::fmt::Display for Filesystem { } } +impl TryFrom<&str> for Filesystem { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "xfs" => Ok(Self::Xfs), + "ext4" => Ok(Self::Ext4), + "btrfs" => Ok(Self::Btrfs), + other => anyhow::bail!("Unknown filesystem: {}", other), + } + } +} + +impl Filesystem { + pub(crate) fn supports_fsverity(&self) -> bool { + matches!(self, Self::Ext4 | Self::Btrfs) + } +} + /// The toplevel config entry for installation configs stored /// in bootc/install (e.g. /etc/bootc/install/05-custom.toml) #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 6cfd85156..5803845bc 100644 --- a/crates/lib/src/kernel.rs +++ b/crates/lib/src/kernel.rs @@ -6,13 +6,16 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::utf8::Cmdline; use camino::Utf8PathBuf; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use serde::Serialize; use crate::bootc_composefs::boot::EFI_LINUX; +use crate::bootc_composefs::status::ComposefsCmdline; +use crate::composefs_consts::COMPOSEFS_CMDLINE; /// Information about the kernel in a container image. #[derive(Debug, Serialize)] @@ -31,8 +34,11 @@ pub(crate) struct Kernel { /// UKI kernels only have the single PE binary, whereas /// traditional "vmlinuz" kernels have distinct kernel and /// initramfs. -pub(crate) enum KernelPath { - Uki(Utf8PathBuf), +pub(crate) enum KernelType { + Uki { + path: Utf8PathBuf, + allow_missing_fsverity: bool, + }, Vmlinuz { path: Utf8PathBuf, initramfs: Utf8PathBuf, @@ -47,7 +53,7 @@ pub(crate) enum KernelPath { /// to get the "public" form where needed. pub(crate) struct KernelInternal { pub(crate) kernel: Kernel, - pub(crate) path: KernelPath, + pub(crate) k_type: KernelType, } impl From for Kernel { @@ -67,12 +73,48 @@ pub(crate) fn find_kernel(root: &Dir) -> Result> { // First, try to find a UKI if let Some(uki_path) = find_uki_path(root)? { let version = uki_path.file_stem().unwrap_or(uki_path.as_str()).to_owned(); + + let uki = root.read(&uki_path).context("Reading UKI")?; + + // Best effort to check for composefs=?verity in the UKI cmdline + let cmdline = composefs_boot::uki::get_section(&uki, ".cmdline"); + + let allow_missing_fsverity = match cmdline { + Some(Ok(cmdline)) => { + let cmdline_str = std::str::from_utf8(cmdline)?; + + let cmdline = Cmdline::from(cmdline_str); + + match cmdline.find(COMPOSEFS_CMDLINE) { + Some(param) => ComposefsCmdline::new(¶m).allow_missing_fsverity, + + // The cmdline might be in an addon, so don't allow missing verity + None => false, + } + } + + Some(Err(uki_error)) => match uki_error { + composefs_boot::uki::UkiError::MissingSection(_) => { + // TODO(Johan-Liebert1): Check this when we have full UKI Addons support + // The cmdline might be in an addon, so don't allow missing verity + false + } + + e => anyhow::bail!("Failed to read UKI cmdline: {e:?}"), + }, + + None => false, + }; + return Ok(Some(KernelInternal { kernel: Kernel { version, unified: true, }, - path: KernelPath::Uki(uki_path), + k_type: KernelType::Uki { + path: uki_path, + allow_missing_fsverity, + }, })); } @@ -89,7 +131,7 @@ pub(crate) fn find_kernel(root: &Dir) -> Result> { version, unified: false, }, - path: KernelPath::Vmlinuz { + k_type: KernelType::Vmlinuz { path: vmlinuz, initramfs, }, @@ -156,8 +198,8 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "6.12.0-100.fc41.x86_64"); assert!(!kernel_internal.kernel.unified); - match &kernel_internal.path { - KernelPath::Vmlinuz { path, initramfs } => { + match &kernel_internal.k_type { + KernelType::Vmlinuz { path, initramfs } => { assert_eq!( path.as_str(), "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz" @@ -167,7 +209,7 @@ mod tests { "usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img" ); } - KernelPath::Uki(_) => panic!("Expected Vmlinuz, got Uki"), + KernelType::Uki { .. } => panic!("Expected Vmlinuz, got Uki"), } Ok(()) } @@ -181,11 +223,11 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0"); assert!(kernel_internal.kernel.unified); - match &kernel_internal.path { - KernelPath::Uki(path) => { + match &kernel_internal.k_type { + KernelType::Uki { path, .. } => { assert_eq!(path.as_str(), "boot/EFI/Linux/fedora-6.12.0.efi"); } - KernelPath::Vmlinuz { .. } => panic!("Expected Uki, got Vmlinuz"), + KernelType::Vmlinuz { .. } => panic!("Expected Uki, got Vmlinuz"), } Ok(()) } diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 7419de2fb..5602cf5e0 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -56,9 +56,9 @@ pub(crate) fn build_ukify( .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?; // Extract vmlinuz and initramfs paths, or bail if this is already a UKI - let (vmlinuz_path, initramfs_path) = match kernel.path { - crate::kernel::KernelPath::Vmlinuz { path, initramfs } => (path, initramfs), - crate::kernel::KernelPath::Uki(path) => { + let (vmlinuz_path, initramfs_path) = match kernel.k_type { + crate::kernel::KernelType::Vmlinuz { path, initramfs } => (path, initramfs), + crate::kernel::KernelType::Uki { path, .. } => { anyhow::bail!("Cannot build UKI: rootfs already contains a UKI at {path}"); } }; From 6bd6c9a1e0e86dce3e70a790877ccb5679f82f79 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 24 Feb 2026 14:58:52 +0530 Subject: [PATCH 8/9] tmt/tests: Don't pass `allow-missing-verity` to bcvk Now that we automatically handle enabling/disabling fsverity depending upon filesystem support, we don't need this anymore Signed-off-by: Pragyan Poudyal --- crates/xtask/src/tmt.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index c649511d8..1f14ddcf4 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -488,12 +488,6 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { let filesystem = args.filesystem.as_deref().unwrap_or("ext4"); opts.push(format!("--filesystem={}", filesystem)); opts.push("--composefs-backend".into()); - - if filesystem == "xfs" { - // As xfs doesn't support fsverity - opts.push("--allow-missing-verity".into()); - } - opts.extend(COMPOSEFS_KERNEL_ARGS.map(|x| x.into())); } From bdfd2d34c681c6344c056f27051b6b30e68f31f5 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 24 Feb 2026 17:47:32 +0530 Subject: [PATCH 9/9] install-to-fs: Move ostree target check before `prepare_install` We need to know the underlying filesystem for the install to filesystem target, which we can't get just by `findmnt` if `/` is mounted as overlay, which is the case for ostree systems (and composefs systems) in the future. We already had code checking for this, move it around so we have that info BEFORE we call `prepare_install` Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index c71161906..1235e10c1 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -201,7 +201,7 @@ use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; -use bootc_mount::{Filesystem, inspect_filesystem}; +use bootc_mount::Filesystem; use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory @@ -2353,22 +2353,6 @@ pub(crate) async fn install_to_filesystem( target_path ); - let fs_inspect = inspect_filesystem(&opts.filesystem_opts.root_path)?; - - // Gather global state, destructuring the provided options. - // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) - // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. - // IMPORTANT: In practice, we should only be gathering information before this point, - // IMPORTANT: and not performing any mutations at all. - let state = prepare_install( - opts.config_opts, - opts.source_opts, - opts.target_opts, - opts.composefs_opts, - Some(fs_inspect.fstype.as_str().try_into()?), - ) - .await?; - // And the last bit of state here is the fsopts, which we also destructure now. let mut fsopts = opts.filesystem_opts; @@ -2390,6 +2374,7 @@ pub(crate) async fn install_to_filesystem( } let target_root_path = fsopts.root_path.clone(); + // Get a file descriptor for the root path /target let target_rootfs_fd = Dir::open_ambient_dir(&target_root_path, cap_std::ambient_authority()) @@ -2412,11 +2397,6 @@ pub(crate) async fn install_to_filesystem( } } - // Check to see if this happens to be the real host root - if !fsopts.acknowledge_destructive { - warn_on_host_root(&target_rootfs_fd)?; - } - // If we're installing to an ostree root, then find the physical root from // the deployment root. let possible_physical_root = fsopts.root_path.join("sysroot"); @@ -2446,6 +2426,28 @@ pub(crate) async fn install_to_filesystem( target_rootfs_fd.try_clone()? }; + // Gather data about the root filesystem + let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?; + + // Gather global state, destructuring the provided options. + // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) + // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. + // IMPORTANT: In practice, we should only be gathering information before this point, + // IMPORTANT: and not performing any mutations at all. + let state = prepare_install( + opts.config_opts, + opts.source_opts, + opts.target_opts, + opts.composefs_opts, + Some(inspect.fstype.as_str().try_into()?), + ) + .await?; + + // Check to see if this happens to be the real host root + if !fsopts.acknowledge_destructive { + warn_on_host_root(&target_rootfs_fd)?; + } + match fsopts.replace { Some(ReplaceMode::Wipe) => { let rootfs_fd = rootfs_fd.try_clone()?; @@ -2459,9 +2461,6 @@ pub(crate) async fn install_to_filesystem( None => require_empty_rootdir(&rootfs_fd)?, } - // Gather data about the root filesystem - let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?; - // We support overriding the mount specification for root (i.e. LABEL vs UUID versus // raw paths). // We also support an empty specification as a signal to omit any mountspec kargs.