diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index c2a984e79..56cf3a72e 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -820,6 +820,32 @@ fn set_reboot_capable_uki_deployments( Ok(()) } +/// Whether the bootloader will boot a deployment other than the booted one, +/// i.e. whether the first (default) boot entry references some other deployment. +#[context("Determining if rollback is queued")] +fn rollback_queued_from_first_entry( + bls_config: &BLSConfig, + booted_composefs_digest: &str, +) -> Result { + match &bls_config.cfg_type { + // For UKI boot + BLSConfigType::EFI { key } => { + let path = match key { + EFIKey::Efi(path) | EFIKey::Uki(path) => path, + }; + Ok(!path.as_str().contains(booted_composefs_digest)) + } + + // For boot entry Type1 + BLSConfigType::NonEFI { options, .. } => Ok(!options + .as_ref() + .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))? + .contains(booted_composefs_digest)), + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } +} + #[context("Getting composefs deployment status")] async fn composefs_deployment_status_from( storage: &Storage, @@ -934,78 +960,61 @@ async fn composefs_deployment_status_from( let booted_cfs = host.require_composefs_booted()?; let mut grub_menu_string = String::new(); - let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_cfs - .bootloader - .kind()? - { - BootloaderKind::GRUBClassic => match boot_type { - BootType::Bls => { - let bls_configs = get_sorted_type1_boot_entries(boot_dir, false)?; - let bls_config = bls_configs - .first() - .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?; - - match &bls_config.cfg_type { - BLSConfigType::NonEFI { options, .. } => { - let is_rollback_queued = !options - .as_ref() - .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))? - .contains(booted_composefs_digest.as_ref()); - - (is_rollback_queued, Some(bls_configs), None) + let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = + match booted_cfs.bootloader.kind()? { + BootloaderKind::GRUBClassic => match boot_type { + BootType::Bls => { + let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?; + let bls_config = bls_configs + .first() + .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?; + + match &bls_config.cfg_type { + BLSConfigType::NonEFI { .. } => { + let is_rollback_queued = rollback_queued_from_first_entry( + bls_config, + booted_composefs_digest.as_ref(), + )?; + + (is_rollback_queued, Some(bls_configs), None) + } + + BLSConfigType::EFI { .. } => { + anyhow::bail!("Found 'efi' field in Type1 boot entry") + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), } - - BLSConfigType::EFI { .. } => { - anyhow::bail!("Found 'efi' field in Type1 boot entry") - } - - BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), } - } - BootType::Uki => { - let menuentries = - get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?; + BootType::Uki => { + let menuentries = + get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?; - let is_rollback_queued = !menuentries - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))? - .body - .chainloader - .contains(booted_composefs_digest.as_ref()); - - (is_rollback_queued, None, Some(menuentries)) - } - }, + let is_rollback_queued = !menuentries + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .body + .chainloader + .contains(booted_composefs_digest.as_ref()); - // We will have BLS stuff and the UKI stuff in the same DIR - BootloaderKind::BLSCompatible => { - let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?; - let bls_config = bls_configs - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))?; - - let is_rollback_queued = match &bls_config.cfg_type { - // For UKI boot - BLSConfigType::EFI { key } => { - let path = match key { - EFIKey::Efi(path) | EFIKey::Uki(path) => path, - }; - path.as_str().contains(booted_composefs_digest.as_ref()) + (is_rollback_queued, None, Some(menuentries)) } + }, - // For boot entry Type1 - BLSConfigType::NonEFI { options, .. } => !options - .as_ref() - .ok_or(anyhow::anyhow!("options key not found in bls config"))? - .contains(booted_composefs_digest.as_ref()), + // We will have BLS stuff and the UKI stuff in the same DIR + BootloaderKind::BLSCompatible => { + let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?; + let bls_config = bls_configs + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))?; - BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), - }; + let is_rollback_queued = + rollback_queued_from_first_entry(bls_config, booted_composefs_digest.as_ref())?; - (is_rollback_queued, Some(bls_configs), None) - } - }; + (is_rollback_queued, Some(bls_configs), None) + } + }; // 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 @@ -1135,6 +1144,160 @@ mod tests { Ok(()) } + #[test] + fn test_rollback_queued_from_first_entry_uki() -> Result<()> { + const BOOTED: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const ROLLBACK: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("loader/entries")?; + + let default_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nuki /EFI/Linux/bootc/bootc_composefs-{BOOTED}.efi\n", + primary_sort_key("fedora") + ); + let other_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nuki /EFI/Linux/bootc/bootc_composefs-{ROLLBACK}.efi\n", + secondary_sort_key("fedora") + ); + + // Production pairing (boot.rs): the primary entry carries sort-key + // "...-0" AND filename release "1" — systemd-boot sorts sort-key + // ascending, grub sorts the release field descending, both put it first. + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY) + ), + default_entry, + )?; + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY) + ), + other_entry, + )?; + + let sorted = + get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)?; + let first = sorted.first().unwrap(); + + // The entry carrying the primary sort key is the bootloader default + assert_eq!( + first.sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + + // The default entry references the booted deployment: nothing is queued + assert!(!rollback_queued_from_first_entry(first, BOOTED)?); + // The default entry references another deployment: a rollback is queued + assert!(rollback_queued_from_first_entry(first, ROLLBACK)?); + + Ok(()) + } + + #[test] + fn test_rollback_queued_from_first_entry_type1() -> Result<()> { + const BOOTED: &str = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + const ROLLBACK: &str = "febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01"; + + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("loader/entries")?; + + let default_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{BOOTED}/vmlinuz\ninitrd /boot/{BOOTED}/initramfs.img\noptions root=UUID=abc123 rw composefs={BOOTED}\n", + primary_sort_key("fedora") + ); + let other_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{ROLLBACK}/vmlinuz\ninitrd /boot/{ROLLBACK}/initramfs.img\noptions root=UUID=abc123 rw composefs={ROLLBACK}\n", + secondary_sort_key("fedora") + ); + + // Production pairing: primary sort-key rides filename release "1" + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY) + ), + default_entry, + )?; + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY) + ), + other_entry, + )?; + + let sorted = + get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)?; + let first = sorted.first().unwrap(); + + assert_eq!( + first.sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + + assert!(!rollback_queued_from_first_entry(first, BOOTED)?); + assert!(rollback_queued_from_first_entry(first, ROLLBACK)?); + + Ok(()) + } + + #[test] + fn test_rollback_queued_from_first_entry_grub_type1() -> Result<()> { + const BOOTED: &str = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + const ROLLBACK: &str = "febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01"; + + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("loader/entries")?; + + let default_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{BOOTED}/vmlinuz\ninitrd /boot/{BOOTED}/initramfs.img\noptions root=UUID=abc123 rw composefs={BOOTED}\n", + primary_sort_key("fedora") + ); + let other_entry = format!( + "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{ROLLBACK}/vmlinuz\ninitrd /boot/{ROLLBACK}/initramfs.img\noptions root=UUID=abc123 rw composefs={ROLLBACK}\n", + secondary_sort_key("fedora") + ); + + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY) + ), + default_entry, + )?; + tempdir.atomic_write( + format!( + "loader/entries/{}", + type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY) + ), + other_entry, + )?; + + // Grub and GrubCC ignore sort-key and sort the filename release field + // descending ("1" > "0"), so the primary entry is grub's default — + // the premise the first-entry check stands on for those bootloaders. + let sorted = get_sorted_type1_boot_entries_helper( + &tempdir, + true, + false, + crate::spec::Bootloader::Grub, + )?; + let first = sorted.first().unwrap(); + assert_eq!( + first.sort_key.as_ref().unwrap(), + &primary_sort_key("fedora") + ); + + assert!(!rollback_queued_from_first_entry(first, BOOTED)?); + assert!(rollback_queued_from_first_entry(first, ROLLBACK)?); + + Ok(()) + } + #[test] fn test_sorted_uki_boot_entries() -> Result<()> { let user_cfg = r#" diff --git a/tmt/tests/booted/test-rollback.nu b/tmt/tests/booted/test-rollback.nu index fae0ab0e6..9e6281089 100644 --- a/tmt/tests/booted/test-rollback.nu +++ b/tmt/tests/booted/test-rollback.nu @@ -24,6 +24,14 @@ def imgsrc [] { $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" } +# Assert that bootc status reports the expected rollbackQueued value. +# Regression check for https://github.com/bootc-dev/bootc/issues/2405 where +# the composefs sd-boot UKI path reported the inverse. +def assert_rollback_queued [expected: bool] { + let queued = (bootc status --json | from json).status.rollbackQueued + assert equal $queued $expected +} + # Run on the first boot - capture initial state and switch to new image def initial_switch [] { tap begin "bootc rollback test" @@ -69,9 +77,15 @@ def second_boot_rollback [] { assert ("/usr/share/bootc-rollback-marker" | path exists) print "New image artifacts verified" + # A freshly booted deployment has no rollback queued + assert_rollback_queued false + print "Performing bootc rollback..." bootc rollback + # ...and a real queued rollback must be reported as one + assert_rollback_queued true + print "Rollback initiated, rebooting to previous deployment..." tmt-reboot } @@ -88,6 +102,9 @@ def back_to_first_depl [boot_count] { if ("/usr/share/bootc-rollback-marker" | path exists) { error make { msg: "Rollback target marker still present - rollback may have failed" } } + + # Booting the intended deployment consumes any queued rollback + assert_rollback_queued false } # Verify that rollback was successful and we're back to original deployment @@ -96,7 +113,9 @@ def third_boot_verify [] { # Finally test a double rollback, to make sure the rollback state is queued then unqueued bootc rollback + assert_rollback_queued true bootc rollback + assert_rollback_queued false tmt-reboot }