Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 226 additions & 63 deletions crates/lib/src/bootc_composefs/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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#"
Expand Down
19 changes: 19 additions & 0 deletions tmt/tests/booted/test-rollback.nu
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down
Loading