diff --git a/Dockerfile b/Dockerfile
index c050877a6..04306c767 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,12 @@ COPY . /src
FROM scratch as packaging
COPY contrib/packaging /
+# Default empty stage for ostree override RPMs. When BOOTC_ostree_src is set,
+# the Justfile passes --build-context ostree-packages=
which overrides
+# this stage with the actual RPMs. When not set, this empty stage ensures
+# the Dockerfile still builds without errors.
+FROM scratch as ostree-packages
+
# This image installs build deps, pulls in our source code, and installs updated
# bootc binaries in /out. The intention is that the target rootfs is extracted from /out
# back into a final stage (without the build deps etc) below.
@@ -27,6 +33,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 +179,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..5ee7ba5a4 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: _build-ostree-rpms package _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/ /
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..0ec2b051d
--- /dev/null
+++ b/crates/lib/src/loader_entries.rs
@@ -0,0 +1,516 @@
+//! # 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 cap_std_ext::cap_std;
+use fn_error_context::context;
+use ostree::gio;
+use ostree_ext::ostree;
+use std::collections::BTreeMap;
+
+/// The BLS extension key prefix for source-tracked options.
+const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";
+
+/// A validated source name (alphanumeric + hyphens + underscores, non-empty).
+///
+/// This is a newtype wrapper around `String` that enforces validation at
+/// construction time. See .
+struct SourceName(String);
+
+impl SourceName {
+ /// Parse and validate a source name.
+ fn parse(source: &str) -> Result {
+ ensure!(!source.is_empty(), "Source name must not be empty");
+ ensure!(
+ source
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
+ "Source name must contain only alphanumeric characters, hyphens, or underscores"
+ );
+ Ok(Self(source.to_owned()))
+ }
+
+ /// The BLS key for this source (e.g., `x-options-source-tuned`).
+ fn bls_key(&self) -> String {
+ format!("{OPTIONS_SOURCE_KEY_PREFIX}{}", self.0)
+ }
+}
+
+impl std::ops::Deref for SourceName {
+ type Target = str;
+ fn deref(&self) -> &str {
+ &self.0
+ }
+}
+
+impl std::fmt::Display for SourceName {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// Extract source options from BLS entry content. Parses `x-options-source-*` keys
+/// from the raw BLS text since the ostree BootconfigParser doesn't expose key iteration.
+fn extract_source_options_from_bls(content: &str) -> BTreeMap {
+ let mut sources = BTreeMap::new();
+ for line in content.lines() {
+ let line = line.trim();
+ let Some(rest) = line.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) else {
+ continue;
+ };
+ let Some((source_name, value)) = rest.split_once(|c: char| c.is_ascii_whitespace()) else {
+ continue;
+ };
+ if source_name.is_empty() {
+ continue;
+ }
+ 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: &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 {
+ 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 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(deployment: &ostree::Deployment) -> Result