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> { + let sysroot_dir = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let entries_dir = sysroot_dir + .open_dir("boot/loader/entries") + .context("Opening boot/loader/entries")?; + + // Build the expected ostree= value from the deployment to match against. + // The ostree= karg format is: /ostree/boot.N/$stateroot/$bootcsum/$bootserial + // where bootcsum is the boot checksum and bootserial is the serial among + // deployments sharing the same bootcsum (NOT the deployserial). + let stateroot = deployment.stateroot(); + let bootserial = deployment.bootserial(); + let bootcsum = deployment.bootcsum(); + let ostree_match = format!("/{stateroot}/{bootcsum}/{bootserial}"); + + for entry in entries_dir.entries_utf8()? { + let entry = entry?; + let file_name = entry.file_name()?; + + if !file_name.starts_with("ostree-") || !file_name.ends_with(".conf") { + continue; + } + let content = entries_dir + .read_to_string(&file_name) + .with_context(|| format!("Reading BLS entry {file_name}"))?; + // Match by parsing the ostree= karg from the options line and checking + // that its path ends with our deployment's stateroot/bootcsum/bootserial. + // A simple `contains` would be fragile (e.g., serial 0 vs 01). + if content.lines().any(|line| { + line.starts_with("options ") + && line.split_ascii_whitespace().any(|arg| { + arg.strip_prefix("ostree=") + .is_some_and(|path| path.ends_with(&ostree_match)) + }) + }) { + return Ok(Some(content)); + } + } + + Ok(None) +} + +/// Set the kernel arguments for a specific source via ostree staged deployment. +/// +/// If no staged deployment exists, this stages a new deployment based on +/// the booted deployment's commit with the updated kargs. If a staged +/// deployment already exists (e.g. from `bootc upgrade`), it is replaced +/// with a new one using the staged commit and origin, preserving any +/// pending upgrade while layering the source kargs change on top. +/// +/// The `x-options-source-*` keys survive the staging roundtrip via the +/// ostree `bootconfig-extra` serialization: source keys are set on the +/// merge deployment's in-memory bootconfig before staging, ostree inherits +/// them during `stage_tree_with_options()`, serializes them into the staged +/// GVariant, and restores them at shutdown during finalization. +#[context("Setting options for source '{source}' (staged)")] +pub(crate) fn set_options_for_source_staged( + sysroot: &ostree_ext::sysroot::SysrootLock, + source: &str, + new_options: Option<&str>, +) -> Result<()> { + let source = SourceName::parse(source)?; + + // The bootconfig-extra serialization (preserving x-prefixed BLS keys through + // staged deployment roundtrips) was added in ostree 2026.1. Without it, + // source keys are silently dropped during finalization at shutdown. + if !ostree::check_version(2026, 1) { + anyhow::bail!("This feature requires ostree >= 2026.1 for bootconfig-extra support"); + } + + let booted = sysroot + .booted_deployment() + .ok_or_else(|| anyhow::anyhow!("Not booted into an ostree deployment"))?; + + // Determine the "base" deployment whose kargs and source keys we start from. + // If there's already a staged deployment (e.g. from `bootc upgrade`), we use + // its commit, origin, and kargs so we don't discard a pending upgrade. If no + // staged deployment exists, we use the booted deployment. + let staged = sysroot.staged_deployment(); + let base_deployment = staged.as_ref().unwrap_or(&booted); + + let bootconfig = ostree::Deployment::bootconfig(base_deployment) + .ok_or_else(|| anyhow::anyhow!("Base deployment has no bootconfig"))?; + + // Read current options from the base deployment's bootconfig. + let current_options = bootconfig + .get("options") + .map(|s| s.to_string()) + .unwrap_or_default(); + + // Read existing x-options-source-* keys. + // + // Known limitation: when multiple *different* sources call set-options-for-source + // before rebooting (e.g., source A then source B), the second call can only + // discover source A if it was already in the booted BLS entry or is the target + // source. If source A was brand-new (added in a previous staged deployment that + // was never booted), its keys may not be discovered here and could be lost when + // the staged deployment is replaced. In practice, this is unlikely — sources + // like TuneD run at boot after finalization, so there's no staged deployment. + // A future improvement could store a manifest of active sources in a dedicated + // BLS key (e.g., x-bootc-active-sources) to enable full discovery. + 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). Discover source names from the booted BLS entry, + // then probe the staged bootconfig for their values. + let mut sources = BTreeMap::new(); + if let Some(bls_content) = + read_bls_entry_for_deployment(&booted).context("Reading booted BLS entry")? + { + 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())); + } + } + } + // 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. + if !sources.contains_key(&*source) { + let target_key = source.bls_key(); + if let Some(val) = bootconfig.get(&target_key) { + if !val.is_empty() { + sources.insert(source.0.clone(), CmdlineOwned::from(val.to_string())); + } + } + } + sources + } else { + // For booted deployments, parse the BLS file directly + let bls_content = read_bls_entry_for_deployment(&booted) + .context("Reading booted BLS entry")? + .ok_or_else(|| anyhow::anyhow!("No BLS entry found for booted deployment"))?; + extract_source_options_from_bls(&bls_content) + }; + + // Compute merged options + let source_key = source.bls_key(); + let merged = compute_merged_options(¤t_options, &source_options, &source, new_options); + + // Check for idempotency: if nothing changed, skip staging. + // Compare the merged cmdline against the current one, and the source value. + let merged_str = merged.to_string(); + let is_options_unchanged = merged_str == current_options; + let is_source_unchanged = match (source_options.get(&*source), new_options) { + (Some(old), Some(new)) => &**old == new, + (None, None) => true, + _ => false, + }; + + if is_options_unchanged && is_source_unchanged { + tracing::info!("No changes needed for source '{source}'"); + return Ok(()); + } + + // Use the base deployment's commit and origin so we don't discard a + // pending upgrade. The merge deployment is always the booted one (for + // /etc merge), but the commit/origin come from whichever deployment + // we're building on top of. + let stateroot = booted.stateroot(); + let merge_deployment = sysroot + .merge_deployment(Some(stateroot.as_str())) + .unwrap_or_else(|| booted.clone()); + + let origin = ostree::Deployment::origin(base_deployment) + .ok_or_else(|| anyhow::anyhow!("Base deployment has no origin"))?; + + let ostree_commit = base_deployment.csum(); + + // Update the source keys on the merge deployment's bootconfig BEFORE staging. + // The ostree patch (bootconfig-extra) inherits x-prefixed keys from the merge + // deployment's bootconfig during stage_tree_with_options(). By updating the + // merge deployment's in-memory bootconfig here, the updated source keys will + // be serialized into the staged GVariant and survive finalization at shutdown. + let merge_bootconfig = ostree::Deployment::bootconfig(&merge_deployment) + .ok_or_else(|| anyhow::anyhow!("Merge deployment has no bootconfig"))?; + + // Set all desired source keys on the merge bootconfig. + // First, clear any existing source keys that we know about by setting + // them to empty string. BootconfigParser has no remove() API, so "" + // acts as a tombstone. An empty x-options-source-* key is harmless: + // extract_source_options_from_bls will parse it as an empty value, + // and the idempotency check skips empty values (!val.is_empty()). + for (name, _) in &source_options { + 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_source_name_validation() { + assert!(SourceName::parse("tuned").is_ok()); + assert!(SourceName::parse("bootc-kargs-d").is_ok()); + assert!(SourceName::parse("my_source_123").is_ok()); + assert!(SourceName::parse("").is_err()); + assert!(SourceName::parse("bad name").is_err()); + assert!(SourceName::parse("bad/name").is_err()); + assert!(SourceName::parse("bad.name").is_err()); + } + + #[test] + fn test_source_name_bls_key() { + let name = SourceName::parse("tuned").unwrap(); + assert_eq!(name.bls_key(), "x-options-source-tuned"); + } + + #[test] + fn test_extract_source_options_from_bls() { + let bls = "\ +title Fedora Linux 43 +version 6.8.0-300.fc40.x86_64 +linux /vmlinuz-6.8.0 +initrd /initramfs-6.8.0.img +options root=UUID=abc rw nohz=full isolcpus=1-3 rd.driver.pre=vfio-pci +x-options-source-tuned nohz=full isolcpus=1-3 +x-options-source-dracut rd.driver.pre=vfio-pci +"; + + let sources = extract_source_options_from_bls(bls); + assert_eq!(sources.len(), 2); + assert_eq!(&*sources["tuned"], "nohz=full isolcpus=1-3"); + assert_eq!(&*sources["dracut"], "rd.driver.pre=vfio-pci"); + } + + #[test] + fn test_extract_source_options_ignores_non_source_keys() { + let bls = "\ +title Test +version 1 +linux /vmlinuz +options root=UUID=abc +x-unrelated-key some-value +custom-key data +"; + + let sources = extract_source_options_from_bls(bls); + assert!(sources.is_empty()); + } + + #[test] + fn test_compute_merged_options_add_new_source() { + let current = "root=UUID=abc123 rw composefs=digest123"; + let sources = BTreeMap::new(); + let source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options( + current, + &sources, + &source, + 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 source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options(current, &sources, &source, 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 source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options(current, &sources, &source, None); + + assert_eq!(&*result, "root=UUID=abc123 rw"); + } + + #[test] + fn test_compute_merged_options_empty_initial() { + let current = ""; + let sources = BTreeMap::new(); + let source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options(current, &sources, &source, 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 source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options(current, &sources, &source, 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 source = SourceName::parse("tuned").unwrap(); + + let result = compute_merged_options(current, &sources, &source, 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()), + ); + let source = SourceName::parse("tuned").unwrap(); + + // Update tuned, dracut should be preserved + let result = compute_merged_options(current, &sources, &source, 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..2abb15459 --- /dev/null +++ b/docs/src/man/bootc-loader-entries-set-options-for-source.8.md @@ -0,0 +1,86 @@ +# NAME + +bootc-loader-entries-set-options-for-source - Set or update the kernel arguments owned by a specific source + +# SYNOPSIS + +bootc loader-entries set-options-for-source **--source** *NAME* [**--options** *"KARGS"*] + +# DESCRIPTION + +Set or update the kernel arguments owned by a specific source. Each +source's arguments are tracked via `x-options-source-` extension +keys in BLS config files on `/boot`. The `options` line is recomputed +as the merge of all tracked sources plus any untracked (pre-existing) +options. + +This command stages a new deployment with the updated kernel arguments. +Changes take effect on the next reboot. + +When a staged deployment already exists (e.g. from `bootc upgrade`), +it is replaced using the staged deployment's commit and origin, +preserving the pending upgrade while layering the kargs change on top. + +# OPTIONS + + +**--source**=*SOURCE* + +: The name of the source that owns these kernel arguments. + Must contain only alphanumeric characters, hyphens, or underscores. + Examples: `tuned`, `admin`, `bootc-kargs-d`. + +**--options**=*OPTIONS* + +: The kernel arguments to set for this source, as a space-separated + string. If not provided, the source is removed and its options are + dropped from the merged `options` line. If provided as an empty + string (`--options ""`), all kargs for the source are cleared. + + + +# REQUIREMENTS + +This command requires ostree >= 2026.1 with `bootconfig-extra` support +for preserving extension BLS keys through staged deployment roundtrips. +On older ostree versions, the command will exit with an error. + +# EXAMPLES + +Add TuneD kernel arguments: + + bootc loader-entries set-options-for-source --source tuned \ + --options "isolcpus=1-3 nohz_full=1-3" + +Update TuneD kernel arguments (replaces previous values): + + bootc loader-entries set-options-for-source --source tuned \ + --options "isolcpus=0-7" + +Remove all kernel arguments owned by TuneD: + + bootc loader-entries set-options-for-source --source tuned + +Multiple sources can coexist independently: + + bootc loader-entries set-options-for-source --source tuned \ + --options "nohz=full isolcpus=1-3" + bootc loader-entries set-options-for-source --source dracut \ + --options "rd.driver.pre=vfio-pci" + +# KNOWN LIMITATIONS + +When multiple different sources call this command before rebooting, only +the target source and sources already known from the booted BLS entry +are discovered. A source added in a previous staged deployment that was +never booted may not be discovered, potentially orphaning its kargs. +In practice this is unlikely, as sources like TuneD run at boot after +finalization when no staged deployment exists. + +# SEE ALSO + +**bootc**(8), **bootc-loader-entries**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-loader-entries.8.md b/docs/src/man/bootc-loader-entries.8.md new file mode 100644 index 000000000..623cc40b4 --- /dev/null +++ b/docs/src/man/bootc-loader-entries.8.md @@ -0,0 +1,33 @@ +# NAME + +bootc-loader-entries - Operations on Boot Loader Specification (BLS) entries + +# SYNOPSIS + +bootc loader-entries *COMMAND* + +# DESCRIPTION + +Manage kernel arguments from multiple independent sources by tracking +argument ownership via `x-options-source-` extension keys in BLS +config files. + +This solves the problem of kernel argument accumulation on bootc systems +with transient `/etc`, where tools like TuneD lose their state files on +reboot and cannot track which kargs they previously set. + + + + +# COMMANDS + +**set-options-for-source** +: Set or update the kernel arguments owned by a specific source. + +# SEE ALSO + +**bootc**(8), **bootc-loader-entries-set-options-for-source**(8) + +# VERSION + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index 99e673afc..d543d0499 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -33,6 +33,7 @@ pulled and `bootc upgrade`. | **bootc usr-overlay** | Add a transient overlayfs on `/usr` | | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | +| **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | | **bootc composefs-finalize-staged** | | diff --git a/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..4b052a430 --- /dev/null +++ b/tmt/tests/booted/test-loader-entries-source.nu @@ -0,0 +1,267 @@ +# 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 + # Note: ostree= is excluded because its value changes between deployments + # (boot version counter, bootcsum). It's managed by ostree's + # install_deployment_kernel() and always regenerated during finalization. + let system_kargs = $cmdline | where { |k| + (($k starts-with "root=") or ($k == "rw") or ($k starts-with "console=")) + } + $system_kargs | to json | save -f /var/bootc-test-system-kargs.json +} + +def load_system_kargs [] { + open /var/bootc-test-system-kargs.json +} + +def first_boot [] { + tap begin "loader-entries set-options-for-source" + + # Save system kargs for later verification + save_system_kargs + + # -- Input validation -- + + # Invalid source name (spaces) + let r = do -i { bootc loader-entries set-options-for-source --source "bad name" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "spaces in source name should fail" + + # Invalid source name (special chars) + let r = do -i { bootc loader-entries set-options-for-source --source "foo@bar" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "special chars in source name should fail" + + # Empty source name + let r = do -i { bootc loader-entries set-options-for-source --source "" --options "foo=bar" } | complete + assert ($r.exit_code != 0) "empty source name should fail" + + # Valid name with underscores/dashes + 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 -- + 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