1182 lines
46 KiB
Diff
1182 lines
46 KiB
Diff
From 74f4fdd00e91cecd62714057eabf676de577bb7f Mon Sep 17 00:00:00 2001
|
|
From: Joseph Marrero Corchado <jmarrero@redhat.com>
|
|
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-<name>` 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 <jmarrero@redhat.com>
|
|
---
|
|
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<String>,
|
|
+}
|
|
+
|
|
+/// 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-<name>` extension keys in BLS config files.
|
|
+///
|
|
+/// See <https://github.com/ostreedev/ostree/pull/3570>
|
|
+#[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-<name>`
|
|
+ /// 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-<name>` extension keys in BLS config
|
|
+//! files.
|
|
+//!
|
|
+//! See <https://github.com/ostreedev/ostree/pull/3570>
|
|
+//! See <https://github.com/bootc-dev/bootc/issues/899>
|
|
+
|
|
+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 <https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/>.
|
|
+struct SourceName(String);
|
|
+
|
|
+impl SourceName {
|
|
+ /// Parse and validate a source name.
|
|
+ fn parse(source: &str) -> Result<Self> {
|
|
+ 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<String, CmdlineOwned> {
|
|
+ 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<String, CmdlineOwned>,
|
|
+ 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<BTreeMap<String, CmdlineOwned>> {
|
|
+ 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<Option<String>> {
|
|
+ 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<String> = 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-<name>` 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
|
|
+
|
|
+<!-- BEGIN GENERATED OPTIONS -->
|
|
+**--source**=*SOURCE*
|
|
+
|
|
+ The name of the source that owns these kernel arguments
|
|
+
|
|
+**--options**=*OPTIONS*
|
|
+
|
|
+ The kernel arguments to set for this source
|
|
+
|
|
+<!-- END GENERATED OPTIONS -->
|
|
+
|
|
+# 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
|
|
+
|
|
+<!-- VERSION PLACEHOLDER -->
|
|
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-<name>` 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.
|
|
+
|
|
+<!-- BEGIN GENERATED OPTIONS -->
|
|
+<!-- END GENERATED OPTIONS -->
|
|
+
|
|
+# 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
|
|
+
|
|
+<!-- VERSION PLACEHOLDER -->
|
|
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** | |
|
|
|
|
<!-- END GENERATED SUBCOMMANDS -->
|
|
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
|
|
|