From 346a6623e9ccecb26631710300dd730f7f15d027 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 14:34:23 -0400 Subject: [PATCH 1/3] composefs: Don't put '+' from the version in Type #1 entry names The Boot Loader Specification reserves `+` in entry file names for boot counting, so an os-release version like `1.2.3+42` gave an entry that systemd-boot treats as having 42 boot attempts left, and counts down. Generated-by: AI --- crates/lib/src/bootc_composefs/boot.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index d3a466775..7cf75d11f 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -537,13 +537,18 @@ pub(crate) const SORTKEY_PRIORITY_SECONDARY: &str = "1"; /// /// The underscore replacement prevents Grub from mis-parsing os_id values /// containing hyphens (e.g., "fedora-coreos" → "fedora_coreos"). +/// +/// A `+` in the version is replaced with an underscore too, as the Boot Loader +/// Specification reserves `+` for boot counting: systemd-boot would take +/// `…-1.2.3+42-1.conf` as an entry with 42 boot attempts left. pub fn type1_entry_conf_file_name( os_id: &str, version: impl std::fmt::Display, priority: &str, ) -> String { let os_id_safe = os_id.replace('-', "_"); - format!("{BLS_ENTRY_FILE_PREFIX}{os_id_safe}-{version}-{priority}.conf") + let version_safe = version.to_string().replace('+', "_"); + format!("{BLS_ENTRY_FILE_PREFIX}{os_id_safe}-{version_safe}-{priority}.conf") } /// Generate sort key for the primary (new/upgraded) boot entry. @@ -3315,6 +3320,10 @@ mod tests { // Test rhel example let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY); assert_eq!(filename, "bootc_rhel-9.3.0-0.conf"); + + // '+' is reserved for boot counting + let filename = type1_entry_conf_file_name("fedora", "1.2.3+42", FILENAME_PRIORITY_PRIMARY); + assert_eq!(filename, "bootc_fedora-1.2.3_42-1.conf"); } #[test] From f1ac55734101eafb7da78ba5428b2dffbcc186d2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 12:25:41 -0400 Subject: [PATCH 2/3] composefs: Sort systemd-boot entries without boot attempts left last systemd-boot puts entries whose boot counter has run out after all others, whatever their sort-key, so after a new deployment fails to boot the previous one becomes the default. Mirror that when ordering Type #1 entries, so that `bootc status` reports the failed deployment as the rollback rather than claiming a rollback is queued when it isn't, and `bootc rollback` swaps the entries the bootloader actually uses. Prep for enabling boot counting for new deployments. Generated-by: AI --- .../lib/src/bootc_composefs/boot_counting.rs | 70 +++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/status.rs | 28 +++++++- 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/boot_counting.rs diff --git a/crates/lib/src/bootc_composefs/boot_counting.rs b/crates/lib/src/bootc_composefs/boot_counting.rs new file mode 100644 index 000000000..75737aa95 --- /dev/null +++ b/crates/lib/src/bootc_composefs/boot_counting.rs @@ -0,0 +1,70 @@ +//! Automatic boot assessment ("boot counting") for systemd-boot entries. +//! +//! See +//! and . +//! +//! A counted entry is named `+.conf`. On each boot attempt +//! systemd-boot renames it to `+-.conf`, and once +//! `boot-complete.target` is reached `systemd-bless-boot.service` renames it +//! to plain `.conf`. An entry whose counter reaches zero is "bad", and +//! systemd-boot sorts it after all other entries, so the next boot falls back +//! to the previous deployment. + +const ENTRY_SUFFIX: &str = ".conf"; + +/// Parse the boot attempts left from a Type #1 entry file name with a boot +/// counter, like `foo+2-1.conf` (2 left, 1 done) or `foo+3.conf`. Returns +/// `None` if the entry isn't being counted. +pub(crate) fn boot_attempts_left(file_name: &str) -> Option { + // Unlike u32::from_str, don't accept a leading '+' + let parse = |s: &str| -> Option { + if s.bytes().all(|b| b.is_ascii_digit()) { + s.parse().ok() + } else { + None + } + }; + let stem = file_name.strip_suffix(ENTRY_SUFFIX)?; + let (_, counter) = stem.rsplit_once('+')?; + let left = match counter.split_once('-') { + Some((left, done)) => { + parse(done)?; + left + } + None => counter, + }; + parse(left) +} + +/// Whether systemd-boot considers the entry with this file name bad, i.e. it +/// has no boot attempts left. +pub(crate) fn entry_is_bad(file_name: &str) -> bool { + boot_attempts_left(file_name) == Some(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_boot_attempts_left() { + let cases = [ + ("bootc_fedora-42.0-1.conf", None), + ("bootc_fedora-42.0-1+3.conf", Some(3)), + ("bootc_fedora-42.0-1+2-1.conf", Some(2)), + ("bootc_fedora-42.0-1+0-3.conf", Some(0)), + ("bootc_fedora-42.0-1+0.conf", Some(0)), + // Not a counter + ("bootc_fedora-42.0-1+.conf", None), + ("bootc_fedora-42.0-1+3-.conf", None), + ("bootc_fedora-42.0-1+-3.conf", None), + ("bootc_fedora-1.0+git-1.conf", None), + ("bootc_fedora-42.0-1+3.efi", None), + ("bootc_fedora-42.0-1+3", None), + ]; + for (name, expected) in cases { + assert_eq!(boot_attempts_left(name), expected, "{name}"); + assert_eq!(entry_is_bad(name), expected == Some(0), "{name}"); + } + } +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index e6d6f8787..8fbf5050c 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod aboot; pub(crate) mod backwards_compat; pub(crate) mod boot; +pub(crate) mod boot_counting; pub(crate) mod delete; pub(crate) mod digest; pub(crate) mod export; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..b31a061e6 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -18,6 +18,7 @@ use crate::{ bootc_composefs::{ aboot, boot::BootType, + boot_counting::entry_is_bad, selinux::are_selinux_policies_compatible, state::{get_usr_overlay_status, read_boot_type, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, @@ -329,8 +330,11 @@ fn get_sorted_type1_boot_entries_helper( // 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 systemd-boot sort by sort-key, except that like systemd-boot + // we put entries without boot attempts left last + Bootloader::Systemd => entry_is_bad(&a.filename) + .cmp(&entry_is_bad(&b.filename)) + .then_with(|| a.config.cmp(&b.config)), // For grub and grub-cc, sort by filename in descending order // See: https://github.com/bootc-dev/bootc/issues/2221 Bootloader::Grub | Bootloader::GrubCC => b.filename.cmp(&a.filename), @@ -1707,6 +1711,26 @@ mod tests { assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); + // Like systemd-boot, an entry that ran out of boot attempts sorts last + // whatever its sort-key, while one with attempts left sorts as usual. + for (name, expected) in [ + ("entry1+0-3.conf", ["2", "1"]), + ("entry1+1-2.conf", ["1", "2"]), + ] { + tempdir.remove_all_optional("loader/entries")?; + tempdir.create_dir_all("loader/entries")?; + tempdir.atomic_write(format!("loader/entries/{name}"), entry1)?; + tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; + + let result = + get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)?; + let keys: Vec<_> = result + .iter() + .map(|c| c.sort_key.as_deref().unwrap()) + .collect(); + assert_eq!(keys, expected, "{name}"); + } + Ok(()) } From a65adf3722095dd47fd9b5160291e9a5c53bec1f Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 12:25:49 -0400 Subject: [PATCH 3/3] composefs: Support boot counting for new systemd-boot deployments A composefs deployment that fails to boot (kernel panic, initramfs failure, a hang before boot-complete.target) stays the default boot entry, and needs someone at the console to pick the previous one. The ostree backend can opt into boot counting; the composefs backend writes its own entries, so it has to do it itself. With systemd-boot, write a staged deployment's entry with a `+N` boot counter per the Boot Loader Specification. systemd-bless-boot renames it to the plain name once the new deployment reaches boot-complete.target; if it never gets there, systemd-boot falls back to the previous deployment after N attempts. The rest of bootc reads entries by their contents, not their names, so it doesn't care about the counter. Like kernel-install (and ostree's boot-counting-tries), this is opt-in: N comes from the booted system's /etc/kernel/tries when staging, and a malformed file is warned about and ignored rather than blocking the update. It also needs systemd-bless-boot in the new image, since otherwise every entry would eventually be marked bad. A default-on setting isn't safe yet because the current Fedora and CentOS SELinux policies deny systemd-bless-boot renaming files on the ESP, so new deployments are never marked good and fall back after N boots; the tmt test adds a local policy module for that. GRUB has no boot counting, so nothing changes there, and the initial install isn't counted as there is nothing to fall back to. Generated-by: AI --- crates/lib/src/bootc_composefs/boot.rs | 62 ++++--- .../lib/src/bootc_composefs/boot_counting.rs | 127 ++++++++++++++ crates/lib/src/bootc_composefs/update.rs | 10 ++ docs/src/bootc-boot-failure-detection.7.md | 25 ++- tmt/plans/integration.fmf | 13 ++ .../booted/test-composefs-boot-counting.nu | 158 ++++++++++++++++++ tmt/tests/tests.fmf | 9 + 7 files changed, 376 insertions(+), 28 deletions(-) create mode 100644 tmt/tests/booted/test-composefs-boot-counting.nu diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7cf75d11f..394a96d59 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -64,6 +64,7 @@ use std::cell::Cell; use std::fs::create_dir_all; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +use std::num::NonZeroU32; use std::os::fd::AsFd; use std::path::Path; use std::sync::Arc; @@ -102,6 +103,7 @@ use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::bootc_composefs::boot_counting::with_boot_tries; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons}; @@ -831,6 +833,7 @@ pub(crate) fn setup_composefs_bls_boot( format_version: FormatVersion, entry: &ComposefsBootEntry, mounted_erofs: &Dir, + boot_tries: Option, ) -> Result { let id_hex = id.to_hex(); @@ -1092,7 +1095,10 @@ pub(crate) fn setup_composefs_bls_boot( .with_context(|| format!("Opening {config_path:?}"))?; loader_entries_dir.atomic_write( - type1_entry_conf_file_name(&os_id, &bls_config.version(), FILENAME_PRIORITY_PRIMARY), + with_boot_tries( + type1_entry_conf_file_name(&os_id, &bls_config.version(), FILENAME_PRIORITY_PRIMARY), + boot_tries, + ), bls_config.to_string().as_bytes(), )?; @@ -1869,18 +1875,20 @@ fn write_grub_uki_menuentry( fn write_systemd_uki_config( esp_dir: &Dir, setup_type: &BootSetupType, - boot_label: String, - version: Option, - os_id: Option, - id: &Sha512HashValue, + uki_info: &UKIInfo, bootloader: &Bootloader, + boot_tries: Option, ) -> Result<()> { - let os_id = os_id.as_deref().unwrap_or("bootc"); + let id = uki_info + .composefs_digest + .as_ref() + .context("No composefs cmdline found in UKI or UKI Addons")?; + let os_id = uki_info.os_id.as_deref().unwrap_or("bootc"); let primary_sort_key = primary_sort_key(os_id); let mut bls_conf = BLSConfig::default(); bls_conf - .with_title(boot_label) + .with_title(uki_info.boot_label.clone()) .with_cfg(BLSConfigType::EFI { key: EFIKey::for_bootloader( format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(), @@ -1888,7 +1896,7 @@ fn write_systemd_uki_config( ), }) .with_sort_key(primary_sort_key.clone()) - .with_version(version.unwrap_or_else(|| id.to_hex())); + .with_version(uki_info.version.clone().unwrap_or_else(|| id.to_hex())); let (entries_dir, booted_bls) = match setup_type { BootSetupType::Setup(..) => { @@ -1913,7 +1921,10 @@ fn write_systemd_uki_config( entries_dir .atomic_write( - type1_entry_conf_file_name(os_id, &bls_conf.version(), FILENAME_PRIORITY_PRIMARY), + with_boot_tries( + type1_entry_conf_file_name(os_id, &bls_conf.version(), FILENAME_PRIORITY_PRIMARY), + boot_tries, + ), bls_conf.to_string().as_bytes(), ) .context("Writing conf file")?; @@ -1947,6 +1958,7 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha512HashValue, boot_ids: &ExpectedBootImageIds, entries: Vec>, + boot_tries: Option, ) -> Result<(String, Sha512HashValue)> { let ( esp_device, @@ -2175,7 +2187,7 @@ pub(crate) fn setup_composefs_uki_boot( }; } - let Some(deploy_id) = uki_info.composefs_digest else { + let Some(deploy_id) = uki_info.composefs_digest.as_ref() else { anyhow::bail!("No composefs cmdline found in UKI or UKI Addons"); }; @@ -2183,14 +2195,6 @@ pub(crate) fn setup_composefs_uki_boot( anyhow::bail!("Failed to get boot label from UKI"); } - let UKIInfo { - boot_label, - version, - os_id, - boot_digest, - .. - } = uki_info; - let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); // Create a `ESP/loader/addons.staged` if we have Global UKI Addons to update @@ -2199,26 +2203,27 @@ pub(crate) fn setup_composefs_uki_boot( } for entry in entries_to_write { - write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path(), is_upgrade)?; + write_pe_to_esp(repo, &entry, deploy_id, esp_mount.dir.path(), is_upgrade)?; } match bootloader.kind()? { - BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, &esp_device)? - } + BootloaderKind::GRUBClassic => write_grub_uki_menuentry( + &setup_type, + uki_info.boot_label.clone(), + deploy_id, + &esp_device, + )?, BootloaderKind::BLSCompatible => write_systemd_uki_config( &esp_mount.fd, &setup_type, - boot_label, - version, - os_id, - &deploy_id, + &uki_info, &bootloader, + boot_tries, )?, }; - Ok((boot_digest, deploy_id)) + Ok((uki_info.boot_digest, deploy_id.clone())) } /// A composefs image attached to a temporary directory with the ESP and a @@ -2908,6 +2913,8 @@ pub(crate) async fn setup_composefs_boot( provisional_format, entry, &image_root, + // A fresh install has nothing to fall back to + None, )?, provisional_deploy_id, ), @@ -2918,6 +2925,7 @@ pub(crate) async fn setup_composefs_boot( &provisional_deploy_id, &boot_ids, entries, + None, ), &repo, &fs, diff --git a/crates/lib/src/bootc_composefs/boot_counting.rs b/crates/lib/src/bootc_composefs/boot_counting.rs index 75737aa95..05936e920 100644 --- a/crates/lib/src/bootc_composefs/boot_counting.rs +++ b/crates/lib/src/bootc_composefs/boot_counting.rs @@ -10,6 +10,23 @@ //! systemd-boot sorts it after all other entries, so the next boot falls back //! to the previous deployment. +use std::num::NonZeroU32; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; + +use crate::spec::Bootloader; + +/// The kernel-install(8) configuration file for the number of boot attempts, +/// relative to the root. Boot counting is only enabled if it exists. +const KERNEL_TRIES_PATH: &str = "etc/kernel/tries"; + +/// The service that marks a booted entry as good; relative to the root. +/// Without it an entry would count down to "bad" on every boot, so boot +/// counting is only enabled for images that ship it. +const BLESS_BOOT_PATH: &str = "usr/lib/systemd/systemd-bless-boot"; + const ENTRY_SUFFIX: &str = ".conf"; /// Parse the boot attempts left from a Type #1 entry file name with a boot @@ -42,9 +59,64 @@ pub(crate) fn entry_is_bad(file_name: &str) -> bool { boot_attempts_left(file_name) == Some(0) } +/// Add a boot counter with `tries` attempts to a Type #1 entry file name. +pub(crate) fn with_boot_tries(file_name: String, tries: Option) -> String { + match (tries, file_name.strip_suffix(ENTRY_SUFFIX)) { + (Some(tries), Some(stem)) => format!("{stem}+{tries}{ENTRY_SUFFIX}"), + _ => file_name, + } +} + +/// Determine how many boot attempts a new deployment's entry gets, or `None` +/// to not count its boots. +/// +/// Like kernel-install, this is opt-in: the count is read from +/// `/etc/kernel/tries` of the running system, and without that file (or with +/// `0` in it) boots aren't counted. Counting is also only done for +/// systemd-boot, and only when the new image ships `systemd-bless-boot`. +#[fn_error_context::context("Determining boot counting tries")] +pub(crate) fn boot_tries_for_deployment( + bootloader: &Bootloader, + host_root: &Dir, + new_root: &Dir, +) -> Result> { + if !matches!(bootloader, Bootloader::Systemd) { + return Ok(None); + } + + let Some(contents) = host_root + .read_to_string_optional(KERNEL_TRIES_PATH) + .with_context(|| format!("Reading /{KERNEL_TRIES_PATH}"))? + else { + return Ok(None); + }; + + // Like a missing file, a broken one shouldn't block updates + let Ok(tries) = contents.trim().parse::() else { + tracing::warn!( + "Not counting boots: /{KERNEL_TRIES_PATH} should hold a number of boot attempts, found {:?}", + contents.trim() + ); + return Ok(None); + }; + + if !new_root + .try_exists(BLESS_BOOT_PATH) + .with_context(|| format!("Querying {BLESS_BOOT_PATH}"))? + { + tracing::warn!( + "Not counting boots despite /{KERNEL_TRIES_PATH}: the new image lacks /{BLESS_BOOT_PATH}" + ); + return Ok(None); + } + + Ok(NonZeroU32::new(tries)) +} + #[cfg(test)] mod tests { use super::*; + use cap_std_ext::cap_std; #[test] fn test_boot_attempts_left() { @@ -67,4 +139,59 @@ mod tests { assert_eq!(entry_is_bad(name), expected == Some(0), "{name}"); } } + + #[test] + fn test_with_boot_tries() { + let three = NonZeroU32::new(3); + let name = with_boot_tries("a-1.conf".into(), three); + assert_eq!(name, "a-1+3.conf"); + assert_eq!(boot_attempts_left(&name), Some(3)); + assert_eq!(with_boot_tries("a-1.conf".into(), None), "a-1.conf"); + } + + #[test] + fn test_boot_tries_for_deployment() -> Result<()> { + let host = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + let image = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let three = NonZeroU32::new(3); + host.create_dir_all("etc/kernel")?; + host.write(KERNEL_TRIES_PATH, "3\n")?; + + // No systemd-bless-boot in the image + assert_eq!( + boot_tries_for_deployment(&Bootloader::Systemd, &host, &image)?, + None + ); + + image.create_dir_all("usr/lib/systemd")?; + image.write(BLESS_BOOT_PATH, "")?; + assert_eq!( + boot_tries_for_deployment(&Bootloader::Systemd, &host, &image)?, + three + ); + // Only systemd-boot does boot counting + for bootloader in [Bootloader::Grub, Bootloader::GrubCC, Bootloader::None] { + assert_eq!(boot_tries_for_deployment(&bootloader, &host, &image)?, None); + } + + // Opt-in via /etc/kernel/tries + host.remove_file(KERNEL_TRIES_PATH)?; + assert_eq!( + boot_tries_for_deployment(&Bootloader::Systemd, &host, &image)?, + None + ); + + // A malformed file is warned about and ignored + for (contents, expected) in [("5\n", NonZeroU32::new(5)), ("0", None), ("three", None)] { + host.write(KERNEL_TRIES_PATH, contents)?; + assert_eq!( + boot_tries_for_deployment(&Bootloader::Systemd, &host, &image)?, + expected, + "{contents:?}" + ); + } + + Ok(()) + } } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 7fc373d9b..e935c6370 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -13,6 +13,7 @@ use fn_error_context::context; use ocidir::cap_std::ambient_authority; use ostree_ext::container::ManifestDiff; +use crate::bootc_composefs::boot_counting::boot_tries_for_deployment; use crate::bootc_composefs::finalize::get_etc_diff; use crate::bootc_composefs::gc::GCOpts; use crate::delta::Delta; @@ -402,6 +403,13 @@ pub(crate) async fn do_upgrade( anyhow::bail!("Merge conflicts found in etc"); } + let host_root = Dir::open_ambient_dir("/", ambient_authority()).context("Opening /")?; + let boot_tries = boot_tries_for_deployment( + &host.require_composefs_booted()?.bootloader, + &host_root, + &mounted_fs, + )?; + let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); let mut aboot_artifacts = None; @@ -414,6 +422,7 @@ pub(crate) async fn do_upgrade( provisional_format, entry, &mounted_fs, + boot_tries, )?, provisional_deploy_id, ), @@ -425,6 +434,7 @@ pub(crate) async fn do_upgrade( &provisional_deploy_id, &boot_ids, entries, + boot_tries, ), &repo, &oci_fs, diff --git a/docs/src/bootc-boot-failure-detection.7.md b/docs/src/bootc-boot-failure-detection.7.md index 3f1adcd84..7fa5ba9c6 100644 --- a/docs/src/bootc-boot-failure-detection.7.md +++ b/docs/src/bootc-boot-failure-detection.7.md @@ -44,7 +44,30 @@ journalctl -u bootc-finalize-staged.service -b -1 As of a recent OSTree with [this commit](https://github.com/ostreedev/ostree/commit/08487091256b93493f8d692e37ab3d892c758da1) it is possible to configure the boot loader entry counting. -At the current time, the composefs backend does not configure boot entry counting, this is likely to be added in the future. +With systemd-boot, the composefs backend supports +[boot counting](https://uapi-group.org/specifications/specs/boot_loader_specification/#boot-counting). +As with `kernel-install`, it is enabled by writing the number of boot attempts +to `/etc/kernel/tries`; `0` or no file turns it off. The file is read from the +booted system when a deployment is staged, so when it is shipped in the +container image, the first deployment of an image with it isn't counted yet. +The boot entry of a staged deployment then gets that many attempts +(e.g. `bootc_fedora-42-1+3.conf`), and once the new deployment reaches +`boot-complete.target`, `systemd-bless-boot.service` marks it good. If it fails +to get there on every attempt, systemd-boot boots the previous deployment +instead, and `bootc status` shows the failed deployment as the rollback. Units +that must succeed for a boot to count as good can be ordered before +`boot-complete.target`; see +[Automatic Boot Assessment](https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT/). +The target image must ship `systemd-bless-boot`. The initial deployment written +by `bootc install`, and entries rewritten by `bootc rollback`, are not counted. + +Note that the SELinux policy in current Fedora and CentOS Stream releases does not +allow `systemd-bless-boot` (running as `init_t`) to rename entries on the ESP +(`dosfs_t`), so without a local policy module granting that, new deployments +are never marked good, and after N boots systemd-boot falls back to the +previous deployment. + +GRUB has no support for boot counting, so it is not enabled there. ## See Also diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 1ce612e6a..6d90a94b5 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -376,6 +376,19 @@ execute: test: - /tmt/tests/tests/test-52-install-repart +/plan-53-composefs-boot-counting: + summary: Test composefs boot counting with systemd-boot + discover: + how: fmf + test: + - /tmt/tests/tests/test-53-composefs-boot-counting + adjust: + - when: distro == centos-9 and boot_type == uki and seal_state == sealed + enabled: false + because: CentOS 9 cannot consume the signed host upgrade because shared storage is unavailable and its guest-local UKI builder produces unsigned images + extra-try_bind_storage: true + extra-skip_if_ostree: true + /plan-57-composefs-separate-boot: summary: Test composefs with GRUB and a separate /boot partition discover: diff --git a/tmt/tests/booted/test-composefs-boot-counting.nu b/tmt/tests/booted/test-composefs-boot-counting.nu new file mode 100644 index 000000000..c3024ada9 --- /dev/null +++ b/tmt/tests/booted/test-composefs-boot-counting.nu @@ -0,0 +1,158 @@ +# number: 53 +# extra: +# try_bind_storage: true +# skip_if_ostree: true +# tmt: +# summary: Test composefs boot counting with systemd-boot +# duration: 30m +# adjust: +# - when: distro == centos-9 and boot_type == uki and seal_state == sealed +# enabled: false +# because: CentOS 9 cannot consume the signed host upgrade because shared storage is unavailable and its guest-local UKI builder produces unsigned images +# +# Verify automatic boot assessment for composefs deployments with systemd-boot: +# 1. With /etc/kernel/tries, a staged deployment's boot entry gets a "+3" counter +# 2. After booting it, systemd-bless-boot marks the entry good +# 3. An entry that ran out of boot attempts sorts last, so status reports +# a rollback as queued, and systemd-boot boots the previous deployment + +use std assert +use tap.nu + +bootc status +journalctl --list-boots + +let st = bootc status --json | from json +let booted = $st.status.booted.image +let bootloader = ($st.status.booted.composefs.bootloader | str downcase) + +const ESP = "/var/tmp/efi" +const TRIES = 3 + +def imgsrc [] { + $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" +} + +def mount_esp [] { + mkdir $ESP + if (do -i { findmnt $ESP } | complete).exit_code != 0 { + mount /dev/disk/by-partlabel/EFI-SYSTEM $ESP + } +} + +# The .conf files in an entries directory, with their contents +def entries [dir: string] { + glob $"($ESP)/loader/($dir)/*.conf" | each { |p| + { name: ($p | path basename), path: $p, contents: (open --raw $p) } + } +} + +# The entry booting a deployment: its composefs karg (Type #1) or UKI path. +# A Type #1 entry may reuse another deployment's kernel, so ignore +# `/bootc_composefs-/vmlinuz` paths. +def entry_for [dir: string, verity: string] { + let found = entries $dir | where { |e| $e.contents =~ $"($verity)\(\\s|$|\\.efi\)" } + assert equal ($found | length) 1 $"expected one entry in ($dir) for ($verity): ($found)" + $found | first +} + +def first_boot [] { + tap begin "composefs boot counting" + + let imgsrc = imgsrc + if ($imgsrc | str ends-with "-local") { + bootc image copy-to-storage + ( + tap make_uki_containerfile " + FROM localhost/bootc as base + RUN touch /usr/share/testing-bootc-boot-counting + " + ) | save Dockerfile + podman build -t $imgsrc . + } + + $st.status.booted.composefs.verity | save /var/original-verity + + # Boot counting is opt-in, like with kernel-install + mkdir /etc/kernel + $"($TRIES)\n" | save -f /etc/kernel/tries + + # The Fedora and CentOS SELinux policies don't let systemd-bless-boot + # (init_t) rename entries on the ESP. + "(allow init_t dosfs_t (file (rename)))" | save -f /var/tmp/bless-boot.cil + semodule -i /var/tmp/bless-boot.cil + + bootc switch --transport containers-storage $imgsrc + let staged = (bootc status --json | from json).status.staged.composefs.verity + + mount_esp + let staged_entry = entry_for "entries.staged" $staged + print $"staged entry: ($staged_entry.name)" + assert ($staged_entry.name | str ends-with $"+($TRIES).conf") "new deployment's entry should have a boot counter" + + # The currently booted deployment is known good, so it isn't counted + let booted_entry = entry_for "entries.staged" $st.status.booted.composefs.verity + assert (not ($booted_entry.name | str contains "+")) $"booted entry should not be counted: ($booted_entry.name)" + + tmt-reboot +} + +def second_boot [] { + assert equal $booted.image.image (imgsrc) + + # systemd-bless-boot runs after boot-complete.target; wait for it. + let blessed = (seq 1 60 | each { |_| + if (systemctl is-active systemd-bless-boot.service | complete).stdout =~ "^active" { + true + } else { + sleep 2sec + false + } + } | any { |x| $x }) + if not $blessed { + systemctl status systemd-bless-boot.service + journalctl -b -u systemd-bless-boot.service + error make { msg: "systemd-bless-boot.service did not succeed" } + } + + mount_esp + let verity = $st.status.booted.composefs.verity + let entry = entry_for "entries" $verity + print $"booted entry: ($entry.name)" + assert (not ($entry.name | str contains "+")) $"booted entry should be blessed: ($entry.name)" + + # Simulate a deployment that failed to boot three times + let bad_name = ($entry.name | str replace ".conf" $"+0-($TRIES).conf") + mv $entry.path $"($ESP)/loader/entries/($bad_name)" + sync + + # systemd-boot will skip the bad entry, and status must say so + let st = bootc status --json | from json + assert equal $st.status.rollbackQueued true + assert equal $st.status.rollback.composefs.verity (open /var/original-verity) + + tmt-reboot +} + +def third_boot [] { + let original = open /var/original-verity + assert equal $st.status.booted.composefs.verity $original "systemd-boot should fall back to the previous deployment" + assert equal $st.status.rollbackQueued false + assert equal $st.status.rollback.image.image.image (imgsrc) + + tap ok +} + +def main [] { + if $bootloader != "systemd" { + print $"Boot counting is only implemented for systemd-boot, not ($bootloader)" + return + } + + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index c22858d18..17b676be8 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -238,6 +238,15 @@ check: duration: 30m test: nu booted/test-install-repart.nu +/test-53-composefs-boot-counting: + summary: Test composefs boot counting with systemd-boot + duration: 30m + adjust: + - when: distro == centos-9 and boot_type == uki and seal_state == sealed + enabled: false + because: CentOS 9 cannot consume the signed host upgrade because shared storage is unavailable and its guest-local UKI builder produces unsigned images + test: nu booted/test-composefs-boot-counting.nu + /test-57-composefs-separate-boot: summary: Test composefs with GRUB and a separate /boot partition duration: 60m