diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b02791af1..f7375a7d1 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -715,6 +715,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 @@ -820,6 +868,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 { @@ -1864,6 +1917,19 @@ async fn run_from_opt(opt: Opt) -> Result<()> { crate::install::install_finalize(&root_path).await } }, + Opt::LoaderEntries(opts) => match opts { + LoaderEntriesOpts::SetOptionsForSource(opts) => { + prepare_for_write()?; + 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..8a87a7566 --- /dev/null +++ b/crates/lib/src/loader_entries.rs @@ -0,0 +1,439 @@ +//! # 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; +use ostree_ext::ostree; +use ostree_ext::prelude::FileExt; +use std::collections::BTreeMap; + +/// The BLS extension key prefix for source-tracked options. +const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-"; + +/// Validate a source name: must be non-empty, alphanumeric + hyphens + underscores. +fn validate_source_name(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(()) +} + +/// 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(); + if let Some(rest) = line.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) { + if let Some((source_name, value)) = rest.split_once(|c: char| c.is_ascii_whitespace()) { + if !source_name.is_empty() { + sources.insert( + source_name.to_string(), + CmdlineOwned::from(value.trim().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: &str, + 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 { + if !new_opts.is_empty() { + let new_cmdline = Cmdline::from(new_opts); + for param in new_cmdline.iter() { + merged.add(¶m); + } + } + } + + merged +} + +/// Read the BLS entry file content for a deployment by scanning /boot/loader/entries/. +/// We find the entry that matches the deployment's bootconfig options. +fn read_bls_entry_for_deployment( + sysroot: &ostree::Sysroot, + deployment: &ostree::Deployment, +) -> Result { + let boot_path = sysroot + .path() + .child("boot") + .child("loader") + .child("entries"); + let entries_dir = boot_path + .path() + .ok_or_else(|| anyhow::anyhow!("Failed to get boot/loader/entries path"))?; + + // The BLS entry filename follows the pattern: ostree-$stateroot-$deployserial.conf + let stateroot = deployment.stateroot(); + let deployserial = deployment.deployserial(); + let bootcsum = deployment.bootcsum(); + let expected_suffix = format!("{stateroot}-{bootcsum}-{deployserial}.conf"); + + for entry in std::fs::read_dir(&entries_dir) + .with_context(|| format!("Reading {}", entries_dir.display()))? + { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(&expected_suffix) { + return std::fs::read_to_string(entry.path()) + .with_context(|| format!("Reading BLS entry {}", entry.path().display())); + } + } + + anyhow::bail!( + "No BLS entry found matching deployment {stateroot} serial {deployserial} in {}", + entries_dir.display() + ) +} + +/// 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<()> { + validate_source_name(source)?; + + // TODO: Uncomment when ostree 2026.1 is released. 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. + // For the booted deployment this comes from the on-disk BLS entry. + // For a staged deployment this comes from the in-memory bootconfig + // (which was rebuilt from the staged GVariant's kargs). + let current_options = bootconfig + .get("options") + .map(|s| s.to_string()) + .unwrap_or_default(); + + // Read existing x-options-source-* keys. For the booted deployment, we read + // the BLS file since the bootconfig parser loaded it at boot. For a staged + // deployment, the keys were restored from "bootconfig-extra" in the GVariant + // by _ostree_sysroot_reload_staged(), so they're on the in-memory bootconfig. + 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). Instead, probe for x-options-source-* keys using get(). + // Since we don't have iteration, we check keys we know about from the booted + // deployment's BLS file plus whatever was on the staged bootconfig. + let mut sources = BTreeMap::new(); + // Read the booted BLS to discover source names + if let Ok(bls_content) = read_bls_entry_for_deployment(sysroot, &booted) { + let booted_sources = extract_source_options_from_bls(&bls_content); + for (name, _) in &booted_sources { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + if let Some(val) = bootconfig.get(&key) { + sources.insert(name.clone(), CmdlineOwned::from(val.to_string())); + } + } + } + sources + } else { + // For booted deployments, parse the BLS file directly + let bls_content = read_bls_entry_for_deployment(sysroot, &booted)?; + extract_source_options_from_bls(&bls_content) + }; + + // Compute merged options + let source_key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{source}"); + let merged = compute_merged_options(¤t_options, &source_options, source, new_options); + + // Check for idempotency: if nothing changed, skip staging + let old_source_value = source_options.get(source).map(|c| c.to_string()); + let new_source_value = new_options.map(|s| s.to_string()); + + if merged.to_string() == current_options && old_source_value == new_source_value { + 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 + for (name, _) in &source_options { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + merge_bootconfig.set(&key, ""); + } + // Re-set the keys we want to keep (all except the one being removed) + for (name, value) in &source_options { + if name != source { + let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}"); + merge_bootconfig.set(&key, &value.to_string()); + } + } + // Set the new/updated source key (if not removing) + if let Some(opts_str) = new_options { + merge_bootconfig.set(&source_key, opts_str); + } + + // Build kargs as string slices for the ostree API + let kargs_strs: Vec = merged.iter_str().map(|s| s.to_string()).collect(); + let kargs_refs: Vec<&str> = kargs_strs.iter().map(|s| s.as_str()).collect(); + + let mut opts = ostree::SysrootDeployTreeOpts::default(); + opts.override_kernel_argv = Some(&kargs_refs); + + sysroot.stage_tree_with_options( + Some(stateroot.as_str()), + &ostree_commit, + Some(&origin), + Some(&merge_deployment), + &opts, + gio::Cancellable::NONE, + )?; + + tracing::info!("Staged deployment with updated kargs for source '{source}'"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_source_name() { + assert!(validate_source_name("tuned").is_ok()); + assert!(validate_source_name("bootc-kargs-d").is_ok()); + assert!(validate_source_name("my_source_123").is_ok()); + assert!(validate_source_name("").is_err()); + assert!(validate_source_name("bad name").is_err()); + assert!(validate_source_name("bad/name").is_err()); + assert!(validate_source_name("bad.name").is_err()); + } + + #[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_compute_merged_options_add_new_source() { + let current = "root=UUID=abc123 rw composefs=digest123"; + let sources = BTreeMap::new(); + + let result = compute_merged_options( + current, + &sources, + "tuned", + Some("isolcpus=1-3 nohz_full=1-3"), + ); + + assert_eq!( + &*result, + "root=UUID=abc123 rw composefs=digest123 isolcpus=1-3 nohz_full=1-3" + ); + } + + #[test] + fn test_compute_merged_options_update_existing_source() { + let current = "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3"; + let mut sources = BTreeMap::new(); + sources.insert( + "tuned".to_string(), + CmdlineOwned::from("isolcpus=1-3 nohz_full=1-3".to_string()), + ); + + let result = compute_merged_options(current, &sources, "tuned", Some("isolcpus=0-7")); + + assert_eq!(&*result, "root=UUID=abc123 rw isolcpus=0-7"); + } + + #[test] + fn test_compute_merged_options_remove_source() { + let current = "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3"; + let mut sources = BTreeMap::new(); + sources.insert( + "tuned".to_string(), + CmdlineOwned::from("isolcpus=1-3 nohz_full=1-3".to_string()), + ); + + let result = compute_merged_options(current, &sources, "tuned", None); + + assert_eq!(&*result, "root=UUID=abc123 rw"); + } + + #[test] + fn test_compute_merged_options_empty_initial() { + let current = ""; + let sources = BTreeMap::new(); + + let result = compute_merged_options(current, &sources, "tuned", Some("isolcpus=1-3")); + + assert_eq!(&*result, "isolcpus=1-3"); + } + + #[test] + fn test_compute_merged_options_clear_source_with_empty() { + let current = "root=UUID=abc123 rw isolcpus=1-3"; + let mut sources = BTreeMap::new(); + sources.insert( + "tuned".to_string(), + CmdlineOwned::from("isolcpus=1-3".to_string()), + ); + + let result = compute_merged_options(current, &sources, "tuned", Some("")); + + assert_eq!(&*result, "root=UUID=abc123 rw"); + } + + #[test] + fn test_compute_merged_options_preserves_untracked() { + let current = "root=UUID=abc123 rw quiet isolcpus=1-3"; + let mut sources = BTreeMap::new(); + sources.insert( + "tuned".to_string(), + CmdlineOwned::from("isolcpus=1-3".to_string()), + ); + + let result = compute_merged_options(current, &sources, "tuned", Some("nohz=full")); + + assert_eq!(&*result, "root=UUID=abc123 rw quiet nohz=full"); + } + + #[test] + fn test_compute_merged_options_multiple_sources() { + let current = "root=UUID=abc rw isolcpus=1-3 rd.driver.pre=vfio-pci"; + let mut sources = BTreeMap::new(); + sources.insert( + "tuned".to_string(), + CmdlineOwned::from("isolcpus=1-3".to_string()), + ); + sources.insert( + "dracut".to_string(), + CmdlineOwned::from("rd.driver.pre=vfio-pci".to_string()), + ); + + // Update tuned, dracut should be preserved + let result = compute_merged_options(current, &sources, "tuned", Some("nohz=full")); + + assert_eq!( + &*result, + "root=UUID=abc rw rd.driver.pre=vfio-pci nohz=full" + ); + } +} 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..99a1ee84c --- /dev/null +++ b/docs/src/man/bootc-loader-entries-set-options-for-source.8.md @@ -0,0 +1,36 @@ +# 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 + +# DESCRIPTION + +Set or update the kernel arguments owned by a specific source + +# OPTIONS + + +**--source**=*SOURCE* + + The name of the source that owns these kernel arguments + +**--options**=*OPTIONS* + + The kernel arguments to set for this source + + + +# EXAMPLES + +TODO: Add practical examples showing how to use this command. + +# SEE ALSO + +**bootc**(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..ee3a8f75d --- /dev/null +++ b/docs/src/man/bootc-loader-entries.8.md @@ -0,0 +1,26 @@ +# NAME + +bootc-loader-entries - Operations on Boot Loader Specification (BLS) entries + +# SYNOPSIS + +bootc loader-entries + +# DESCRIPTION + +Operations on Boot Loader Specification (BLS) entries + + + + +# EXAMPLES + +TODO: Add practical examples showing how to use this command. + +# SEE ALSO + +**bootc**(8) + +# VERSION + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index 99e673afc..d543d0499 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -33,6 +33,7 @@ pulled and `bootc upgrade`. | **bootc usr-overlay** | Add a transient overlayfs on `/usr` | | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | +| **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | | **bootc composefs-finalize-staged** | | diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 46c58eb55..c5b399d7c 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -237,4 +237,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-39-upgrade-tag + +/plan-40-loader-entries-source: + summary: Test bootc loader-entries set-options-for-source + discover: + how: fmf + test: + - /tmt/tests/tests/test-40-loader-entries-source # END GENERATED PLANS diff --git a/tmt/tests/booted/test-loader-entries-source.nu b/tmt/tests/booted/test-loader-entries-source.nu new file mode 100644 index 000000000..7d2124636 --- /dev/null +++ b/tmt/tests/booted/test-loader-entries-source.nu @@ -0,0 +1,157 @@ +# number: 40 +# 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) +# +# Requires ostree with bootconfig-extra support (>= 2026.1). +# See: https://github.com/ostreedev/ostree/pull/3570 +# See: https://github.com/bootc-dev/bootc/issues/899 +use std assert +use tap.nu +use bootc_testlib.nu + +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +# Read x-options-source-* keys from the booted BLS entry +def read_bls_source_keys [] { + let entries = glob /boot/loader/entries/ostree-*.conf + if ($entries | length) == 0 { + error make { msg: "No BLS entries found" } + } + let entry = open ($entries | first) + $entry | lines | where { |line| $line starts-with "x-options-source-" } +} + +def first_boot [] { + tap begin "loader-entries set-options-for-source" + + # -- 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 + let r = do -i { bootc loader-entries set-options-for-source --source "my_custom-src" --options "testvalid=1" } | complete + assert ($r.exit_code == 0) "valid source name should succeed" + + # Clear it immediately + let r = do -i { bootc loader-entries set-options-for-source --source "my_custom-src" } | complete + assert ($r.exit_code == 0) "clearing source should succeed" + + # -- Add source kargs -- + bootc loader-entries set-options-for-source --source tuned --options "nohz=full isolcpus=1-3" + + # 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 x-options-source-tuned key 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" + print "ok: kargs and source key survived reboot" + + # -- 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" + print "ok: source replacement persisted" + + # -- 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: remove dracut kargs -- + 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 -- + let r = bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7" | complete + # 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" + + 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, + $o => { error make { msg: $"Unexpected TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index b26b3ad9c..9424416b8 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -136,3 +136,8 @@ summary: Test bootc upgrade --tag functionality with containers-storage duration: 30m test: nu booted/test-upgrade-tag.nu + +/test-40-loader-entries-source: + summary: Test bootc loader-entries set-options-for-source + duration: 30m + test: nu booted/test-loader-entries-source.nu