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..0db9f8756 --- /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 000000000..238e40204 --- /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 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/hack/provision-derived.sh b/hack/provision-derived.sh index 9d5910aff..f4b4ddb3b 100755 --- a/hack/provision-derived.sh +++ b/hack/provision-derived.sh @@ -126,6 +126,38 @@ bootupd_nevra=$(dnf --disableplugin=subscription-manager --disablerepo=* --enabl dnf -y install ${bootupd_nevra} rm -f /etc/yum.repos.d/coreos-continuous.repo +# Temporary: upgrade ostree to 2026.1 for bootconfig-extra support +# (required by loader-entries source tracking) +# xref https://github.com/ostreedev/ostree/pull/3570 +# TODO: Remove this block once all base images ship ostree >= 2026.1 +if ! rpm -q ostree 2>/dev/null | grep -q "2026\." ; then + arch=$(uname -m) + case "${ID}-${VERSION_ID}" in + "centos-9") + koji_base="https://kojihub.stream.centos.org/kojifiles/packages/ostree/2026.1/1.el9/${arch}" + dnf -y install \ + "${koji_base}/ostree-2026.1-1.el9.${arch}.rpm" \ + "${koji_base}/ostree-libs-2026.1-1.el9.${arch}.rpm" + if rpm -q ostree-grub2 &>/dev/null; then + dnf -y install "${koji_base}/ostree-grub2-2026.1-1.el9.${arch}.rpm" + fi + ;; + "centos-10") + koji_base="https://kojihub.stream.centos.org/kojifiles/vol/koji02/packages/ostree/2026.1/1.el10/${arch}" + dnf -y install \ + "${koji_base}/ostree-2026.1-1.el10.${arch}.rpm" \ + "${koji_base}/ostree-libs-2026.1-1.el10.${arch}.rpm" + if rpm -q ostree-grub2 &>/dev/null; then + dnf -y install "${koji_base}/ostree-grub2-2026.1-1.el10.${arch}.rpm" + fi + ;; + "fedora-"*) + dnf -y --enablerepo=updates-testing install \ + ostree-2026.1 ostree-libs-2026.1 + ;; + esac +fi + dnf clean all # Stock extra cleaning of logs and caches in general (mostly dnf) rm /var/log/* /var/cache /var/lib/{dnf,rpm-state,rhsm} -rf diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index ce7469453..8a4850974 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 000000000..6a6223bc5 --- /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 0d081d5ef..bce8daddc 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