From d35f18bb7397ae391bae22944e72a51f918015bc Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Thu, 2 Apr 2026 22:13:18 -0400 Subject: [PATCH 1/2] build: Add BOOTC_ostree_src for building with patched ostree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Dockerfile includes a `FROM scratch as ostree-packages` fallback stage so that builds without BOOTC_ostree_src (including CI) continue to work unchanged — the empty stage satisfies the mount reference without requiring a build context. 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 Opus 4.6) Signed-off-by: Joseph Marrero Corchado --- Dockerfile | 27 +++++ Justfile | 41 ++++++- contrib/packaging/Dockerfile.ostree-override | 113 +++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 contrib/packaging/Dockerfile.ostree-override 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/ / From 132ab41fe49145de7bb3ebdbeb703e5ad30bcf36 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Thu, 2 Apr 2026 22:13:41 -0400 Subject: [PATCH 2/2] loader-entries: Add `set-options-for-source` for source-tracked kargs Add a new `bootc loader-entries set-options-for-source` command that manages kernel arguments from independent sources (e.g. TuneD, admin) by tracking ownership via `x-options-source-` 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 Includes a multi-reboot TMT integration test covering: input validation, kargs surviving staging roundtrip, source replacement, multiple sources coexisting, source removal, idempotency, system kargs preservation, empty --options vs no --options, and staged deployment interaction with bootc switch. Example usage: bootc loader-entries set-options-for-source --source tuned \ --options "isolcpus=1-3 nohz_full=1-3" See: https://github.com/ostreedev/ostree/pull/3570 Assisted-by: OpenCode (Claude Opus 4.6) Signed-off-by: Joseph Marrero Corchado --- crates/lib/src/cli.rs | 66 +++ crates/lib/src/lib.rs | 1 + crates/lib/src/loader_entries.rs | 516 ++++++++++++++++++ ...loader-entries-set-options-for-source.8.md | 86 +++ docs/src/man/bootc-loader-entries.8.md | 33 ++ docs/src/man/bootc.8.md | 1 + tmt/plans/integration.fmf | 7 + .../booted/test-loader-entries-source.nu | 267 +++++++++ tmt/tests/tests.fmf | 5 + 9 files changed, 982 insertions(+) create mode 100644 crates/lib/src/loader_entries.rs create mode 100644 docs/src/man/bootc-loader-entries-set-options-for-source.8.md create mode 100644 docs/src/man/bootc-loader-entries.8.md create mode 100644 tmt/tests/booted/test-loader-entries-source.nu diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 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