From 913368f67ac5d4c163f296bc5ac342277a887b74 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Tue, 14 Apr 2026 14:53:22 -0400 Subject: [PATCH] loader-entries: Add `set-options-for-source` for source-tracked kargs Add a new `bootc loader-entries set-options-for-source` command that manages kernel arguments from independent sources (e.g. TuneD, admin) by tracking ownership via `x-options-source-` extension keys in BLS config files. This solves the problem of karg accumulation on bootc systems with transient /etc, where tools like TuneD lose their state files on reboot and cannot track which kargs they previously set. The command stages a new deployment with the updated kargs and source keys. The kargs diff is computed by removing the old source's args and adding the new ones, preserving all untracked options. Source keys survive the staging roundtrip via ostree's `bootconfig-extra` serialization (ostree >= 2026.1, version check active). Staged deployment handling: - No staged deployment: stages based on the booted commit - Staged deployment exists (e.g. from `bootc upgrade`): replaces it using the staged commit and origin, preserving pending upgrades while layering the source kargs change on top Includes a multi-reboot TMT integration test covering: input validation, kargs surviving staging roundtrip, source replacement, multiple sources coexisting (including pre-reboot), source removal, idempotency, system kargs preservation, empty --options vs no --options, and staged deployment interaction with bootc switch. Example usage: bootc loader-entries set-options-for-source --source tuned \ --options "isolcpus=1-3 nohz_full=1-3" See: https://github.com/ostreedev/ostree/pull/3570 See: https://github.com/bootc-dev/bootc/issues/899 Assisted-by: OpenCode (Claude claude-opus-4-6) --- crates/lib/src/cli.rs | 65 +++ crates/lib/src/lib.rs | 1 + crates/lib/src/loader_entries.rs | 506 ++++++++++++++++++ ...loader-entries-set-options-for-source.8.md | 81 +++ docs/src/man/bootc-loader-entries.8.md | 33 ++ docs/src/man/bootc.8.md | 1 + tmt/plans/integration.fmf | 17 +- .../booted/test-loader-entries-source.nu | 266 +++++++++ tmt/tests/tests.fmf | 10 + 9 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 crates/lib/src/loader_entries.rs create mode 100644 docs/src/man/bootc-loader-entries-set-options-for-source.8.md create mode 100644 docs/src/man/bootc-loader-entries.8.md create mode 100644 tmt/tests/booted/test-loader-entries-source.nu diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b1752c2c2..e4fa2a70c 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -716,6 +716,54 @@ pub(crate) enum InternalsOpts { }, } +/// Options for the `set-options-for-source` subcommand. +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct SetOptionsForSourceOpts { + /// The name of the source that owns these kernel arguments. + /// + /// Must contain only alphanumeric characters, hyphens, or underscores. + /// Examples: "tuned", "admin", "bootc-kargs-d" + #[clap(long)] + pub(crate) source: String, + + /// The kernel arguments to set for this source. + /// + /// If not provided, the source is removed and its options are + /// dropped from the merged `options` line. + #[clap(long)] + pub(crate) options: Option, +} + +/// Operations on Boot Loader Specification (BLS) entries. +/// +/// These commands support managing kernel arguments from multiple independent +/// sources (e.g., TuneD, admin, bootc kargs.d) by tracking argument ownership +/// via `x-options-source-` extension keys in BLS config files. +/// +/// See +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum LoaderEntriesOpts { + /// Set or update the kernel arguments owned by a specific source. + /// + /// Each source's arguments are tracked via `x-options-source-` + /// keys in BLS config files. The `options` line is recomputed as the + /// merge of all tracked sources plus any untracked (pre-existing) options. + /// + /// This stages a new deployment with the updated kernel arguments. + /// + /// ## Examples + /// + /// Add TuneD kernel arguments: + /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=1-3 nohz_full=1-3" + /// + /// Update TuneD kernel arguments: + /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=0-7" + /// + /// Remove TuneD kernel arguments: + /// bootc loader-entries set-options-for-source --source tuned + SetOptionsForSource(SetOptionsForSourceOpts), +} + #[derive(Debug, clap::Subcommand, PartialEq, Eq)] pub(crate) enum StateOpts { /// Remove all ostree deployments from this system @@ -821,6 +869,11 @@ pub(crate) enum Opt { /// Stability: This interface may change in the future. #[clap(subcommand, hide = true)] Image(ImageOpts), + /// Operations on Boot Loader Specification (BLS) entries. + /// + /// Manage kernel arguments from multiple independent sources. + #[clap(subcommand)] + LoaderEntries(LoaderEntriesOpts), /// Execute the given command in the host mount namespace #[clap(hide = true)] ExecInHostMountNamespace { @@ -1899,6 +1952,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> { crate::install::install_finalize(&root_path).await } }, + Opt::LoaderEntries(opts) => match opts { + LoaderEntriesOpts::SetOptionsForSource(opts) => { + let storage = get_storage().await?; + let sysroot = storage.get_ostree()?; + crate::loader_entries::set_options_for_source_staged( + sysroot, + &opts.source, + opts.options.as_deref(), + )?; + Ok(()) + } + }, Opt::ExecInHostMountNamespace { args } => { crate::install::exec_in_host_mountns(args.as_slice()) } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 558ca8718..69544ad6e 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -82,6 +82,7 @@ pub(crate) mod journal; mod k8sapitypes; mod kernel; mod lints; +mod loader_entries; mod lsm; pub(crate) mod metadata; mod parsers; diff --git a/crates/lib/src/loader_entries.rs b/crates/lib/src/loader_entries.rs new file mode 100644 index 000000000..34da2fea5 --- /dev/null +++ b/crates/lib/src/loader_entries.rs @@ -0,0 +1,506 @@ +//! # Boot Loader Specification entry management +//! +//! This module implements support for merging disparate kernel argument sources +//! into the single BLS entry `options` field. Each source (e.g., TuneD, admin, +//! bootc kargs.d) can independently manage its own set of kernel arguments, +//! which are tracked via `x-options-source-` extension keys in BLS config +//! files. +//! +//! See +//! See + +use anyhow::{Context, Result, ensure}; +use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned}; +use cap_std_ext::cap_std; +use fn_error_context::context; +use ostree::gio; +use ostree_ext::ostree; +use std::collections::BTreeMap; + +/// The BLS extension key prefix for source-tracked options. +const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-"; + +/// A validated source name (alphanumeric + hyphens + underscores, non-empty). +/// +/// This is a newtype wrapper around `String` that enforces validation at +/// construction time. See . +struct SourceName(String); + +impl SourceName { + /// Parse and validate a source name. + fn parse(source: &str) -> Result { + ensure!(!source.is_empty(), "Source name must not be empty"); + ensure!( + source + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "Source name must contain only alphanumeric characters, hyphens, or underscores" + ); + Ok(Self(source.to_owned())) + } + + /// The BLS key for this source (e.g., `x-options-source-tuned`). + fn bls_key(&self) -> String { + format!("{OPTIONS_SOURCE_KEY_PREFIX}{}", self.0) + } +} + +impl std::ops::Deref for SourceName { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SourceName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Extract source options from BLS entry content. Parses `x-options-source-*` keys +/// from the raw BLS text since the ostree BootconfigParser doesn't expose key iteration. +fn extract_source_options_from_bls(content: &str) -> BTreeMap { + let mut sources = BTreeMap::new(); + for line in content.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) else { + continue; + }; + let Some((source_name, value)) = rest.split_once(|c: char| c.is_ascii_whitespace()) else { + continue; + }; + let value = value.trim(); + if source_name.is_empty() || value.is_empty() { + continue; + } + sources.insert( + source_name.to_string(), + CmdlineOwned::from(value.to_string()), + ); + } + sources +} + +/// Compute the merged `options` line from all sources. +/// +/// The algorithm: +/// 1. Start with the current options line +/// 2. Remove all options that belong to the old value of the specified source +/// 3. Add the new options for the specified source +/// +/// Options not tracked by any source are preserved as-is. +fn compute_merged_options( + current_options: &str, + source_options: &BTreeMap, + target_source: &SourceName, + new_options: Option<&str>, +) -> CmdlineOwned { + let mut merged = CmdlineOwned::from(current_options.to_owned()); + + // Remove old options from the target source (if it was previously tracked) + if let Some(old_source_opts) = source_options.get(&**target_source) { + for param in old_source_opts.iter() { + merged.remove_exact(¶m); + } + } + + // Add new options for the target source + if let Some(new_opts) = new_options.filter(|v| !v.is_empty()) { + let new_cmdline = Cmdline::from(new_opts); + for param in new_cmdline.iter() { + merged.add(¶m); + } + } + + merged +} + +/// Read the BLS entry file content for a deployment from /boot/loader/entries/. +/// +/// Returns `Ok(Some(content))` if the entry is found, `Ok(None)` if no matching +/// entry exists, or `Err` if there's an I/O error. +/// +/// We match by checking the `options` line for the deployment's ostree path +/// (which includes the stateroot, bootcsum, and bootserial). +fn read_bls_entry_for_deployment(deployment: &ostree::Deployment) -> Result> { + let sysroot_dir = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let entries_dir = sysroot_dir + .open_dir("boot/loader/entries") + .context("Opening boot/loader/entries")?; + + // Build the expected ostree= value from the deployment to match against. + // The ostree= karg format is: /ostree/boot.N/$stateroot/$bootcsum/$bootserial + // where bootcsum is the boot checksum and bootserial is the serial among + // deployments sharing the same bootcsum (NOT the deployserial). + let stateroot = deployment.stateroot(); + let bootserial = deployment.bootserial(); + let bootcsum = deployment.bootcsum(); + let ostree_match = format!("/{stateroot}/{bootcsum}/{bootserial}"); + + for entry in entries_dir.entries_utf8()? { + let entry = entry?; + let file_name = entry.file_name()?; + + if !file_name.starts_with("ostree-") || !file_name.ends_with(".conf") { + continue; + } + let content = entries_dir + .read_to_string(&file_name) + .with_context(|| format!("Reading BLS entry {file_name}"))?; + // Match by parsing the ostree= karg from the options line and checking + // that its path ends with our deployment's stateroot/bootcsum/bootserial. + // A simple `contains` would be fragile (e.g., serial 0 vs 01). + if content.lines().any(|line| { + line.starts_with("options ") + && line.split_ascii_whitespace().any(|arg| { + arg.strip_prefix("ostree=") + .is_some_and(|path| path.ends_with(&ostree_match)) + }) + }) { + return Ok(Some(content)); + } + } + + Ok(None) +} + +/// Set the kernel arguments for a specific source via ostree staged deployment. +/// +/// If no staged deployment exists, this stages a new deployment based on +/// the booted deployment's commit with the updated kargs. If a staged +/// deployment already exists (e.g. from `bootc upgrade`), it is replaced +/// with a new one using the staged commit and origin, preserving any +/// pending upgrade while layering the source kargs change on top. +/// +/// The `x-options-source-*` keys survive the staging roundtrip via the +/// ostree `bootconfig-extra` serialization: source keys are set on the +/// merge deployment's in-memory bootconfig before staging, ostree inherits +/// them during `stage_tree_with_options()`, serializes them into the staged +/// GVariant, and restores them at shutdown during finalization. +#[context("Setting options for source '{source}' (staged)")] +pub(crate) fn set_options_for_source_staged( + sysroot: &ostree_ext::sysroot::SysrootLock, + source: &str, + new_options: Option<&str>, +) -> Result<()> { + let source = SourceName::parse(source)?; + + // The bootconfig-extra serialization (preserving x-prefixed BLS keys through + // staged deployment roundtrips) was added in ostree 2026.1. Without it, + // source keys are silently dropped during finalization at shutdown. + if !ostree::check_version(2026, 1) { + anyhow::bail!("This feature requires ostree >= 2026.1 for bootconfig-extra support"); + } + + let booted = sysroot + .booted_deployment() + .ok_or_else(|| anyhow::anyhow!("Not booted into an ostree deployment"))?; + + // Determine the "base" deployment whose kargs and source keys we start from. + // If there's already a staged deployment (e.g. from `bootc upgrade`), we use + // its commit, origin, and kargs so we don't discard a pending upgrade. If no + // staged deployment exists, we use the booted deployment. + let staged = sysroot.staged_deployment(); + let base_deployment = staged.as_ref().unwrap_or(&booted); + + let bootconfig = ostree::Deployment::bootconfig(base_deployment) + .ok_or_else(|| anyhow::anyhow!("Base deployment has no bootconfig"))?; + + // Read current options from the base deployment's bootconfig. + let current_options = bootconfig + .get("options") + .map(|s| s.to_string()) + .unwrap_or_default(); + + // Read existing x-options-source-* keys. + // + // Known limitation: when multiple *different* sources call set-options-for-source + // before rebooting (e.g., source A then source B), the second call can only + // discover source A if it was already in the booted BLS entry or is the target + // source. If source A was brand-new (added in a previous staged deployment that + // was never booted), its keys may not be discovered here and could be lost when + // the staged deployment is replaced. In practice, this is unlikely — sources + // like TuneD run at boot after finalization, so there's no staged deployment. + // A future improvement could store a manifest of active sources in a dedicated + // BLS key (e.g., x-bootc-active-sources) to enable full discovery. + let source_options = if staged.is_some() { + // For staged deployments, extract source keys from the in-memory bootconfig. + // We can't read a BLS file because it hasn't been written yet (finalization + // happens at shutdown). Discover source names from the booted BLS entry, + // then probe the staged bootconfig for their values. + let mut sources = BTreeMap::new(); + if let Some(bls_content) = + read_bls_entry_for_deployment(&booted).context("Reading booted BLS entry")? + { + let booted_sources = extract_source_options_from_bls(&bls_content); + for (name, _) in &booted_sources { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + if let Some(val) = bootconfig.get(&key) { + sources.insert(name.clone(), CmdlineOwned::from(val.to_string())); + } + } + } + // Also check the target source directly in the staged bootconfig. + // This handles the case where set-options-for-source was called + // multiple times before rebooting: the target source exists in the + // staged bootconfig but not in the booted BLS entry. + if !sources.contains_key(&*source) { + let target_key = source.bls_key(); + if let Some(val) = bootconfig.get(&target_key) { + if !val.is_empty() { + sources.insert(source.0.clone(), CmdlineOwned::from(val.to_string())); + } + } + } + sources + } else { + // For booted deployments, parse the BLS file directly + let bls_content = read_bls_entry_for_deployment(&booted) + .context("Reading booted BLS entry")? + .ok_or_else(|| anyhow::anyhow!("No BLS entry found for booted deployment"))?; + extract_source_options_from_bls(&bls_content) + }; + + // Compute merged options + let source_key = source.bls_key(); + let merged = compute_merged_options(¤t_options, &source_options, &source, new_options); + + // Check for idempotency: if nothing changed, skip staging. + // Compare the merged cmdline against the current one, and the source value. + let merged_str = merged.to_string(); + let is_options_unchanged = merged_str == current_options; + let is_source_unchanged = match (source_options.get(&*source), new_options) { + (Some(old), Some(new)) => &**old == new, + (None, None) | (None, Some("")) => true, + _ => false, + }; + + if is_options_unchanged && is_source_unchanged { + tracing::info!("No changes needed for source '{source}'"); + return Ok(()); + } + + // Use the base deployment's commit and origin so we don't discard a + // pending upgrade. The merge deployment is always the booted one (for + // /etc merge), but the commit/origin come from whichever deployment + // we're building on top of. + let stateroot = booted.stateroot(); + let merge_deployment = sysroot + .merge_deployment(Some(stateroot.as_str())) + .unwrap_or_else(|| booted.clone()); + + let origin = ostree::Deployment::origin(base_deployment) + .ok_or_else(|| anyhow::anyhow!("Base deployment has no origin"))?; + + let ostree_commit = base_deployment.csum(); + + // Update the source keys on the merge deployment's bootconfig BEFORE staging. + // The ostree patch (bootconfig-extra) inherits x-prefixed keys from the merge + // deployment's bootconfig during stage_tree_with_options(). By updating the + // merge deployment's in-memory bootconfig here, the updated source keys will + // be serialized into the staged GVariant and survive finalization at shutdown. + let merge_bootconfig = ostree::Deployment::bootconfig(&merge_deployment) + .ok_or_else(|| anyhow::anyhow!("Merge deployment has no bootconfig"))?; + + // Set all desired source keys on the merge bootconfig. + // First, clear any existing source keys that we know about by setting + // them to empty string. BootconfigParser has no remove() API, so "" + // acts as a tombstone. An empty x-options-source-* key is harmless: + // extract_source_options_from_bls will parse it as an empty value, + // and the idempotency check skips empty values (!val.is_empty()). + for (name, _) in &source_options { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + merge_bootconfig.set(&key, ""); + } + // Re-set the keys we want to keep (all except the one being removed) + for (name, value) in &source_options { + if name != &*source { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + merge_bootconfig.set(&key, &value.to_string()); + } + } + // Set the new/updated source key (if not removing) + if let Some(opts_str) = new_options { + merge_bootconfig.set(&source_key, opts_str); + } + + // Build kargs as string slices for the ostree API + let kargs_strs: Vec = merged.iter_str().map(|s| s.to_string()).collect(); + let kargs_refs: Vec<&str> = kargs_strs.iter().map(|s| s.as_str()).collect(); + + let mut opts = ostree::SysrootDeployTreeOpts::default(); + opts.override_kernel_argv = Some(&kargs_refs); + + sysroot.stage_tree_with_options( + Some(stateroot.as_str()), + &ostree_commit, + Some(&origin), + Some(&merge_deployment), + &opts, + gio::Cancellable::NONE, + )?; + + tracing::info!("Staged deployment with updated kargs for source '{source}'"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_name_validation() { + // (input, should_succeed) + let cases = [ + ("tuned", true), + ("bootc-kargs-d", true), + ("my_source_123", true), + ("", false), + ("bad name", false), + ("bad/name", false), + ("bad.name", false), + ("foo@bar", false), + ]; + for (input, expect_ok) in cases { + let result = SourceName::parse(input); + assert_eq!( + result.is_ok(), + expect_ok, + "SourceName::parse({input:?}) should {}", + if expect_ok { "succeed" } else { "fail" } + ); + } + } + + #[test] + fn test_source_name_bls_key() { + let name = SourceName::parse("tuned").unwrap(); + assert_eq!(name.bls_key(), "x-options-source-tuned"); + } + + #[test] + fn test_extract_source_options_from_bls() { + let bls = "\ +title Fedora Linux 43 +version 6.8.0-300.fc40.x86_64 +linux /vmlinuz-6.8.0 +initrd /initramfs-6.8.0.img +options root=UUID=abc rw nohz=full isolcpus=1-3 rd.driver.pre=vfio-pci +x-options-source-tuned nohz=full isolcpus=1-3 +x-options-source-dracut rd.driver.pre=vfio-pci +"; + + let sources = extract_source_options_from_bls(bls); + assert_eq!(sources.len(), 2); + assert_eq!(&*sources["tuned"], "nohz=full isolcpus=1-3"); + assert_eq!(&*sources["dracut"], "rd.driver.pre=vfio-pci"); + } + + #[test] + fn test_extract_source_options_ignores_non_source_keys() { + let bls = "\ +title Test +version 1 +linux /vmlinuz +options root=UUID=abc +x-unrelated-key some-value +custom-key data +"; + + let sources = extract_source_options_from_bls(bls); + assert!(sources.is_empty()); + } + + #[test] + fn test_extract_source_options_ignores_empty_values() { + // Empty value (tombstone) should be filtered out + let bls = "\ +options root=UUID=abc +x-options-source-tuned +x-options-source-dracut +x-options-source-admin nohz=full +"; + + let sources = extract_source_options_from_bls(bls); + assert_eq!(sources.len(), 1); + assert_eq!(&*sources["admin"], "nohz=full"); + } + + #[test] + fn test_compute_merged_options() { + // Each case: (description, current_options, source_map, target_source, new_options, expected) + let cases: &[(&str, &str, &[(&str, &str)], &str, Option<&str>, &str)] = &[ + ( + "add new source", + "root=UUID=abc123 rw composefs=digest123", + &[], + "tuned", + Some("isolcpus=1-3 nohz_full=1-3"), + "root=UUID=abc123 rw composefs=digest123 isolcpus=1-3 nohz_full=1-3", + ), + ( + "update existing source", + "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3", + &[("tuned", "isolcpus=1-3 nohz_full=1-3")], + "tuned", + Some("isolcpus=0-7"), + "root=UUID=abc123 rw isolcpus=0-7", + ), + ( + "remove source (None)", + "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3", + &[("tuned", "isolcpus=1-3 nohz_full=1-3")], + "tuned", + None, + "root=UUID=abc123 rw", + ), + ( + "empty initial options", + "", + &[], + "tuned", + Some("isolcpus=1-3"), + "isolcpus=1-3", + ), + ( + "clear source with empty string", + "root=UUID=abc123 rw isolcpus=1-3", + &[("tuned", "isolcpus=1-3")], + "tuned", + Some(""), + "root=UUID=abc123 rw", + ), + ( + "preserves untracked options", + "root=UUID=abc123 rw quiet isolcpus=1-3", + &[("tuned", "isolcpus=1-3")], + "tuned", + Some("nohz=full"), + "root=UUID=abc123 rw quiet nohz=full", + ), + ( + "multiple sources, update one preserves others", + "root=UUID=abc rw isolcpus=1-3 rd.driver.pre=vfio-pci", + &[ + ("tuned", "isolcpus=1-3"), + ("dracut", "rd.driver.pre=vfio-pci"), + ], + "tuned", + Some("nohz=full"), + "root=UUID=abc rw rd.driver.pre=vfio-pci nohz=full", + ), + ]; + + for (desc, current, source_entries, target, new_opts, expected) in cases { + let mut sources = BTreeMap::new(); + for (name, value) in *source_entries { + sources.insert(name.to_string(), CmdlineOwned::from(value.to_string())); + } + let source = SourceName::parse(target).unwrap(); + let result = compute_merged_options(current, &sources, &source, *new_opts); + assert_eq!(&*result, *expected, "case: {desc}"); + } + } +} diff --git a/docs/src/man/bootc-loader-entries-set-options-for-source.8.md b/docs/src/man/bootc-loader-entries-set-options-for-source.8.md new file mode 100644 index 000000000..32f6a2f5a --- /dev/null +++ b/docs/src/man/bootc-loader-entries-set-options-for-source.8.md @@ -0,0 +1,81 @@ +# NAME + +bootc-loader-entries-set-options-for-source - Set or update the kernel arguments owned by a specific source + +# SYNOPSIS + +bootc loader-entries set-options-for-source **--source** *NAME* [**--options** *"KARGS"*] + +# DESCRIPTION + +Set or update the kernel arguments owned by a specific source. Each +source's arguments are tracked via `x-options-source-` extension +keys in BLS config files on `/boot`. The `options` line is recomputed +as the merge of all tracked sources plus any untracked (pre-existing) +options. + +This command stages a new deployment with the updated kernel arguments. +Changes take effect on the next reboot. + +When a staged deployment already exists (e.g. from `bootc upgrade`), +it is replaced using the staged deployment's commit and origin, +preserving the pending upgrade while layering the kargs change on top. + +# OPTIONS + + +**--source**=*SOURCE* + + The name of the source that owns these kernel arguments + +**--options**=*OPTIONS* + + The kernel arguments to set for this source + + + +# REQUIREMENTS + +This command requires ostree >= 2026.1 with `bootconfig-extra` support +for preserving extension BLS keys through staged deployment roundtrips. +On older ostree versions, the command will exit with an error. + +# EXAMPLES + +Add TuneD kernel arguments: + + bootc loader-entries set-options-for-source --source tuned \ + --options "isolcpus=1-3 nohz_full=1-3" + +Update TuneD kernel arguments (replaces previous values): + + bootc loader-entries set-options-for-source --source tuned \ + --options "isolcpus=0-7" + +Remove all kernel arguments owned by TuneD: + + bootc loader-entries set-options-for-source --source tuned + +Multiple sources can coexist independently: + + bootc loader-entries set-options-for-source --source tuned \ + --options "nohz=full isolcpus=1-3" + bootc loader-entries set-options-for-source --source dracut \ + --options "rd.driver.pre=vfio-pci" + +# KNOWN LIMITATIONS + +When multiple different sources call this command before rebooting, only +the target source and sources already known from the booted BLS entry +are discovered. A source added in a previous staged deployment that was +never booted may not be discovered, potentially orphaning its kargs. +In practice this is unlikely, as sources like TuneD run at boot after +finalization when no staged deployment exists. + +# SEE ALSO + +**bootc**(8), **bootc-loader-entries**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-loader-entries.8.md b/docs/src/man/bootc-loader-entries.8.md new file mode 100644 index 000000000..623cc40b4 --- /dev/null +++ b/docs/src/man/bootc-loader-entries.8.md @@ -0,0 +1,33 @@ +# NAME + +bootc-loader-entries - Operations on Boot Loader Specification (BLS) entries + +# SYNOPSIS + +bootc loader-entries *COMMAND* + +# DESCRIPTION + +Manage kernel arguments from multiple independent sources by tracking +argument ownership via `x-options-source-` extension keys in BLS +config files. + +This solves the problem of kernel argument accumulation on bootc systems +with transient `/etc`, where tools like TuneD lose their state files on +reboot and cannot track which kargs they previously set. + + + + +# COMMANDS + +**set-options-for-source** +: Set or update the kernel arguments owned by a specific source. + +# SEE ALSO + +**bootc**(8), **bootc-loader-entries-set-options-for-source**(8) + +# VERSION + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index 99e673afc..d543d0499 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -33,6 +33,7 @@ pulled and `bootc upgrade`. | **bootc usr-overlay** | Add a transient overlayfs on `/usr` | | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | +| **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | | **bootc composefs-finalize-staged** | | diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index d473b5c6c..1fb7be2b9 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -167,6 +167,13 @@ execute: test: - /tmt/tests/tests/test-32-install-to-filesystem-var-mount +/plan-32-multi-device-esp: + summary: Test multi-device ESP detection for to-existing-root + discover: + how: fmf + test: + - /tmt/tests/tests/test-32-multi-device-esp + /plan-33-bib-build: summary: Test building a qcow2 disk image with bootc-image-builder discover: @@ -240,14 +247,10 @@ execute: - /tmt/tests/tests/test-40-install-karg-delete extra-fixme_skip_if_composefs: true -/plan-41-multi-device-esp: - summary: Test multi-device ESP detection for to-existing-root - provision+: - hardware: - boot: - method: uefi +/plan-42-loader-entries-source: + summary: Test bootc loader-entries set-options-for-source discover: how: fmf test: - - /tmt/tests/test-41-multi-device-esp + - /tmt/tests/tests/test-42-loader-entries-source # END GENERATED PLANS diff --git a/tmt/tests/booted/test-loader-entries-source.nu b/tmt/tests/booted/test-loader-entries-source.nu new file mode 100644 index 000000000..33099ce68 --- /dev/null +++ b/tmt/tests/booted/test-loader-entries-source.nu @@ -0,0 +1,266 @@ +# number: 42 +# tmt: +# summary: Test bootc loader-entries set-options-for-source +# duration: 30m +# +# This test verifies the source-tracked kernel argument management via +# bootc loader-entries set-options-for-source. It covers: +# 1. Input validation (invalid/empty source names) +# 2. Adding source-tracked kargs and verifying they appear in /proc/cmdline +# 3. Kargs and x-options-source-* BLS keys surviving the staging roundtrip +# 4. Source replacement semantics (old kargs removed, new ones added) +# 5. Multiple sources coexisting independently +# 6. Source removal (--source without --options clears all owned kargs) +# 7. Idempotent operation (no changes when kargs already match) +# 8. Existing system kargs (root=, ostree=, etc.) preserved through changes +# 9. --options "" (empty string) clears kargs without removing the source +# 10. Staged deployment interaction (bootc switch + set-options-for-source +# preserves the pending image switch) +# +# Requires ostree with bootconfig-extra support (>= 2026.1). +# See: https://github.com/ostreedev/ostree/pull/3570 +# See: https://github.com/bootc-dev/bootc/issues/899 +use std assert +use tap.nu +use bootc_testlib.nu + +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +# Read x-options-source-* keys from the booted BLS entry +def read_bls_source_keys [] { + let entries = glob /boot/loader/entries/ostree-*.conf + if ($entries | length) == 0 { + error make { msg: "No BLS entries found" } + } + let entry = open ($entries | first) + $entry | lines | where { |line| $line starts-with "x-options-source-" } +} + +# Save the current system kargs (root=, ostree=, rw, etc.) for later comparison +def save_system_kargs [] { + let cmdline = parse_cmdline + # Filter to well-known system kargs that must never be lost + # Note: ostree= is excluded because its value changes between deployments + # (boot version counter, bootcsum). It's managed by ostree's + # install_deployment_kernel() and always regenerated during finalization. + let system_kargs = $cmdline | where { |k| + (($k starts-with "root=") or ($k == "rw") or ($k starts-with "console=")) + } + $system_kargs | to json | save -f /var/bootc-test-system-kargs.json +} + +def load_system_kargs [] { + open /var/bootc-test-system-kargs.json +} + +def first_boot [] { + tap begin "loader-entries set-options-for-source" + + # Save system kargs for later verification + save_system_kargs + + # -- Input validation -- + + # Invalid source name (spaces) + let r = do -i { bootc loader-entries set-options-for-source --source "bad name" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "spaces in source name should fail" + + # Invalid source name (special chars) + let r = do -i { bootc loader-entries set-options-for-source --source "foo@bar" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "special chars in source name should fail" + + # Empty source name + let r = do -i { bootc loader-entries set-options-for-source --source "" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "empty source name should fail" + + # Valid name with underscores/dashes + bootc loader-entries set-options-for-source --source "my_custom-src" --options "testvalid=1" + # Clear it immediately (no --options = remove source) + bootc loader-entries set-options-for-source --source "my_custom-src" + + # -- Add source kargs (multiple sources before reboot) -- + bootc loader-entries set-options-for-source --source tuned --options "nohz=full isolcpus=1-3" + bootc loader-entries set-options-for-source --source admin --options "quiet" + + # Verify deployment is staged + let st = bootc status --json | from json + assert ($st.status.staged != null) "deployment should be staged" + + print "ok: validation and initial staging" + tmt-reboot +} + +def second_boot [] { + # Verify kargs survived the staging roundtrip + let cmdline = parse_cmdline + assert ("nohz=full" in $cmdline) "nohz=full should be in cmdline after reboot" + assert ("isolcpus=1-3" in $cmdline) "isolcpus=1-3 should be in cmdline after reboot" + + # Verify both sources staged in first_boot survived + assert ("quiet" in $cmdline) "admin quiet karg should be in cmdline after reboot" + print "ok: multiple sources staged before reboot both survived" + + # Verify system kargs were preserved + let system_kargs = load_system_kargs + for karg in $system_kargs { + assert ($karg in $cmdline) $"system karg '($karg)' must be preserved" + } + print "ok: system kargs preserved" + + # Verify x-options-source-* keys in BLS entry + let source_keys = read_bls_source_keys + let tuned_key = $source_keys | where { |line| $line starts-with "x-options-source-tuned" } + assert (($tuned_key | length) > 0) "x-options-source-tuned should be in BLS entry" + let tuned_line = $tuned_key | first + assert ($tuned_line | str contains "nohz=full") "tuned source key should contain nohz=full" + assert ($tuned_line | str contains "isolcpus=1-3") "tuned source key should contain isolcpus=1-3" + let admin_key = $source_keys | where { |line| $line starts-with "x-options-source-admin" } + assert (($admin_key | length) > 0) "x-options-source-admin should be in BLS entry" + print "ok: kargs and source keys survived reboot" + + # Clean up admin source before continuing with replacement test + bootc loader-entries set-options-for-source --source admin + + # -- Source replacement: new kargs replace old ones -- + bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7" + + tmt-reboot +} + +def third_boot [] { + # Verify replacement worked + let cmdline = parse_cmdline + assert ("nohz=full" not-in $cmdline) "old nohz=full should be gone" + assert ("isolcpus=1-3" not-in $cmdline) "old isolcpus=1-3 should be gone" + assert ("nohz=on" in $cmdline) "new nohz=on should be present" + assert ("rcu_nocbs=2-7" in $cmdline) "new rcu_nocbs=2-7 should be present" + # Admin source was removed in second_boot + assert ("quiet" not-in $cmdline) "admin quiet should be gone after removal" + + # Verify system kargs still preserved after replacement + let system_kargs = load_system_kargs + for karg in $system_kargs { + assert ($karg in $cmdline) $"system karg '($karg)' must survive replacement" + } + print "ok: source replacement persisted, system kargs preserved" + + # -- Multiple sources coexist -- + bootc loader-entries set-options-for-source --source dracut --options "rd.driver.pre=vfio-pci" + + tmt-reboot +} + +def fourth_boot [] { + # Verify both sources persisted + let cmdline = parse_cmdline + assert ("nohz=on" in $cmdline) "tuned nohz=on should still be present" + assert ("rcu_nocbs=2-7" in $cmdline) "tuned rcu_nocbs=2-7 should still be present" + assert ("rd.driver.pre=vfio-pci" in $cmdline) "dracut karg should be present" + + # Verify both source keys in BLS + let source_keys = read_bls_source_keys + let tuned_keys = $source_keys | where { |line| $line starts-with "x-options-source-tuned" } + let dracut_keys = $source_keys | where { |line| $line starts-with "x-options-source-dracut" } + assert (($tuned_keys | length) > 0) "tuned source key should exist" + assert (($dracut_keys | length) > 0) "dracut source key should exist" + print "ok: multiple sources coexist" + + # -- Clear source with empty --options "" (different from no --options) -- + # --options "" should remove the kargs but the key can remain with empty value + bootc loader-entries set-options-for-source --source dracut --options "" + # dracut kargs should be removed from pending deployment + let st = bootc status --json | from json + assert ($st.status.staged != null) "empty options should still stage a deployment" + print "ok: --options '' clears kargs" + + # Now also test no --options (remove the source entirely) + # First re-add dracut so we can test removal + bootc loader-entries set-options-for-source --source dracut --options "rd.driver.pre=vfio-pci" + # Then remove it with no --options + bootc loader-entries set-options-for-source --source dracut + + tmt-reboot +} + +def fifth_boot [] { + # Verify dracut cleared, tuned preserved + let cmdline = parse_cmdline + assert ("rd.driver.pre=vfio-pci" not-in $cmdline) "dracut karg should be gone" + assert ("nohz=on" in $cmdline) "tuned nohz=on should still be present" + assert ("rcu_nocbs=2-7" in $cmdline) "tuned rcu_nocbs=2-7 should still be present" + print "ok: source clear persisted" + + # -- Idempotent: same kargs again should be a no-op -- + bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7" + # Should not stage a new deployment (idempotent) + let st = bootc status --json | from json + assert ($st.status.staged == null) "idempotent call should not stage a deployment" + print "ok: idempotent operation" + + # -- Staged deployment interaction -- + # Build a derived image and switch to it (this stages a deployment). + # Then call set-options-for-source on top. The staged deployment should + # be replaced with one that has the new image AND the source kargs. + bootc image copy-to-storage + + let td = mktemp -d + $"FROM localhost/bootc +RUN echo source-test-marker > /usr/share/source-test-marker.txt +" | save $"($td)/Dockerfile" + podman build -t localhost/bootc-source-test $"($td)" + + bootc switch --transport containers-storage localhost/bootc-source-test + let st = bootc status --json | from json + assert ($st.status.staged != null) "switch should stage a deployment" + + # Now add source kargs on top of the staged switch + bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7 skew_tick=1" + + # Verify a deployment is still staged (it was replaced, not removed) + let st = bootc status --json | from json + assert ($st.status.staged != null) "deployment should still be staged after set-options-for-source" + + tmt-reboot +} + +def sixth_boot [] { + # Verify the image switch landed (the derived image's marker file exists) + assert ("/usr/share/source-test-marker.txt" | path exists) "derived image marker should exist" + print "ok: image switch preserved" + + # Verify the source kargs also landed + let cmdline = parse_cmdline + assert ("nohz=on" in $cmdline) "tuned nohz=on should be present" + assert ("rcu_nocbs=2-7" in $cmdline) "tuned rcu_nocbs=2-7 should be present" + assert ("skew_tick=1" in $cmdline) "tuned skew_tick=1 should be present" + + # Verify source key in BLS + let source_keys = read_bls_source_keys + let tuned_key = $source_keys | where { |line| $line starts-with "x-options-source-tuned" } + assert (($tuned_key | length) > 0) "tuned source key should exist after staged interaction" + print "ok: staged deployment interaction preserved both image and source kargs" + + # Verify system kargs still intact + let system_kargs = load_system_kargs + let cmdline = parse_cmdline + for karg in $system_kargs { + assert ($karg in $cmdline) $"system karg '($karg)' must survive staged interaction" + } + print "ok: system kargs preserved through all phases" + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + "4" => fifth_boot, + "5" => sixth_boot, + $o => { error make { msg: $"Unexpected TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 4904e6883..bce8daddc 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -97,6 +97,11 @@ check: - e2fsprogs test: bash booted/test-install-to-filesystem-var-mount.sh +/test-32-multi-device-esp: + summary: Test multi-device ESP detection for to-existing-root + duration: 60m + test: nu booted/test-multi-device-esp.nu + /test-33-bib-build: summary: Test building a qcow2 disk image with bootc-image-builder duration: 45m @@ -148,3 +153,8 @@ check: summary: Test bootc install --karg-delete duration: 30m test: nu booted/test-install-karg-delete.nu + +/test-42-loader-entries-source: + summary: Test bootc loader-entries set-options-for-source + duration: 30m + test: nu booted/test-loader-entries-source.nu