From d02d5e4999c6537798c30acda3982a47a4c58f08 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Mon, 21 Sep 2026 15:15:45 -0400 Subject: [PATCH] cli: Make `loader-entries` unavailable when ostree lacks bootconfig-extra `bootc loader-entries set-options-for-source` only works with libostree >= 2026.1, which added the `bootconfig-extra` serialization that carries `x-options-source-*` keys through a staged deployment. Until now the subcommand was advertised in `bootc --help` on every host and only failed at runtime. That matters for TuneD: its bootc integration probes for the feature by running `bootc loader-entries set-options-for-source --help` and checking the exit status, and only falls back to its rpm-ostree/GRUB2 path when that fails. See . Merely hiding the subcommand from help would not be enough, since a hidden clap subcommand still answers `--help` with exit status 0. So detect the feature via `ostree_check_version()` at runtime and, when it is missing, make the subcommand unavailable: hide it from `--help` and reject any invocation of it before parsing, `--help` included, with an error naming the required ostree version. That rejection exits with status 77 (the "skipped" convention) rather than clap's usage-error 2, so a caller can tell "not supported on this host" from a mistake. The gate is applied only at argument-parsing time, not in `Opt::command()`. That keeps the man page sync (`dump-cli-json`) and `bootc completion` (run by the Makefile at package build time) describing the full CLI, so generated docs and completions don't change depending on the ostree version of whichever host built them. Assisted-by: AI Signed-off-by: Joseph Marrero Corchado --- crates/lib/src/cli.rs | 154 +++++++++++++++++- crates/lib/src/loader_entries.rs | 30 +++- ...loader-entries-set-options-for-source.8.md | 6 +- 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4a8ccea630..240e67c62a 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -895,6 +895,9 @@ pub(crate) enum LoaderEntriesOpts { SetOptionsForSource(SetOptionsForSourceOpts), } +/// The name of the `bootc loader-entries` subcommand. +const LOADER_ENTRIES_SUBCOMMAND: &str = "loader-entries"; + #[derive(Debug, clap::Subcommand, PartialEq, Eq)] pub(crate) enum StateOpts { /// Remove all ostree deployments from this system @@ -1003,7 +1006,10 @@ pub(crate) enum Opt { /// Operations on Boot Loader Specification (BLS) entries. /// /// Manage kernel arguments from multiple independent sources. - #[clap(subcommand)] + // + // Unavailable on hosts whose ostree lacks `bootconfig-extra` support; + // see `apply_host_feature_gates`. + #[clap(subcommand, name = LOADER_ENTRIES_SUBCOMMAND)] LoaderEntries(LoaderEntriesOpts), /// Execute the given command in the host mount namespace #[clap(hide = true)] @@ -1920,14 +1926,86 @@ impl Opt { }; if let Some(base_args) = mapped { let base_args = base_args.iter().map(OsString::from); - return Opt::parse_from(base_args.chain(args.map(|i| i.into()))); + return Opt::parse_from_host(base_args.chain(args.map(|i| i.into()))); } Some(first) } else { None }; - Opt::parse_from(first.into_iter().chain(args.map(|i| i.into()))) + Opt::parse_from_host(first.into_iter().chain(args.map(|i| i.into()))) + } + + /// Equivalent to [`clap::Parser::parse_from`], but with host feature gates + /// applied so that `--help` and error output only advertise what this host + /// supports, and gated subcommands are rejected before being dispatched. + /// + /// [`Opt::command`] always describes the full CLI; it is what documentation + /// and shell completions are generated from, so those don't vary with the + /// ostree version on the build host. + fn parse_from_host(args: I) -> Self + where + I: IntoIterator, + I::Item: Into + Clone, + { + use clap::FromArgMatches; + let args: Vec = args.into_iter().map(Into::into).collect(); + let bootconfig_extra = crate::loader_entries::ostree_supports_bootconfig_extra(); + let host_command = || apply_host_feature_gates(Opt::command(), bootconfig_extra); + // A gated subcommand is rejected whatever its arguments, `--help` + // included, with a distinct exit status so that a caller probing for + // the feature can tell "unsupported here" from a usage error. + if host_feature_gate_rejects(&args, bootconfig_extra) { + let err = host_command().error( + clap::error::ErrorKind::InvalidSubcommand, + crate::loader_entries::unsupported_ostree_error(), + ); + // Only stderr can fail here, and we're exiting anyway. + let _ = err.print(); + std::process::exit(EXIT_UNAVAILABLE); + } + let mut matches = host_command().get_matches_from(args); + match Self::from_arg_matches_mut(&mut matches) { + Ok(opt) => opt, + Err(e) => e.format(&mut host_command()).exit(), + } + } +} + +/// Exit status when a subcommand is unavailable because the running host +/// lacks the feature behind it (see [`host_feature_gate_rejects`]). Distinct +/// from a usage error (2) and a runtime failure (1) so that a caller probing +/// for the feature, as TuneD does with +/// `bootc loader-entries set-options-for-source --help`, can tell +/// "not supported here" from "failed". 77 is the status test harnesses use +/// for "skipped". +pub const EXIT_UNAVAILABLE: i32 = 77; + +/// Hide subcommands whose backing functionality is missing on the running +/// host from `--help`. Invoking one is rejected before parsing by +/// [`host_feature_gate_rejects`]. +/// +/// `bootconfig_extra` is whether the host ostree supports `bootconfig-extra`, +/// which `loader-entries` requires; see +/// [`crate::loader_entries::ostree_supports_bootconfig_extra`]. +fn apply_host_feature_gates(cmd: clap::Command, bootconfig_extra: bool) -> clap::Command { + if bootconfig_extra { + return cmd; + } + cmd.mut_subcommand(LOADER_ENTRIES_SUBCOMMAND, |c| c.hide(true)) +} + +/// Whether `args` (argv, including argv0) invoke a subcommand that +/// [`apply_host_feature_gates`] made unavailable. The top-level command has +/// no options that take a value, so the first non-option argument is the +/// subcommand name. +fn host_feature_gate_rejects(args: &[OsString], bootconfig_extra: bool) -> bool { + if bootconfig_extra { + return false; } + args.iter() + .skip(1) + .find(|a| !a.to_string_lossy().starts_with('-')) + .is_some_and(|a| a.as_os_str() == OsStr::new(LOADER_ENTRIES_SUBCOMMAND)) } /// Internal (non-generic/monomorphized) primary CLI entrypoint @@ -2913,4 +2991,74 @@ mod tests { } } } + + #[test] + fn test_host_feature_gates() { + // The full CLI (used for docs and completions) always advertises the command. + let full = Opt::command(); + let sub = full + .find_subcommand(LOADER_ENTRIES_SUBCOMMAND) + .expect("loader-entries subcommand"); + assert!(!sub.is_hide_set()); + + let invocation = [ + "bootc", + LOADER_ENTRIES_SUBCOMMAND, + "set-options-for-source", + "--source", + "tuned", + ]; + + // (host ostree supports bootconfig-extra, expect subcommand available) + for (supported, expect_available) in [(true, true), (false, false)] { + let cmd = apply_host_feature_gates(Opt::command(), supported); + let visible: Vec<_> = cmd + .get_subcommands() + .filter(|c| !c.is_hide_set()) + .map(clap::Command::get_name) + .collect(); + assert_eq!( + visible.contains(&LOADER_ENTRIES_SUBCOMMAND), + expect_available, + "bootconfig_extra={supported}: {visible:?}" + ); + + // Any invocation of the gated subcommand, including the `--help` + // probe TuneD uses, is rejected before parsing on unsupported + // hosts (with EXIT_UNAVAILABLE); everything else is left to clap. + let argv = |a: &[&str]| a.iter().map(OsString::from).collect::>(); + for gated in [ + argv(&["bootc", LOADER_ENTRIES_SUBCOMMAND, "--help"]), + argv(&[ + "bootc", + LOADER_ENTRIES_SUBCOMMAND, + "set-options-for-source", + "--help", + ]), + argv(&invocation), + ] { + assert_eq!( + host_feature_gate_rejects(&gated, supported), + !expect_available, + "bootconfig_extra={supported}: {gated:?}" + ); + } + for other in [ + argv(&["bootc", "status"]), + argv(&["bootc", "--version"]), + argv(&["bootc"]), + ] { + assert!(!host_feature_gate_rejects(&other, supported), "{other:?}"); + } + + // With the gate open the invocation parses normally. + if expect_available { + let opt = ::from_arg_matches( + &cmd.clone().try_get_matches_from(invocation).unwrap(), + ) + .unwrap(); + assert!(matches!(opt, Opt::LoaderEntries(_))); + } + } + } } diff --git a/crates/lib/src/loader_entries.rs b/crates/lib/src/loader_entries.rs index 3f193da392..92d0ecf669 100644 --- a/crates/lib/src/loader_entries.rs +++ b/crates/lib/src/loader_entries.rs @@ -19,6 +19,27 @@ use std::collections::BTreeMap; /// The BLS extension key prefix for source-tracked options. const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-"; +/// The ostree release (year, release) that introduced `bootconfig-extra` +/// serialization, which preserves `x-` prefixed BLS keys across staged +/// deployment roundtrips. See . +const OSTREE_BOOTCONFIG_EXTRA_VERSION: (u32, u32) = (2026, 1); + +/// Whether the libostree loaded at runtime supports `bootconfig-extra`. +/// +/// Without it, the `x-options-source-*` keys set by this module are +/// silently dropped during finalization at shutdown, so the feature +/// cannot work at all. +pub(crate) fn ostree_supports_bootconfig_extra() -> bool { + let (year, release) = OSTREE_BOOTCONFIG_EXTRA_VERSION; + ostree::check_version(year, release) +} + +/// The error reported when the host ostree lacks `bootconfig-extra` support. +pub(crate) fn unsupported_ostree_error() -> anyhow::Error { + let (year, release) = OSTREE_BOOTCONFIG_EXTRA_VERSION; + anyhow::anyhow!("This feature requires ostree >= {year}.{release} for bootconfig-extra support") +} + /// A validated source name (alphanumeric + hyphens + underscores, non-empty). /// /// This is a newtype wrapper around `String` that enforces validation at @@ -250,11 +271,10 @@ pub(crate) fn set_options_for_source_staged( ) -> 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"); + // The CLI refuses to dispatch here on unsupported hosts, but keep the + // check so the library API can't be misused either. + if !ostree_supports_bootconfig_extra() { + return Err(unsupported_ostree_error()); } let booted = sysroot 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 index 238e402042..618ae17fb3 100644 --- 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 @@ -38,7 +38,11 @@ preserving the pending upgrade while layering the kargs change on top. 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. +On older ostree versions, **bootc loader-entries** is unavailable: it is +omitted from **bootc --help**, and invoking it (including with **--help**) +prints an error and exits with status 77, distinct from usage errors (2) +and other failures (1). Callers can probe for the feature by checking the +exit status of **bootc loader-entries set-options-for-source --help**. # EXAMPLES