From 7d942d31607042f7013d3f95771625a1105087f1 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 1 Apr 2026 16:52:00 -0400 Subject: [PATCH 1/5] feat: Add `loader-entries set-options-for-source` for source-tracked kargs Add a new `bootc loader-entries set-options-for-source` command that manages kernel arguments from independent sources (e.g. TuneD, admin) by tracking ownership via `x-options-source-` extension keys in BLS config files. This solves the problem of karg accumulation on bootc systems with transient /etc, where tools like TuneD lose their state files on reboot and can't 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 present but commented out until release). 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 Example usage: bootc loader-entries set-options-for-source --source tuned \ --options "isolcpus=1-3 nohz_full=1-3" See: https://github.com/ostreedev/ostree/pull/3570 See: https://github.com/bootc-dev/bootc/issues/899 Assisted-by: OpenCode (Claude claude-opus-4-6) --- crates/lib/src/cli.rs | 66 +++++ crates/lib/src/lib.rs | 1 + crates/lib/src/loader_entries.rs | 439 +++++++++++++++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 crates/lib/src/loader_entries.rs 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" + ); + } +} From c4e71a304f2cad7544b1ca8bd31426469998c2ed Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 1 Apr 2026 17:06:19 -0400 Subject: [PATCH 2/5] tests: Add TMT integration test for loader-entries set-options-for-source Add a multi-reboot integration test covering the full lifecycle of source-tracked kernel arguments: - Input validation (invalid/empty source names, special characters) - Adding source-tracked kargs and verifying /proc/cmdline after reboot - x-options-source-* BLS keys surviving the staging roundtrip - Source replacement semantics (old kargs removed, new ones added) - Multiple sources coexisting independently - Source removal (--source without --options clears owned kargs) - --options "" (empty string) clears kargs distinctly from no --options - Existing system kargs (root=, ostree=, rw, console=) preserved through all operations - Staged deployment interaction: bootc switch stages a deployment, then set-options-for-source replaces it using the staged commit, preserving the image switch alongside the source kargs - Idempotent operation (no deployment staged when kargs unchanged) The test uses 5 reboots across 6 phases. Also includes auto-generated man pages for the new loader-entries subcommand. Assisted-by: OpenCode (Claude claude-opus-4-6) --- ...loader-entries-set-options-for-source.8.md | 36 +++ docs/src/man/bootc-loader-entries.8.md | 26 ++ docs/src/man/bootc.8.md | 1 + tmt/plans/integration.fmf | 7 + .../booted/test-loader-entries-source.nu | 257 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 6 files changed, 332 insertions(+) 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/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..5a9646463 --- /dev/null +++ b/tmt/tests/booted/test-loader-entries-source.nu @@ -0,0 +1,257 @@ +# 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) +# 8. Existing system kargs (root=, ostree=, etc.) preserved through changes +# 9. --options "" (empty string) clears kargs without removing the source +# 10. Staged deployment interaction (bootc switch + set-options-for-source +# preserves the pending image switch) +# +# Requires ostree with bootconfig-extra support (>= 2026.1). +# See: https://github.com/ostreedev/ostree/pull/3570 +# See: https://github.com/bootc-dev/bootc/issues/899 +use std assert +use tap.nu +use bootc_testlib.nu + +def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} + +# Read x-options-source-* keys from the booted BLS entry +def read_bls_source_keys [] { + let entries = glob /boot/loader/entries/ostree-*.conf + if ($entries | length) == 0 { + error make { msg: "No BLS entries found" } + } + let entry = open ($entries | first) + $entry | lines | where { |line| $line starts-with "x-options-source-" } +} + +# Save the current system kargs (root=, ostree=, rw, etc.) for later comparison +def save_system_kargs [] { + let cmdline = parse_cmdline + # Filter to well-known system kargs that must never be lost + let system_kargs = $cmdline | where { |k| + ($k starts-with "root=") or + ($k starts-with "ostree=") 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 | from 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 + 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 (no --options = remove source) + 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 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-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" + + # 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 -- + 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" + + # -- 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 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 From 2a07a3b8c36a34bef284ce58c8f33a3fc5eea290 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 1 Apr 2026 19:35:29 -0400 Subject: [PATCH 3/5] build: Add BOOTC_ostree_src for building with patched ostree Add support for building the bootc test image with a custom ostree built from source. When BOOTC_ostree_src is set to a path to an ostree source tree, the build system: 1. Builds ostree RPMs inside a container matching the base image distro (via contrib/packaging/Dockerfile.ostree-override) 2. Installs ostree-devel into the buildroot so bootc links against the patched libostree 3. Installs ostree + ostree-libs into the final image The override ostree is built as version 2026.1 by default (configurable via BOOTC_ostree_version) to ensure it is always newer than the stock package. This is important for runtime version checks like ostree::check_version(). The same pattern can be reused for other dependency overrides (e.g. composefs) in the future. Usage: BOOTC_ostree_src=/path/to/ostree just build BOOTC_ostree_src=/path/to/ostree just test-tmt loader-entries-source Assisted-by: OpenCode (Claude claude-opus-4-6) --- Dockerfile | 21 ++++ Justfile | 41 ++++++- contrib/packaging/Dockerfile.ostree-override | 113 +++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 contrib/packaging/Dockerfile.ostree-override diff --git a/Dockerfile b/Dockerfile index c050877a6..2b25668b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,17 @@ ARG initramfs=1 RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packaging,src=/,target=/run/packaging \ /run/packaging/install-buildroot +# Install ostree override RPMs into the buildroot if provided via BOOTC_ostree_src. +# This ensures bootc compiles and links against the patched ostree (ostree-devel, +# ostree-libs, ostree). When the directory is empty, nothing is installed. +RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=bind,from=ostree-packages,src=/,target=/run/ostree-packages </dev/null; then + echo "Installing ostree override into buildroot" + rpm -Uvh --oldpackage /run/ostree-packages/*.rpm +fi +EORUN # Now copy the rest of the source COPY --from=src /src /src WORKDIR /src @@ -162,6 +173,16 @@ ARG rootfs="" RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packaging,src=/,target=/run/packaging \ /run/packaging/configure-rootfs "${variant}" "${rootfs}" +# Install ostree override RPMs into the final image if provided via BOOTC_ostree_src. +# Only ostree and ostree-libs are installed here (not ostree-devel). +RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=bind,from=ostree-packages,src=/,target=/run/ostree-packages </dev/null; then + echo "Installing ostree override RPMs into final image" + rpm -Uvh --oldpackage /run/ostree-packages/ostree-2*.rpm /run/ostree-packages/ostree-libs-*.rpm +fi +EORUN # Override with our built package RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packaging,src=/,target=/run/packaging \ diff --git a/Justfile b/Justfile index 32d59c009..3be83269e 100644 --- a/Justfile +++ b/Justfile @@ -38,6 +38,16 @@ buildroot_base := env("BOOTC_buildroot_base", "quay.io/centos/centos:stream10") extra_src := env("BOOTC_extra_src", "") # Set to "1" to disable auto-detection of local Rust dependencies no_auto_local_deps := env("BOOTC_no_auto_local_deps", "") +# Optional: path to an ostree source tree to build and inject into the image. +# When set, ostree is built from source inside a container matching the base +# image distro, and the resulting RPMs override the stock ostree packages in +# both the buildroot (so bootc links against the patched libostree) and the +# final image. This pattern can be reused for other dependency overrides. +# Example: BOOTC_ostree_src=/path/to/ostree just build +ostree_src := env("BOOTC_ostree_src", "") +# Version to assign to the override ostree RPMs. This should be set to the +# next unreleased ostree version so the override is always newer than stock. +ostree_version := env("BOOTC_ostree_version", "2026.1") # Internal variables nocache := env("BOOTC_nocache", "") @@ -64,13 +74,14 @@ buildargs := base_buildargs \ # Build container image from current sources (default target) [group('core')] -build: package _keygen && _pull-lbi-images +build: package _build-ostree-rpms _keygen && _pull-lbi-images #!/bin/bash set -xeuo pipefail test -d target/packages pkg_path=$(realpath target/packages) + ostree_pkg_path=$(realpath target/ostree-packages) eval $(just _git-build-vars) - podman build {{_nocache_arg}} --build-arg=image_version=${VERSION} --build-context "packages=${pkg_path}" -t {{base_img}} {{buildargs}} . + podman build {{_nocache_arg}} --build-arg=image_version=${VERSION} --build-context "packages=${pkg_path}" --build-context "ostree-packages=${ostree_pkg_path}" -t {{base_img}} {{buildargs}} . # Show available build variants and current configuration [group('core')] @@ -321,7 +332,9 @@ package: if [[ -z "{{no_auto_local_deps}}" ]]; then local_deps_args=$(cargo xtask local-rust-deps) fi - podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} -t localhost/bootc-pkg --target=build $local_deps_args . + mkdir -p target/ostree-packages + ostree_pkg_path=$(realpath target/ostree-packages) + podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} --build-context "ostree-packages=${ostree_pkg_path}" -t localhost/bootc-pkg --target=build $local_deps_args . mkdir -p "${packages}" rm -vf "${packages}"/*.rpm podman run --rm localhost/bootc-pkg tar -C /out/ -cf - . | tar -C "${packages}"/ -xvf - @@ -359,6 +372,28 @@ _git-build-vars: echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" echo "VERSION=${VERSION}" +# Build ostree RPMs from source if BOOTC_ostree_src is set. +# The RPMs are built inside a container matching the base image distro. +# When BOOTC_ostree_src is not set, this creates an empty directory (no-op). +_build-ostree-rpms: + #!/bin/bash + set -xeuo pipefail + mkdir -p target/ostree-packages + if [ -z "{{ostree_src}}" ]; then exit 0; fi + echo "Building ostree {{ostree_version}} from source: {{ostree_src}}" + rm -f target/ostree-packages/*.rpm + podman build \ + --build-context ostree-src={{ostree_src}} \ + --build-arg=base={{base}} \ + --build-arg=ostree_version={{ostree_version}} \ + -t localhost/ostree-build \ + -f contrib/packaging/Dockerfile.ostree-override . + cid=$(podman create localhost/ostree-build) + podman cp "${cid}:/" target/ostree-packages/ + podman rm "${cid}" + echo "ostree override RPMs:" + ls -la target/ostree-packages/ + _keygen: ./hack/generate-secureboot-keys diff --git a/contrib/packaging/Dockerfile.ostree-override b/contrib/packaging/Dockerfile.ostree-override new file mode 100644 index 000000000..35f227ecb --- /dev/null +++ b/contrib/packaging/Dockerfile.ostree-override @@ -0,0 +1,113 @@ +# Build ostree RPMs from source, matching the base image distro. +# +# This Dockerfile is used by the BOOTC_ostree_src mechanism in the Justfile +# to build a patched ostree and inject it into the bootc test image. It builds +# ostree RPMs inside a container matching the base image so the resulting RPMs +# are compatible with the target distro. +# +# The ostree source is provided via the `ostree-src` build context. +# The version is overridden to ensure the built RPMs are always newer than +# the stock packages. +# +# Usage (via Justfile): +# BOOTC_ostree_src=/path/to/ostree just build +# +# Direct usage: +# podman build --build-context ostree-src=/path/to/ostree \ +# --build-arg=base=quay.io/centos-bootc/centos-bootc:stream10 \ +# --build-arg=ostree_version=2026.1 \ +# -f contrib/packaging/Dockerfile.ostree-override . + +ARG base=quay.io/centos-bootc/centos-bootc:stream10 + +FROM $base as ostree-build +# Install ostree build dependencies +RUN < "${PKG_VER}.tar.tmp" +git submodule status | while read line; do + rev=$(echo ${line} | cut -f 1 -d ' ') + path=$(echo ${line} | cut -f 2 -d ' ') + (cd "${path}"; git archive --format=tar --prefix="${PKG_VER}/${path}/" "${rev}") > submodule.tar + tar -A -f "${PKG_VER}.tar.tmp" submodule.tar + rm submodule.tar +done +mv "${PKG_VER}.tar.tmp" "${PKG_VER}.tar" +xz "${PKG_VER}.tar" + +# Get spec file: use local one if present, otherwise fetch from dist-git +if ! test -f ostree.spec; then + rm -rf ostree-distgit + . /usr/lib/os-release + case "${ID}" in + centos|rhel) + git clone --depth=1 https://gitlab.com/redhat/centos-stream/rpms/ostree.git ostree-distgit || \ + git clone --depth=1 https://src.fedoraproject.org/rpms/ostree ostree-distgit + ;; + *) + git clone --depth=1 https://src.fedoraproject.org/rpms/ostree ostree-distgit + ;; + esac + cp ostree-distgit/ostree.spec . +fi + +# Set the target version and strip any distro patches +sed -i -e '/^Patch/d' -e "s,^Version:.*,Version: ${ostree_version}," ostree.spec + +# Build SRPM +ci/rpmbuild-cwd -bs ostree.spec + +# Install any missing build deps from the SRPM +if test "$(id -u)" = 0; then + dnf builddep -y *.src.rpm +fi + +# Build binary RPMs +ci/rpmbuild-cwd --rebuild *.src.rpm + +# Collect the RPMs we need +mkdir -p /out +cp x86_64/ostree-${ostree_version}*.rpm \ + x86_64/ostree-libs-${ostree_version}*.rpm \ + x86_64/ostree-devel-${ostree_version}*.rpm \ + /out/ +echo "Built ostree override RPMs:" +ls -la /out/ +EORUN + +# Final stage: just the RPMs +FROM scratch +COPY --from=ostree-build /out/ / From 53cf2ffce8973eb53962ac37b2e0720d2de1b489 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 1 Apr 2026 21:15:10 -0400 Subject: [PATCH 4/5] fix: Handle repeated set-options-for-source calls before reboot When set-options-for-source is called multiple times before rebooting, the second call needs to find the target source's previous value in the staged deployment's bootconfig (not the booted BLS, since we haven't rebooted). Without this, the old kargs from the first call would not be removed from the options line, causing duplication. Fix by explicitly checking the target source in the staged bootconfig after iterating known sources from the booted BLS entry. Addresses review feedback from PR #2114. Assisted-by: OpenCode (Claude claude-opus-4-6) --- crates/lib/src/loader_entries.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/lib/src/loader_entries.rs b/crates/lib/src/loader_entries.rs index 8a87a7566..46578a1bb 100644 --- a/crates/lib/src/loader_entries.rs +++ b/crates/lib/src/loader_entries.rs @@ -202,6 +202,18 @@ pub(crate) fn set_options_for_source_staged( } } } + // Also check the target source directly in the staged bootconfig. + // This handles the case where set-options-for-source was called + // multiple times before rebooting: the target source exists in the + // staged bootconfig but not in the booted BLS entry. + let target_key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{source}"); + if !sources.contains_key(source) { + if let Some(val) = bootconfig.get(&target_key) { + if !val.is_empty() { + sources.insert(source.to_string(), CmdlineOwned::from(val.to_string())); + } + } + } sources } else { // For booted deployments, parse the BLS file directly From 8940445a94ba79ed2ab9c4b46e41e411fc8ce190 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Thu, 2 Apr 2026 09:09:10 -0400 Subject: [PATCH 5/5] fix: Fix nushell syntax in loader-entries test The multi-line `or` expression with trailing `or` at end of line is not valid nushell syntax. Collapse the filter predicate to a single line. Assisted-by: OpenCode (Claude claude-opus-4-6) --- Justfile | 2 +- crates/lib/src/loader_entries.rs | 43 ++++++++++++++----- .../booted/test-loader-entries-source.nu | 20 ++++++--- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/Justfile b/Justfile index 3be83269e..5ee7ba5a4 100644 --- a/Justfile +++ b/Justfile @@ -74,7 +74,7 @@ buildargs := base_buildargs \ # Build container image from current sources (default target) [group('core')] -build: package _build-ostree-rpms _keygen && _pull-lbi-images +build: _build-ostree-rpms package _keygen && _pull-lbi-images #!/bin/bash set -xeuo pipefail test -d target/packages diff --git a/crates/lib/src/loader_entries.rs b/crates/lib/src/loader_entries.rs index 46578a1bb..88d9d5612 100644 --- a/crates/lib/src/loader_entries.rs +++ b/crates/lib/src/loader_entries.rs @@ -88,8 +88,15 @@ fn compute_merged_options( 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. +/// Read the BLS entry file content for a deployment from /boot/loader/entries/. +/// +/// The BLS entry filename pattern changed in ostree 2024.5: +/// - New: ostree-.conf +/// - Old: ostree--.conf +/// +/// Since we can't easily reconstruct the index, we read all ostree-*.conf +/// entries and match by checking the `options` line for the deployment's +/// ostree path (which includes the stateroot and deploy serial). fn read_bls_entry_for_deployment( sysroot: &ostree::Sysroot, deployment: &ostree::Deployment, @@ -103,27 +110,43 @@ fn read_bls_entry_for_deployment( .path() .ok_or_else(|| anyhow::anyhow!("Failed to get boot/loader/entries path"))?; - // The BLS entry filename follows the pattern: ostree-$stateroot-$deployserial.conf + // 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 deployserial = deployment.deployserial(); + let bootserial = deployment.bootserial(); let bootcsum = deployment.bootcsum(); - let expected_suffix = format!("{stateroot}-{bootcsum}-{deployserial}.conf"); + let ostree_match = format!("/{stateroot}/{bootcsum}/{bootserial}"); + let mut found_entries = Vec::new(); 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())); + if !name.starts_with("ostree-") || !name.ends_with(".conf") { + continue; + } + found_entries.push(name.to_string()); + let content = std::fs::read_to_string(entry.path()) + .with_context(|| format!("Reading BLS entry {}", entry.path().display()))?; + // Check if this entry's options line contains our deployment's ostree path + if content + .lines() + .any(|line| line.starts_with("options ") && line.contains(&ostree_match)) + { + return Ok(content); } } anyhow::bail!( - "No BLS entry found matching deployment {stateroot} serial {deployserial} in {}", - entries_dir.display() + "No BLS entry found matching deployment {stateroot}/{bootcsum}/{bootserial} in {}. \ + Looking for '{}' in options line. Found entries: {:?}", + entries_dir.display(), + ostree_match, + found_entries, ) } diff --git a/tmt/tests/booted/test-loader-entries-source.nu b/tmt/tests/booted/test-loader-entries-source.nu index 5a9646463..4b052a430 100644 --- a/tmt/tests/booted/test-loader-entries-source.nu +++ b/tmt/tests/booted/test-loader-entries-source.nu @@ -42,17 +42,17 @@ def read_bls_source_keys [] { 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 starts-with "ostree=") or - ($k == "rw") or - ($k starts-with "console=") + (($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 | from json + open /var/bootc-test-system-kargs.json } def first_boot [] { @@ -77,10 +77,20 @@ def first_boot [] { # Valid name with underscores/dashes let r = do -i { bootc loader-entries set-options-for-source --source "my_custom-src" --options "testvalid=1" } | complete + if $r.exit_code != 0 { + print $"FAILED: valid source name returned exit code ($r.exit_code)" + print $"stdout: ($r.stdout)" + print $"stderr: ($r.stderr)" + } assert ($r.exit_code == 0) "valid source name should succeed" # Clear it immediately (no --options = remove source) let r = do -i { bootc loader-entries set-options-for-source --source "my_custom-src" } | complete + if $r.exit_code != 0 { + print $"FAILED: clearing source returned exit code ($r.exit_code)" + print $"stdout: ($r.stdout)" + print $"stderr: ($r.stderr)" + } assert ($r.exit_code == 0) "clearing source should succeed" # -- Add source kargs --