Skip to content
Draft
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
73 changes: 45 additions & 28 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -537,13 +539,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.
Expand Down Expand Up @@ -826,6 +833,7 @@ pub(crate) fn setup_composefs_bls_boot(
format_version: FormatVersion,
entry: &ComposefsBootEntry<Sha512HashValue>,
mounted_erofs: &Dir,
boot_tries: Option<NonZeroU32>,
) -> Result<String> {
let id_hex = id.to_hex();

Expand Down Expand Up @@ -1087,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(),
)?;

Expand Down Expand Up @@ -1864,26 +1875,28 @@ fn write_grub_uki_menuentry(
fn write_systemd_uki_config(
esp_dir: &Dir,
setup_type: &BootSetupType,
boot_label: String,
version: Option<String>,
os_id: Option<String>,
id: &Sha512HashValue,
uki_info: &UKIInfo,
bootloader: &Bootloader,
boot_tries: Option<NonZeroU32>,
) -> 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(),
bootloader,
),
})
.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(..) => {
Expand All @@ -1908,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")?;
Expand Down Expand Up @@ -1942,6 +1958,7 @@ pub(crate) fn setup_composefs_uki_boot(
id: &Sha512HashValue,
boot_ids: &ExpectedBootImageIds,
entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
boot_tries: Option<NonZeroU32>,
) -> Result<(String, Sha512HashValue)> {
let (
esp_device,
Expand Down Expand Up @@ -2170,22 +2187,14 @@ 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");
};

if uki_info.boot_label.is_empty() {
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
Expand All @@ -2194,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
Expand Down Expand Up @@ -2903,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,
),
Expand All @@ -2913,6 +2925,7 @@ pub(crate) async fn setup_composefs_boot(
&provisional_deploy_id,
&boot_ids,
entries,
None,
),
&repo,
&fs,
Expand Down Expand Up @@ -3315,6 +3328,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]
Expand Down
197 changes: 197 additions & 0 deletions crates/lib/src/bootc_composefs/boot_counting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//! Automatic boot assessment ("boot counting") for systemd-boot entries.
//!
//! See <https://uapi-group.org/specifications/specs/boot_loader_specification/#boot-counting>
//! and <https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT/>.
//!
//! A counted entry is named `<name>+<tries>.conf`. On each boot attempt
//! systemd-boot renames it to `<name>+<left>-<done>.conf`, and once
//! `boot-complete.target` is reached `systemd-bless-boot.service` renames it
//! to plain `<name>.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.

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
/// 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<u32> {
// Unlike u32::from_str, don't accept a leading '+'
let parse = |s: &str| -> Option<u32> {
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)
}

/// 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<NonZeroU32>) -> 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<Option<NonZeroU32>> {
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::<u32>() 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() {
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}");
}
}

#[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(())
}
}
Loading