diff --git a/0001-loader-entries-Add-set-options-for-source-for-source.patch b/0001-loader-entries-Add-set-options-for-source-for-source.patch new file mode 100644 index 0000000..56a49a3 --- /dev/null +++ b/0001-loader-entries-Add-set-options-for-source-for-source.patch @@ -0,0 +1,1181 @@ +From 74f4fdd00e91cecd62714057eabf676de577bb7f 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" + +Closes: https://github.com/bootc-dev/bootc/issues/899 +See: https://github.com/ostreedev/ostree/pull/3570 + +Assisted-by: OpenCode (Claude Opus 4.6) +Signed-off-by: Joseph Marrero Corchado +--- + crates/lib/src/cli.rs | 65 ++ + crates/lib/src/lib.rs | 1 + + crates/lib/src/loader_entries.rs | 573 ++++++++++++++++++ + ...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 | 8 + + .../booted/test-loader-entries-source.nu | 267 ++++++++ + tmt/tests/tests.fmf | 5 + + 9 files changed, 1034 insertions(+) + 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 b1752c2c..e4fa2a70 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 558ca871..69544ad6 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 00000000..0db9f875 +--- /dev/null ++++ b/crates/lib/src/loader_entries.rs +@@ -0,0 +1,573 @@ ++//! # 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 fn_error_context::context; ++use ostree::{gio, glib}; ++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 x-options-source-* keys from the staged deployment data file. ++/// ++/// When a deployment is staged, ostree serializes any extension BLS keys into ++/// the "bootconfig-extra" field of the staged deployment GVariant at ++/// /run/ostree/staged-deployment. This function reads that file, extracts the ++/// bootconfig-extra dict, and returns all x-options-source-* entries. ++/// ++/// This is needed to discover sources set by previous calls to ++/// set-options-for-source in the same boot cycle, since the staged BLS entry ++/// doesn't exist on disk yet (finalization writes it at shutdown). ++fn read_staged_bootconfig_extra_sources( ++ sysroot: &ostree::Sysroot, ++) -> Result> { ++ let mut sources = BTreeMap::new(); ++ let sysroot_dir = crate::utils::sysroot_dir(sysroot)?; ++ ++ // The staged deployment data file is written by ostree during ++ // stage_tree_with_options() and lives under /run/ostree/. ++ let data = match sysroot_dir.open("run/ostree/staged-deployment") { ++ Ok(mut f) => { ++ let mut buf = Vec::new(); ++ std::io::Read::read_to_end(&mut f, &mut buf) ++ .context("Reading staged deployment data")?; ++ buf ++ } ++ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(sources), ++ Err(e) => return Err(anyhow::Error::new(e).context("Opening staged deployment data")), ++ }; ++ ++ // The staged deployment file is a GVariant of type a{sv}. ++ let variant = glib::Variant::from_data_with_type(&data, glib::VariantTy::VARDICT); ++ let dict = glib::VariantDict::new(Some(&variant)); ++ ++ // Look up "bootconfig-extra" which is stored as a{ss} inside the a{sv} dict. ++ if let Some(extra) = dict.lookup_value("bootconfig-extra", None) { ++ // Handle both direct a{ss} and variant-wrapped a{ss} ++ let inner = if extra.type_().as_str() == "v" { ++ extra.child_value(0) ++ } else { ++ extra ++ }; ++ if inner.type_().as_str() == "a{ss}" { ++ for i in 0..inner.n_children() { ++ let entry = inner.child_value(i); ++ let key: String = entry.child_value(0).get().ok_or_else(|| { ++ anyhow::anyhow!("Unexpected type for key in bootconfig-extra entry") ++ })?; ++ let value: String = entry.child_value(1).get().ok_or_else(|| { ++ anyhow::anyhow!("Unexpected type for value in bootconfig-extra entry") ++ })?; ++ if let Some(name) = key.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) { ++ if !value.is_empty() { ++ sources.insert(name.to_string(), CmdlineOwned::from(value)); ++ } ++ } ++ } ++ } ++ } ++ ++ Ok(sources) ++} ++ ++/// 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( ++ sysroot: &ostree::Sysroot, ++ deployment: &ostree::Deployment, ++) -> Result> { ++ let sysroot_dir = crate::utils::sysroot_dir(sysroot)?; ++ 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. ++ // ++ 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). ++ // ++ // We discover sources from two places: ++ // 1. The booted BLS entry (sources that have been finalized in previous boots) ++ // 2. The staged bootconfig (sources set since last boot via prior calls to ++ // set-options-for-source that haven't been finalized yet) ++ // ++ // For (2), the staged bootconfig's extra keys are restored from the ++ // "bootconfig-extra" GVariant by ostree's _ostree_sysroot_reload_staged() ++ // during sysroot.load(). We probe the bootconfig for all source keys we ++ // can discover. ++ let mut sources = BTreeMap::new(); ++ ++ // First: discover from the booted BLS entry (already-finalized sources) ++ if let Some(bls_content) = ++ read_bls_entry_for_deployment(sysroot, &booted).context("Reading booted BLS entry")? ++ { ++ let booted_sources = extract_source_options_from_bls(&bls_content); ++ for name in booted_sources.keys() { ++ let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); ++ if let Some(val) = bootconfig.get(&key) { ++ sources.insert(name.clone(), CmdlineOwned::from(val.to_string())); ++ } ++ } ++ } ++ ++ // Second: discover from the staged bootconfig's extra keys. ++ // These are sources set by prior calls to set-options-for-source ++ // in this boot cycle (before any reboot). We read them from the ++ // staged deployment data file which contains the serialized ++ // bootconfig-extra GVariant. ++ let staged_sources = read_staged_bootconfig_extra_sources(sysroot)?; ++ for (name, value) in staged_sources { ++ sources.entry(name).or_insert(value); ++ } ++ ++ sources ++ } else { ++ // For booted deployments, parse the BLS file directly ++ let bls_content = read_bls_entry_for_deployment(sysroot, &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.keys() { ++ 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); ++ } ++ } ++ // 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 opts = ostree::SysrootDeployTreeOpts { ++ override_kernel_argv: Some(&kargs_refs), ++ ..Default::default() ++ }; ++ ++ 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 00000000..238e4020 +--- /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 ++ ++Source keys set by prior calls in the same boot cycle (before any reboot) ++are discovered by reading the staged deployment data file at ++`/run/ostree/staged-deployment`. If this file is missing or cannot be ++parsed, sources from prior calls may not be discovered, potentially ++orphaning their kargs. In practice this should not occur, as the file is ++managed by ostree and always present when a 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 00000000..623cc40b +--- /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 99e673af..d543d049 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 ce746945..8a485097 100644 +--- a/tmt/plans/integration.fmf ++++ b/tmt/plans/integration.fmf +@@ -246,4 +246,12 @@ execute: + test: + - /tmt/tests/tests/test-40-install-karg-delete + extra-fixme_skip_if_composefs: true ++ ++/plan-42-loader-entries-source: ++ summary: Test bootc loader-entries set-options-for-source ++ discover: ++ how: fmf ++ test: ++ - /tmt/tests/tests/test-42-loader-entries-source ++ extra-fixme_skip_if_composefs: true + # 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 00000000..6a6223bc +--- /dev/null ++++ b/tmt/tests/booted/test-loader-entries-source.nu +@@ -0,0 +1,267 @@ ++# 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 ++ ++def parse_cmdline [] { ++ open /proc/cmdline | str trim | split row " " ++} ++ ++# Read x-options-source-* keys from the booted BLS entry. ++# The booted deployment always has the highest version number, ++# so we pick the last entry when sorted by filename (ostree-N.conf). ++def read_bls_source_keys [] { ++ let entries = glob /boot/loader/entries/ostree-*.conf | sort ++ if ($entries | length) == 0 { ++ error make { msg: "No BLS entries found" } ++ } ++ let entry = open ($entries | last) ++ $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 0d081d5e..bce8dadd 100644 +--- a/tmt/tests/tests.fmf ++++ b/tmt/tests/tests.fmf +@@ -153,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 +-- +2.53.0 + diff --git a/bootc.spec b/bootc.spec index 88528c0..16e2220 100644 --- a/bootc.spec +++ b/bootc.spec @@ -39,6 +39,10 @@ URL: https://github.com/bootc-dev/bootc Source0: %{url}/releases/download/v%{version}/bootc-%{version}.tar.zstd Source1: %{url}/releases/download/v%{version}/bootc-%{version}-vendor.tar.zstd +# Backport https://github.com/bootc-dev/bootc/pull/2114 +# This can be safely removed in any release > 1.15.1 +Patch0: 0001-loader-entries-Add-set-options-for-source-for-source.patch + # AlmaLinux Patch Patch1001: 0001-Add-riscv64-architecture-support.patch