From adbe9ffc3b25e22a14e66795c2ebc91f6e55573f Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 26 May 2026 10:50:38 +0530 Subject: [PATCH 1/5] cfs/rollback: Remove staged entry on rollback Similar to ostree, if we find any staged deployment while performing a rollback, we'll get rid of the staged deployment. The staged deployment still exists on disk and will be GC'd later Fixes: #2208 Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/rollback.rs | 44 ++++++++++++++++++---- crates/lib/src/deploy.rs | 3 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 106919f04..921d1a5c4 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result, anyhow}; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; +use ocidir::cap_std::ambient_authority; use rustix::fs::{AtFlags, RenameFlags, fsync, renameat_with}; use crate::bootc_composefs::boot::{ @@ -11,8 +12,11 @@ use crate::bootc_composefs::boot::{ secondary_sort_key, type1_entry_conf_file_name, }; use crate::bootc_composefs::status::{get_composefs_status, get_sorted_type1_boot_entries}; -use crate::composefs_consts::TYPE1_ENT_PATH_STAGED; -use crate::spec::Bootloader; +use crate::composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, TYPE1_ENT_PATH_STAGED, +}; +use crate::deploy::ROLLBACK_JOURNAL_ID; +use crate::spec::{Bootloader, Host}; use crate::store::{BootedComposefs, Storage}; use crate::{ bootc_composefs::{boot::get_efi_uuid_source, status::get_sorted_grub_uki_boot_entries}, @@ -124,7 +128,7 @@ fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { /// 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<()> { +fn rollback_composefs_entries(host: &Host, boot_dir: &Dir, bootloader: Bootloader) -> Result<()> { // Get all boot entries sorted in descending order by sort-key let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?; @@ -143,6 +147,32 @@ fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result< // OR if rollback was queued, it would become secondary all_configs[1].sort_key = Some(secondary_sort_key(os_id)); + // Ostree will drop any staged deployment on rollback + // We follow the same approach for now + // + // Cleanup any previous staged entries + boot_dir + .remove_all_optional(TYPE1_ENT_PATH_STAGED) + .context("Removing staged entries")?; + + if let Some(staged) = &host.status.staged { + tracing::info!( + message_id = ROLLBACK_JOURNAL_ID, + "Removing currently staged composefs deployment {}", + // SAFETY: This is a staged composefs entry, so composefs property + // will always exist + staged.composefs.as_ref().unwrap().verity + ); + + let transient_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient dir")?; + + transient_dir + .remove_file(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) + .context("Removing staged deployment file")?; + } + // Write these boot_dir .create_dir_all(TYPE1_ENT_PATH_STAGED) @@ -213,11 +243,9 @@ pub(crate) async fn composefs_rollback( let rollback_status = host .status .rollback + .as_ref() .ok_or_else(|| anyhow!("No rollback available"))?; - // TODO: Handle staged deployment - // Ostree will drop any staged deployment on rollback but will keep it if it is the first item - // in the new deployment list let Some(rollback_entry) = &rollback_status.composefs else { anyhow::bail!("Rollback deployment not a composefs deployment") }; @@ -227,7 +255,7 @@ pub(crate) async fn composefs_rollback( match &rollback_entry.bootloader { Bootloader::Grub => match rollback_entry.boot_type { BootType::Bls => { - rollback_composefs_entries(boot_dir, rollback_entry.bootloader.clone())?; + rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } BootType::Uki => { rollback_grub_uki_entries(boot_dir)?; @@ -236,7 +264,7 @@ pub(crate) async fn composefs_rollback( Bootloader::Systemd => { // We use BLS entries for systemd UKI as well - rollback_composefs_entries(boot_dir, rollback_entry.bootloader.clone())?; + rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } Bootloader::None => unreachable!("Checked at install time"), diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index e82314629..041e96a44 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -1118,9 +1118,10 @@ fn write_reboot_required(image: &str) -> Result<()> { Ok(()) } +pub(crate) const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; + /// Implementation of rollback functionality pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { - const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; let ostree = sysroot.get_ostree()?; let (booted_ostree, deployments, host) = crate::status::get_status_require_booted(ostree)?; From 523464a4e5e8a21e7e3e12b864e825a4304d75f1 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 26 May 2026 14:42:30 +0530 Subject: [PATCH 2/5] tests: Update rollback test Add a case in the rollback test which tests rollback when there is a staged deployment present. This is a test for #2208 Signed-off-by: Pragyan Poudyal --- tmt/tests/booted/test-rollback.nu | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tmt/tests/booted/test-rollback.nu b/tmt/tests/booted/test-rollback.nu index 0afe377ac..fae0ab0e6 100644 --- a/tmt/tests/booted/test-rollback.nu +++ b/tmt/tests/booted/test-rollback.nu @@ -103,6 +103,31 @@ def third_boot_verify [] { def fourth_boot_verify [] { back_to_first_depl Fourth + + # Stage a new deployment, then rollback -> staged deployment should be removed + let dockerfile = $" + FROM localhost/bootc as base + RUN echo 'Second Stage' > /usr/share/second-stage + " + + (tap make_uki_containerfile $dockerfile) | podman build -t localhost/second-stage . -f - + + bootc switch --transport containers-storage localhost/second-stage + + assert ( + (bootc status --json | from json).status + | get staged + | is-not-empty + ) + + bootc rollback + + assert ( + (bootc status --json | from json).status + | get staged + | is-empty + ) + tap ok } From 8c029e7e57e38578d8110a0895739bfa8fe943c8 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 26 May 2026 15:29:56 +0530 Subject: [PATCH 3/5] cfs/status: Implement bootloader-specific sorting Update `get_sorted_type1_boot_entries_helper` to implement sorting logic based on bootloader type - systemd-boot: Sort by sort-key (using BLSConfig::cmp which handles sort-key ascending, then version descending) - GRUB: Sort by filename in descending order (ignoring sort-key fields) Unit Tests generated by ClaudeCode (Opus) Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/status.rs | 254 ++++++++++++++++++++--- 1 file changed, 226 insertions(+), 28 deletions(-) diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index bd8b7a19c..57feda631 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -243,18 +243,26 @@ fn get_sorted_grub_uki_boot_entries_helper<'a>( parse_grub_menuentry_file(str) } +/// Get sorted boot entries +/// The sort here is done in terms of what will be shown on the boot menu +/// For systemd-boot, the entries are sorted by `sort-key` +/// For grub, the entries are sorted by the filename in descending order pub(crate) fn get_sorted_type1_boot_entries( boot_dir: &Dir, ascending: bool, ) -> Result> { - get_sorted_type1_boot_entries_helper(boot_dir, ascending, false) + let bootloader = get_bootloader()?; + get_sorted_type1_boot_entries_helper(boot_dir, ascending, false, bootloader) } +/// Same as [`get_sorted_type1_boot_entries`], but returns staged entries +/// See [`get_sorted_type1_boot_entries`] for more details pub(crate) fn get_sorted_staged_type1_boot_entries( boot_dir: &Dir, ascending: bool, ) -> Result> { - get_sorted_type1_boot_entries_helper(boot_dir, ascending, true) + let bootloader = get_bootloader()?; + get_sorted_type1_boot_entries_helper(boot_dir, ascending, true, bootloader) } #[context("Getting sorted Type1 boot entries")] @@ -262,15 +270,20 @@ fn get_sorted_type1_boot_entries_helper( boot_dir: &Dir, ascending: bool, get_staged_entries: bool, + bootloader: crate::spec::Bootloader, ) -> Result> { - let mut all_configs = vec![]; + #[derive(Debug)] + struct ConfigWithFilename { + config: BLSConfig, + filename: String, + } let dir = match get_staged_entries { true => { let dir = boot_dir.open_dir_optional(TYPE1_ENT_PATH_STAGED)?; let Some(dir) = dir else { - return Ok(all_configs); + return Ok(vec![]); }; dir.read_dir(".")? @@ -279,6 +292,8 @@ fn get_sorted_type1_boot_entries_helper( false => boot_dir.read_dir(TYPE1_ENT_PATH)?, }; + let mut configs_with_filenames = vec![]; + for entry in dir { let entry = entry?; @@ -302,12 +317,31 @@ fn get_sorted_type1_boot_entries_helper( let config = parse_bls_config(&contents).context("Parsing bls config")?; - all_configs.push(config); + configs_with_filenames.push(ConfigWithFilename { + config, + filename: file_name.to_string(), + }); } - all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + // Sort based on bootloader type + configs_with_filenames.sort_by(|a, b| { + let ord = match bootloader { + // For systemd-boot sort by sort-key + Bootloader::Systemd => a.config.cmp(&b.config), + // For grub, sort by filename in descending order + Bootloader::Grub => b.filename.cmp(&a.filename), + Bootloader::None => { + unreachable!("Bootloader checked during installation should not have been none") + } + }; - Ok(all_configs) + if ascending { ord } else { ord.reverse() } + }); + + Ok(configs_with_filenames + .into_iter() + .map(|c| c.config) + .collect()) } pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result> { @@ -979,7 +1013,11 @@ async fn composefs_deployment_status_from( mod tests { use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; - use crate::parsers::{bls_config::BLSConfigType, grub_menuconfig::MenuentryBody}; + use crate::bootc_composefs::boot::{ + FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key, + secondary_sort_key, type1_entry_conf_file_name, + }; + use crate::parsers::grub_menuconfig::MenuentryBody; use super::*; @@ -1024,30 +1062,16 @@ mod tests { tempdir.atomic_write("loader/entries/entry1.conf", entry1)?; tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; - let result = get_sorted_type1_boot_entries(&tempdir, true).unwrap(); - - let mut config1 = BLSConfig::default(); - config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into()); - config1.sort_key = Some("1".into()); - config1.cfg_type = BLSConfigType::NonEFI { - linux: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(), - initrd: vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()], - options: Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()), - }; - - let mut config2 = BLSConfig::default(); - config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into()); - config2.sort_key = Some("2".into()); - config2.cfg_type = BLSConfigType::NonEFI { - linux: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(), - initrd: vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()], - options: Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into()) - }; + let result = + get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd) + .unwrap(); assert_eq!(result[0].sort_key.as_ref().unwrap(), "1"); assert_eq!(result[1].sort_key.as_ref().unwrap(), "2"); - let result = get_sorted_type1_boot_entries(&tempdir, false).unwrap(); + let result = + get_sorted_type1_boot_entries_helper(&tempdir, false, false, Bootloader::Systemd) + .unwrap(); assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); @@ -1217,4 +1241,178 @@ mod tests { Ok(()) } + + #[test] + fn test_get_sorted_type1_boot_entries_helper_systemd() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + // Create entries with different sort-keys for systemd-boot testing + let entry1 = format!( + r#" + title Fedora Linux (1.0.0) + version 1.0.0 + sort-key {} + linux /boot/vmlinuz + initrd /boot/initramfs.img + "#, + secondary_sort_key("fedora") + ); + + let entry2 = format!( + r#" + title Fedora Linux (2.0.0) + version 2.0.0 + sort-key {} + linux /boot/vmlinuz + initrd /boot/initramfs.img + "#, + primary_sort_key("fedora") + ); + + let entry3 = format!( + r#" + title Fedora Linux (1.5.0) + version 1.5.0 + sort-key {} + linux /boot/vmlinuz + initrd /boot/initramfs.img + "#, + primary_sort_key("fedora") + ); + + tempdir.create_dir_all("loader/entries")?; + + // Use realistic filenames as used in production + let filename1 = type1_entry_conf_file_name("fedora", "1.0.0", FILENAME_PRIORITY_SECONDARY); + let filename2 = type1_entry_conf_file_name("fedora", "2.0.0", FILENAME_PRIORITY_PRIMARY); + let filename3 = type1_entry_conf_file_name("fedora", "1.5.0", FILENAME_PRIORITY_PRIMARY); + + tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?; + tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?; + tempdir.atomic_write(format!("loader/entries/{}", filename3), entry3)?; + + // Test systemd-boot sorting (by sort-key, then by version in descending order) + let result = get_sorted_type1_boot_entries_helper( + &tempdir, + true, + false, + crate::spec::Bootloader::Systemd, + )?; + + assert_eq!(result.len(), 3); + // With ascending=true, primary sort-key (bootc-fedora-0) should come before secondary (bootc-fedora-1) + // Within primary sort-key, version 2.0.0 should come before 1.5.0 (descending version order) + assert_eq!( + result[0].sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + assert_eq!(result[0].version(), "2.0.0".into()); // Entry 2 (version 2.0.0) + assert_eq!( + result[1].sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + assert_eq!(result[1].version(), "1.5.0".into()); // Entry 3 (version 1.5.0) + assert_eq!( + result[2].sort_key.as_ref().unwrap(), + &secondary_sort_key("fedora") + ); + assert_eq!(result[2].version(), "1.0.0".into()); // Entry 1 (version 1.0.0) + + // Test descending order + let result = get_sorted_type1_boot_entries_helper( + &tempdir, + false, + false, + crate::spec::Bootloader::Systemd, + )?; + + assert_eq!(result.len(), 3); + // With ascending=false, secondary sort-key should come before primary + assert_eq!( + result[0].sort_key.as_ref().unwrap(), + &secondary_sort_key("fedora") + ); + assert_eq!(result[0].version(), "1.0.0".into()); // Entry 1 + assert_eq!( + result[1].sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + assert_eq!(result[1].version(), "1.5.0".into()); // Entry 3 + assert_eq!( + result[2].sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + assert_eq!(result[2].version(), "2.0.0".into()); // Entry 2 + + Ok(()) + } + + #[test] + fn test_get_sorted_type1_boot_entries_helper_grub() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + // Create entries with sort-keys but GRUB ignores them and sorts by filename + let entry1 = format!( + r#" + title Fedora Linux (41.20251125.0) + version 41.20251125.0 + sort-key {} + linux /boot/vmlinuz + initrd /boot/initramfs.img + "#, + secondary_sort_key("fedora") + ); + + let entry2 = format!( + r#" + title Fedora Linux (42.20251125.0) + version 42.20251125.0 + sort-key {} + linux /boot/vmlinuz + initrd /boot/initramfs.img + "#, + primary_sort_key("fedora") + ); + + tempdir.create_dir_all("loader/entries")?; + + // Use realistic filenames - GRUB will sort by these, not sort-key + let filename1 = + type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY); + let filename2 = + type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY); + + tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?; + tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?; + + let result = get_sorted_type1_boot_entries_helper( + &tempdir, + true, + false, + crate::spec::Bootloader::Grub, + )?; + + assert_eq!(result.len(), 2); + // With ascending=true for GRUB, we reverse the default descending filename order + // Filenames: bootc_fedora-41.20251125.0-0.conf, bootc_fedora-42.20251125.0-1.conf + // Ascending filename order should be: 42-1, 41-0 + assert_eq!(result[0].version(), "42.20251125.0".into()); + assert_eq!(result[1].version(), "41.20251125.0".into()); + + // Test descending order (GRUB's default filename sorting) + let result = get_sorted_type1_boot_entries_helper( + &tempdir, + false, + false, + crate::spec::Bootloader::Grub, + )?; + + assert_eq!(result.len(), 2); + // With ascending=false for GRUB, filenames should be sorted in descending order + // Descending filename order should be: 42-1, 41-0 + assert_eq!(result[0].version(), "41.20251125.0".into()); + assert_eq!(result[1].version(), "42.20251125.0".into()); + + Ok(()) + } } From 585b3eb0110d657d4e2b2a66e8fa1c7bd250cb57 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 26 May 2026 15:49:06 +0530 Subject: [PATCH 4/5] cfs/rollback: Update the way we get rollback Instead of blindly selecting the "second" one in a list of sorted boot entries as the rollback and failing if there are more than one rollback candidate, sort the rollback candidates in the same order as the boot entries and take the first one as rollback. All the remaining deployments become `other_deployments`. This is especially useful if and when we implement pinned deployments for composefs Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/status.rs | 29 +++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 57feda631..fca551f7e 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, io::Read, sync::OnceLock}; +use std::{io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_kernel_cmdline::utf8::Cmdline; @@ -831,7 +831,6 @@ async fn composefs_deployment_status_from( Err(e) => Err(e), }?; - // NOTE: This cannot work if we support both BLS and UKI at the same time let mut boot_type: Option = None; // Boot entries from deployments that are neither booted nor staged deployments @@ -966,6 +965,8 @@ async fn composefs_deployment_status_from( // Determine rollback deployment by matching extra deployment boot entries against entires read from /boot // This collects verity digest across bls and grub enties, we should just have one of them, but still works + // + // We want this ordered, so we have a vector here let bootloader_configured_verity = sorted_bls_config .iter() .flatten() @@ -976,9 +977,9 @@ async fn composefs_deployment_status_from( .flatten() .map(|menu| menu.get_verity()), ) - .collect::>>()?; + .collect::>>()?; - let rollback_candidates: Vec<_> = extra_deployment_boot_entries + let mut rollback_candidates: Vec<_> = extra_deployment_boot_entries .into_iter() .filter(|entry| { let verity = &entry @@ -990,10 +991,22 @@ async fn composefs_deployment_status_from( }) .collect(); - if rollback_candidates.len() > 1 { - anyhow::bail!("Multiple extra entries in /boot, could not determine rollback entry"); - } else if let Some(rollback_entry) = rollback_candidates.into_iter().next() { - host.status.rollback = Some(rollback_entry); + // We get sorted bootloader entries, so here we re-sort the rollback candidates + // wrt their positions in the sorted bootloader entries as that's what determines + // what's shown on the bootloader menu. The very next boot entry, that's not the + // default should be the rollback + rollback_candidates.sort_by_key(|ent| { + bootloader_configured_verity + .iter() + // SAFETY: ent.composefs will definitely exist + .position(|v| ent.composefs.as_ref().unwrap().verity == *v) + }); + + if !rollback_candidates.is_empty() { + let mut iter = rollback_candidates.into_iter(); + + host.status.rollback = iter.next(); + host.status.other_deployments = iter.collect(); } host.status.rollback_queued = is_rollback_queued; From 830e90ae9c2c2fe3ac062c840dc9c55b4945d127 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Mon, 1 Jun 2026 15:45:13 +0530 Subject: [PATCH 5/5] composefs: Make operations resilient to corrupted state If we are unable to find the origin file for a deployment, we were immediately exiting with an error which rendered any bootc operation useless. Instead, just log a warning in the journal and continue with the specified operation so that we don't completely stop working if the system is in an unpredictable state Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/status.rs | 15 ++- tmt/plans/integration.fmf | 7 ++ ...est-composefs-corruped-state-resilience.nu | 116 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tmt/tests/booted/test-composefs-corruped-state-resilience.nu diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index fca551f7e..e686049fa 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -842,8 +842,19 @@ async fn composefs_deployment_status_from( .. } in bootloader_entry_verity { - let ini = read_origin(&storage.physical_root, &verity_digest)? - .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {verity_digest}"))?; + let ini = read_origin(&storage.physical_root, &verity_digest)?; + + let Some(ini) = ini else { + const STATUS_JOURNAL_ID: &str = "d264f924dadb4c31bff0412107d391fb"; + + tracing::warn!( + message_id = STATUS_JOURNAL_ID, + bootc.operation = "status", + "No origin file for deployment {verity_digest}" + ); + + continue; + }; let mut boot_entry = boot_entry_from_composefs_deployment( storage, diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index b3366648b..8e87d2a3e 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -276,4 +276,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-44-shadow-fixup + +/plan-45-composefs-corruped-state-resilience: + summary: Test composefs backend resilience to state corruption + discover: + how: fmf + test: + - /tmt/tests/tests/test-45-composefs-corruped-state-resilience # END GENERATED PLANS diff --git a/tmt/tests/booted/test-composefs-corruped-state-resilience.nu b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu new file mode 100644 index 000000000..bafa0f0f3 --- /dev/null +++ b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu @@ -0,0 +1,116 @@ +# number: 45 +# tmt: +# summary: Test composefs backend resilience to state corruption +# duration: 30m + +use std assert +use tap.nu + +if not (tap is_composefs) { + exit 0 +} + +let st = bootc status --json | from json +let booted = $st.status.booted.image +let is_uki = ($st.status.booted.composefs.bootType | str downcase) == "uki" +let is_grub = $st.status.booted.composefs.bootloader == "grub" + +# NOTE: Not testing for grub menuentries as that's niche case and we will +# remove it once we have https://github.com/bootc-dev/bootc/issues/2212 +if ($is_uki and $is_grub) { + exit 0 +} + +def first_boot [] { + bootc image copy-to-storage + + let bootloader = $st.status.booted.composefs.bootloader + let entries_dir = if ($bootloader | str downcase) == "systemd" { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + "/var/tmp/efi/loader/entries" + } else { + "/sysroot/boot/loader/entries" + } + + let booted_verity = $st.status.booted.composefs.verity + + # Add some random entry in /boot/loader/entries to simulate + # https://github.com/bootc-dev/bootc/issues/2208 + systemd-run -p MountFlags=slave -qdPG -- /bin/sh -c $" + mount -orw,remount /sysroot + cd ($entries_dir) + cp * new-entry.conf + + sed -i 's;($booted_verity);bad-verity;' new-entry.conf + " + + # This should work but log a warning in journal + bootc status + + assert ( + journalctl F_MESSAGE_ID=d264f924dadb4c31bff0412107d391fb + | str contains $"No origin file for deployment bad-verity" + ) + + # Create a simple derived image to switch to + tap make_uki_containerfile $" + FROM localhost/bootc + RUN echo 'first-deployment' > /usr/share/deployment-marker + " | podman build -t localhost/bootc-test-1 . -f - + + bootc switch --transport containers-storage localhost/bootc-test-1 + + # Remove /run/composefs to simulate switch/update that failed midway + # and make sure switch still works + rm -rf /run/composefs + + assert ((bootc status --json | from json | get status.staged) == null) + + bootc switch --transport containers-storage localhost/bootc-test-1 + + tmt-reboot +} + +# Test the same thing but with an existing rollback deployment +def second_boot [] { + assert equal $booted.image.image "localhost/bootc-test-1" + + # Create another derived image + tap make_uki_containerfile $" + FROM localhost/bootc + RUN echo 'second-deployment' > /usr/share/deployment-marker + " | podman build -t localhost/bootc-test-2 . -f - + + bootc switch --transport containers-storage localhost/bootc-test-2 + + # Remove the origin file for staged deployment + # Make sure bootc status and switch still work + let staged_verity = (bootc status --json | from json).status.staged.composefs.verity + + systemd-run -p MountFlags=slave -qdPG -- /bin/sh -c $" + mount -orw,remount /sysroot + rm -rvf /sysroot/state/deploy/($staged_verity)/($staged_verity).origin + " + + # This should work but log a warning in journal + bootc status + + assert ( + journalctl F_MESSAGE_ID=d264f924dadb4c31bff0412107d391fb + | str contains $"No origin file for deployment ($staged_verity)" + ) + + # Switch again and it should work + bootc switch --transport containers-storage localhost/bootc-test-2 + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 9c6b41713..a8b5b91f8 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -173,3 +173,8 @@ check: summary: Test bootc-sysusers-shadow-sync removes orphaned gshadow entries before sysusers duration: 30m test: nu booted/test-44-shadow-fixup.nu + +/test-45-composefs-corruped-state-resilience: + summary: Test composefs backend resilience to state corruption + duration: 30m + test: nu booted/test-composefs-corruped-state-resilience.nu