From b1199d3fd88d1b8cf12c9b867f804302e4c001a2 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 26 May 2026 15:29:23 +0200 Subject: [PATCH 1/4] feat(operator/client): Support feature gate retrieval --- Cargo.lock | 5 +- Cargo.toml | 1 + crates/stackable-operator/Cargo.toml | 1 + .../src/client/feature_gates.rs | 229 ++++++++++++++++++ .../src/{client.rs => client/mod.rs} | 16 +- 5 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 crates/stackable-operator/src/client/feature_gates.rs rename crates/stackable-operator/src/{client.rs => client/mod.rs} (98%) diff --git a/Cargo.lock b/Cargo.lock index 1c55de634..b9d7ac73f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3028,6 +3028,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "url", + "winnow", ] [[package]] @@ -4073,9 +4074,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index fe90a9506..816aa6be6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ tracing-opentelemetry = "0.32.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } trybuild = "1.0.99" url = { version = "2.5.2", features = ["serde"] } +winnow = "1.0.3" x509-cert = { version = "0.2.5", features = ["builder"] } zeroize = "1.8.1" diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 21cdd12de..193285726 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -54,6 +54,7 @@ tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true url.workspace = true +winnow.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/stackable-operator/src/client/feature_gates.rs b/crates/stackable-operator/src/client/feature_gates.rs new file mode 100644 index 000000000..6d35af9e5 --- /dev/null +++ b/crates/stackable-operator/src/client/feature_gates.rs @@ -0,0 +1,229 @@ +use std::{collections::HashMap, str::FromStr}; + +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use winnow::{ + Parser as _, + ascii::{alphanumeric0, alphanumeric1, digit1, space0, space1}, + combinator::{delimited, separated, separated_pair}, +}; + +use crate::client::{ + Client, CreateRawRequestSnafu, ParseFeatureGateSnafu, PerformRawRequestSnafu, Result, +}; + +impl Client { + /// Retrieves and parses all feature gates via a raw request to the `/metrics` endpoint. + /// + /// This list of feature gates in combination with [`KubeClient::apiserver_version`] can be used + /// to enable gated behaviour. + pub async fn get_feature_gates(&self) -> Result> { + let request = + http::Request::get("/metrics") + .body(vec![]) + .context(CreateRawRequestSnafu { + method: http::Method::GET, + })?; + + let response = self + .client + .request_text(request) + .await + .context(PerformRawRequestSnafu)?; + + response + .lines() + .filter(|l| l.starts_with("kubernetes_feature_enabled")) + .map(FeatureGate::from_str) + .collect::, _>>() + .map_err(|error| ParseFeatureGateSnafu { error }.build()) + } + + /// Retrieves enabled feature gates. + /// + /// Uses [`Client::get_feature_gates`] internally. + pub async fn get_enabled_feature_gates(&self) -> Result> { + let feature_gates = self.get_feature_gates().await?; + let enabled_feature_gates = feature_gates.into_iter().filter(|fg| fg.enabled).collect(); + + Ok(enabled_feature_gates) + } + + /// Retrieves disabled feature gates. + /// + /// Uses [`Client::get_feature_gates`] internally. + pub async fn get_disabled_feature_gates(&self) -> Result> { + let feature_gates = self.get_feature_gates().await?; + let disabled_feature_gates = feature_gates.into_iter().filter(|fg| !fg.enabled).collect(); + + Ok(disabled_feature_gates) + } +} + +#[derive(Debug, Snafu)] +enum FeatureGateParseError { + #[snafu(display("required feature gate metric label missing, expected 'name' and 'stage'"))] + MissingLabel, + + #[snafu(display("failed to parse feature stage"))] + ParseStage { source: strum::ParseError }, + + #[snafu(display("failed to parse string as integer"))] + ParseInt { source: std::num::ParseIntError }, +} + +#[derive(Debug)] +pub struct FeatureGate { + /// The name of the feature gate, eg. `AllowDNSOnlyNodeCSR`. + pub name: String, + + /// In which stage the feature is, eg. `ALPHA`. + pub stage: FeatureStage, + + /// Whether the feature is enabled or disabled. + pub enabled: bool, +} + +impl FromStr for FeatureGate { + type Err = String; + + fn from_str(s: &str) -> std::prelude::v1::Result { + Self::parse_from_metric + .parse(s) + .map_err(|err| err.to_string()) + } +} + +impl FeatureGate { + /// Parses a feature gate from the line-based `/metrics` response. + /// + /// This function expects feature gates to be passed as individual lines. + fn parse_from_metric(input: &mut &str) -> winnow::Result { + ( + Self::parse_metric_name, + delimited('{', Self::parse_labels, '}'), + // At least one space after the metric and the value + space1, + // The counter value + digit1, + ) + .try_map(|((), mut kv_pairs, _, count)| { + let name = kv_pairs + .remove("name") + .context(MissingLabelSnafu)? + .to_owned(); + + let stage = kv_pairs + .remove("stage") + .context(MissingLabelSnafu)? + .parse() + .context(ParseStageSnafu)?; + + let count = count.parse::().context(ParseIntSnafu)?; + // TODO (@Techassi): Potentially replace this with TryFrom instead. + // The TryFrom impl for bool is only available in Rust 1.95+ + let enabled = count != 0; + + Ok::(Self { + name, + stage, + enabled, + }) + }) + .parse_next(input) + } + + /// Parses (and removes) the well-known, static metric name. + fn parse_metric_name(input: &mut &str) -> winnow::Result<()> { + "kubernetes_feature_enabled".void().parse_next(input) + } + + /// Parses and collects a list of labels contained within `{` and `}`. + fn parse_labels<'s>(input: &mut &'s str) -> winnow::Result> { + separated( + // We expect at least two labels: name and stage + 2.., + // The value of the label can be empty + separated_pair(alphanumeric1, '=', ('"', alphanumeric0, '"')) + .map(|(key, (_, value, _))| (key, value)), + // There might be spaces between labels (separated by comma) + (',', space0), + ) + .parse_next(input) + } +} + +/// A feature can be in one of four different stages. +/// +/// See the [list of feature gates] and [feature stages] in the official documentation. +/// +/// [list of feature gates]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-gates +/// [feature stages]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-stages +#[derive(Debug, strum::Display, strum::EnumString)] +#[strum(serialize_all = "UPPERCASE")] +pub enum FeatureStage { + /// An Alpha feature. + /// + /// - Disabled by default. + /// - Might be buggy. Enabling the feature may expose bugs. + /// - Support for feature may be dropped at any time without notice. + /// - The API may change in incompatible ways in a later software release without notice. + /// - Recommended for use only in short-lived testing clusters, due to increased risk of bugs + /// and lack of long-term support. + /// + /// Taken from the Kubernetes documentation. + Alpha, + + /// A Beta feature. + /// + /// - Usually enabled by default. Beta API groups are [disabled by default]. + /// - The feature is well tested. Enabling the feature is considered safe. + /// - Support for the overall feature will not be dropped, though details may change. + /// - The schema and/or semantics of objects may change in incompatible ways in a subsequent + /// beta or stable release. When this happens, we will provide instructions for migrating to + /// the next version. This may require deleting, editing, and re-creating API objects. The + /// editing process may require some thought. This may require downtime for applications that + /// rely on the feature. + /// - Recommended for only non-business-critical uses because of potential for incompatible + /// changes in subsequent releases. If you have multiple clusters that can be upgraded + /// independently, you may be able to relax this restriction. + /// + /// Taken from the Kubernetes documentation. + Beta, + + /// A General Availability feature. + /// + /// - The feature is always enabled; you cannot disable it. + /// - The corresponding feature gate is no longer needed. + /// - Stable versions of features will appear in released software for many subsequent versions. + /// + /// Taken from the Kubernetes documentation. + #[strum(serialize = "")] + GeneralAvailability, + + /// A feature is deprecated. + /// + /// The official documentation doesn't explain this stage at all, but it exists (in metrics). + Deprecated, +} + +#[cfg(test)] +mod tests { + use crate::client::{initialize_operator, tests::test_cluster_info_opts}; + + #[tokio::test] + #[ignore = "Tests depending on Kubernetes are not ran by default"] + async fn k8s_test_feature_gates() { + let client = initialize_operator(None, &test_cluster_info_opts()) + .await + .expect("KUBECONFIG variable must be configured."); + + let feature_gates = client + .get_feature_gates() + .await + .expect("list of feature gates must parse"); + + for feature_gate in feature_gates { + println!("{feature_gate:?}"); + } + } +} diff --git a/crates/stackable-operator/src/client.rs b/crates/stackable-operator/src/client/mod.rs similarity index 98% rename from crates/stackable-operator/src/client.rs rename to crates/stackable-operator/src/client/mod.rs index cbd36391a..11f2fbfc3 100644 --- a/crates/stackable-operator/src/client.rs +++ b/crates/stackable-operator/src/client/mod.rs @@ -24,6 +24,8 @@ use crate::{ utils::cluster_info::{KubernetesClusterInfo, KubernetesClusterInfoOptions}, }; +mod feature_gates; + pub type Result = std::result::Result; #[derive(Debug, Snafu)] @@ -89,6 +91,18 @@ pub enum Error { NewKubeletClusterInfo { source: crate::utils::cluster_info::Error, }, + + #[snafu(display("failed to create raw {method} request"))] + CreateRawRequest { + source: http::Error, + method: http::Method, + }, + + #[snafu(display("failed to perform raw request"))] + PerformRawRequest { source: kube::Error }, + + #[snafu(display("failed to parse feature gate: {error}"))] + ParseFeatureGate { error: String }, } /// This `Client` can be used to access Kubernetes. @@ -708,7 +722,7 @@ mod tests { use crate::utils::cluster_info::KubernetesClusterInfoOptions; - fn test_cluster_info_opts() -> KubernetesClusterInfoOptions { + pub(super) fn test_cluster_info_opts() -> KubernetesClusterInfoOptions { KubernetesClusterInfoOptions { // We have to hard-code a made-up cluster domain, // since kubernetes_node_name (probably) won't be a valid Node that we can query. From 43902423604414aae7c6740557f696ba4efe8d7a Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 26 May 2026 16:16:58 +0200 Subject: [PATCH 2/4] test(operator): Add feature gate parsing unit tests --- .../src/client/feature_gates.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/stackable-operator/src/client/feature_gates.rs b/crates/stackable-operator/src/client/feature_gates.rs index 6d35af9e5..a38dd37c8 100644 --- a/crates/stackable-operator/src/client/feature_gates.rs +++ b/crates/stackable-operator/src/client/feature_gates.rs @@ -208,6 +208,9 @@ pub enum FeatureStage { #[cfg(test)] mod tests { + use rstest::rstest; + + use super::*; use crate::client::{initialize_operator, tests::test_cluster_info_opts}; #[tokio::test] @@ -226,4 +229,24 @@ mod tests { println!("{feature_gate:?}"); } } + + #[rstest] + #[case(r#"kubernetes_feature_enabled{name="APIResponseCompression",stage="BETA"} 1"#)] + #[case(r#"kubernetes_feature_enabled{name="APIServingWithRoutine",stage="ALPHA"} 0"#)] + #[case(r#"kubernetes_feature_enabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1"#)] + #[case(r#"kubernetes_feature_enabled{name="AnyVolumeDataSource",stage=""} 1"#)] + fn parse_feature_gate_valid(#[case] input: &str) { + assert!(FeatureGate::from_str(input).is_ok()) + } + + #[rstest] + #[case(r#"kubernetes_feature_disabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1"#)] + #[case(r#"kubernetes_feature_enabled{name="APIResponseCompression",stage="GAMMA"} 1"#)] + #[case(r#"kubernetes_feature_enabled{name="APIResponseCompression"} 1"#)] + #[case(r#"kubernetes_feature_enabled{="APIResponseCompression",="ALPHA"} 1"#)] + #[case("kubernetes_feature_enabled{} 0")] + #[case("")] + fn parse_feature_gate_invalid(#[case] input: &str) { + assert!(FeatureGate::from_str(input).is_err()) + } } From 733776c6235b918e48a6f83de4d5d710e5066992 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 26 May 2026 16:25:49 +0200 Subject: [PATCH 3/4] chore(operator): Add changelog entry --- crates/stackable-operator/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 676de476f..04a6b4fc7 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,11 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Add `Client::{get_feature_gates,get_enabled_feature_gates,get_disabled_feature_gates}` associated + functions to retrieve all, enabled, or disabled feature gates from the Kubernetes apiserver ([#1207]). + ### Changed - BREAKING: Use `serde_json::Value` instead of `String` for user-provided JSON `configOverrides`. This change is marked as breaking, as it causes a breaking change to the CRDs ([#1206]). [#1206]: https://github.com/stackabletech/operator-rs/pull/1206 +[#1207]: https://github.com/stackabletech/operator-rs/pull/1207 ## [0.111.1] - 2026-04-28 From b2264de6ac4b1ebd45a7d299b5664a26bdaafa42 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 26 May 2026 17:05:09 +0200 Subject: [PATCH 4/4] test(operator): Test with real snippet, remove positive tests --- .../src/client/feature_gates.rs | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/stackable-operator/src/client/feature_gates.rs b/crates/stackable-operator/src/client/feature_gates.rs index a38dd37c8..4f49c782c 100644 --- a/crates/stackable-operator/src/client/feature_gates.rs +++ b/crates/stackable-operator/src/client/feature_gates.rs @@ -30,12 +30,7 @@ impl Client { .await .context(PerformRawRequestSnafu)?; - response - .lines() - .filter(|l| l.starts_with("kubernetes_feature_enabled")) - .map(FeatureGate::from_str) - .collect::, _>>() - .map_err(|error| ParseFeatureGateSnafu { error }.build()) + FeatureGate::parse_from_metrics(&response) } /// Retrieves enabled feature gates. @@ -94,6 +89,18 @@ impl FromStr for FeatureGate { } impl FeatureGate { + pub const METRIC_NAME: &str = "kubernetes_feature_enabled"; + + /// Enumerates the complete body line-by-line and parses the relevant feature gate metrics. + #[allow(clippy::result_large_err)] + fn parse_from_metrics(body: &str) -> Result> { + body.lines() + .filter(|l| l.starts_with(Self::METRIC_NAME)) + .map(Self::from_str) + .collect::, _>>() + .map_err(|error| ParseFeatureGateSnafu { error }.build()) + } + /// Parses a feature gate from the line-based `/metrics` response. /// /// This function expects feature gates to be passed as individual lines. @@ -134,7 +141,7 @@ impl FeatureGate { /// Parses (and removes) the well-known, static metric name. fn parse_metric_name(input: &mut &str) -> winnow::Result<()> { - "kubernetes_feature_enabled".void().parse_next(input) + Self::METRIC_NAME.void().parse_next(input) } /// Parses and collects a list of labels contained within `{` and `}`. @@ -208,6 +215,7 @@ pub enum FeatureStage { #[cfg(test)] mod tests { + use indoc::indoc; use rstest::rstest; use super::*; @@ -230,13 +238,37 @@ mod tests { } } - #[rstest] - #[case(r#"kubernetes_feature_enabled{name="APIResponseCompression",stage="BETA"} 1"#)] - #[case(r#"kubernetes_feature_enabled{name="APIServingWithRoutine",stage="ALPHA"} 0"#)] - #[case(r#"kubernetes_feature_enabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1"#)] - #[case(r#"kubernetes_feature_enabled{name="AnyVolumeDataSource",stage=""} 1"#)] - fn parse_feature_gate_valid(#[case] input: &str) { - assert!(FeatureGate::from_str(input).is_ok()) + #[test] + fn parse_feature_gates() { + // This snippet is a combination of + // + // - kubectl get --raw /metrics | head + // - kubectl get --raw /metrics | grep kubernetes_feature_enabled | head + let response = indoc! {r#" + # HELP aggregator_discovery_aggregation_count_total [ALPHA] Counter of number of times discovery was aggregated + # TYPE aggregator_discovery_aggregation_count_total counter + aggregator_discovery_aggregation_count_total 614 + # HELP aggregator_unavailable_apiservice [ALPHA] Gauge of APIServices which are marked as unavailable broken down by APIService name. + # TYPE aggregator_unavailable_apiservice gauge + aggregator_unavailable_apiservice{name="v1."} 0 + aggregator_unavailable_apiservice{name="v1.admissionregistration.k8s.io"} 0 + aggregator_unavailable_apiservice{name="v1.apiextensions.k8s.io"} 0 + aggregator_unavailable_apiservice{name="v1.apps"} 0 + aggregator_unavailable_apiservice{name="v1.authentication.k8s.io"} 0 + # ... + # HELP kubernetes_feature_enabled [BETA] This metric records the data about the stage and enablement of a k8s feature. + # TYPE kubernetes_feature_enabled gauge + kubernetes_feature_enabled{name="APIResponseCompression",stage="BETA"} 1 + kubernetes_feature_enabled{name="APIServerIdentity",stage="BETA"} 1 + kubernetes_feature_enabled{name="APIServerTracing",stage="BETA"} 1 + kubernetes_feature_enabled{name="APIServingWithRoutine",stage="ALPHA"} 0 + kubernetes_feature_enabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1 + kubernetes_feature_enabled{name="AllAlpha",stage="ALPHA"} 0 + kubernetes_feature_enabled{name="AllBeta",stage="BETA"} 0 + kubernetes_feature_enabled{name="AllowDNSOnlyNodeCSR",stage="DEPRECATED"} 0 + "#}; + + assert!(FeatureGate::parse_from_metrics(response).is_ok()); } #[rstest] @@ -247,6 +279,6 @@ mod tests { #[case("kubernetes_feature_enabled{} 0")] #[case("")] fn parse_feature_gate_invalid(#[case] input: &str) { - assert!(FeatureGate::from_str(input).is_err()) + assert!(FeatureGate::from_str(input).is_err()); } }