Skip to content

Commit d2e418b

Browse files
authored
feat(operator/client): Support feature gate retrieval (#1207)
* feat(operator/client): Support feature gate retrieval * test(operator): Add feature gate parsing unit tests * chore(operator): Add changelog entry * test(operator): Test with real snippet, remove positive tests
1 parent 57d8d12 commit d2e418b

6 files changed

Lines changed: 310 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ tracing-opentelemetry = "0.32.0"
8686
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
8787
trybuild = "1.0.99"
8888
url = { version = "2.5.2", features = ["serde"] }
89+
winnow = "1.0.3"
8990
x509-cert = { version = "0.2.5", features = ["builder"] }
9091
zeroize = "1.8.1"
9192

crates/stackable-operator/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Add `Client::{get_feature_gates,get_enabled_feature_gates,get_disabled_feature_gates}` associated
10+
functions to retrieve all, enabled, or disabled feature gates from the Kubernetes apiserver ([#1207]).
11+
712
### Changed
813

914
- 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]).
1015

1116
[#1206]: https://github.com/stackabletech/operator-rs/pull/1206
17+
[#1207]: https://github.com/stackabletech/operator-rs/pull/1207
1218

1319
## [0.111.1] - 2026-04-28
1420

crates/stackable-operator/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ tracing.workspace = true
5454
tracing-appender.workspace = true
5555
tracing-subscriber.workspace = true
5656
url.workspace = true
57+
winnow.workspace = true
5758

5859
[dev-dependencies]
5960
indoc.workspace = true
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
use std::{collections::HashMap, str::FromStr};
2+
3+
use snafu::{OptionExt as _, ResultExt as _, Snafu};
4+
use winnow::{
5+
Parser as _,
6+
ascii::{alphanumeric0, alphanumeric1, digit1, space0, space1},
7+
combinator::{delimited, separated, separated_pair},
8+
};
9+
10+
use crate::client::{
11+
Client, CreateRawRequestSnafu, ParseFeatureGateSnafu, PerformRawRequestSnafu, Result,
12+
};
13+
14+
impl Client {
15+
/// Retrieves and parses all feature gates via a raw request to the `/metrics` endpoint.
16+
///
17+
/// This list of feature gates in combination with [`KubeClient::apiserver_version`] can be used
18+
/// to enable gated behaviour.
19+
pub async fn get_feature_gates(&self) -> Result<Vec<FeatureGate>> {
20+
let request =
21+
http::Request::get("/metrics")
22+
.body(vec![])
23+
.context(CreateRawRequestSnafu {
24+
method: http::Method::GET,
25+
})?;
26+
27+
let response = self
28+
.client
29+
.request_text(request)
30+
.await
31+
.context(PerformRawRequestSnafu)?;
32+
33+
FeatureGate::parse_from_metrics(&response)
34+
}
35+
36+
/// Retrieves enabled feature gates.
37+
///
38+
/// Uses [`Client::get_feature_gates`] internally.
39+
pub async fn get_enabled_feature_gates(&self) -> Result<Vec<FeatureGate>> {
40+
let feature_gates = self.get_feature_gates().await?;
41+
let enabled_feature_gates = feature_gates.into_iter().filter(|fg| fg.enabled).collect();
42+
43+
Ok(enabled_feature_gates)
44+
}
45+
46+
/// Retrieves disabled feature gates.
47+
///
48+
/// Uses [`Client::get_feature_gates`] internally.
49+
pub async fn get_disabled_feature_gates(&self) -> Result<Vec<FeatureGate>> {
50+
let feature_gates = self.get_feature_gates().await?;
51+
let disabled_feature_gates = feature_gates.into_iter().filter(|fg| !fg.enabled).collect();
52+
53+
Ok(disabled_feature_gates)
54+
}
55+
}
56+
57+
#[derive(Debug, Snafu)]
58+
enum FeatureGateParseError {
59+
#[snafu(display("required feature gate metric label missing, expected 'name' and 'stage'"))]
60+
MissingLabel,
61+
62+
#[snafu(display("failed to parse feature stage"))]
63+
ParseStage { source: strum::ParseError },
64+
65+
#[snafu(display("failed to parse string as integer"))]
66+
ParseInt { source: std::num::ParseIntError },
67+
}
68+
69+
#[derive(Debug)]
70+
pub struct FeatureGate {
71+
/// The name of the feature gate, eg. `AllowDNSOnlyNodeCSR`.
72+
pub name: String,
73+
74+
/// In which stage the feature is, eg. `ALPHA`.
75+
pub stage: FeatureStage,
76+
77+
/// Whether the feature is enabled or disabled.
78+
pub enabled: bool,
79+
}
80+
81+
impl FromStr for FeatureGate {
82+
type Err = String;
83+
84+
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
85+
Self::parse_from_metric
86+
.parse(s)
87+
.map_err(|err| err.to_string())
88+
}
89+
}
90+
91+
impl FeatureGate {
92+
pub const METRIC_NAME: &str = "kubernetes_feature_enabled";
93+
94+
/// Enumerates the complete body line-by-line and parses the relevant feature gate metrics.
95+
#[allow(clippy::result_large_err)]
96+
fn parse_from_metrics(body: &str) -> Result<Vec<Self>> {
97+
body.lines()
98+
.filter(|l| l.starts_with(Self::METRIC_NAME))
99+
.map(Self::from_str)
100+
.collect::<Result<Vec<Self>, _>>()
101+
.map_err(|error| ParseFeatureGateSnafu { error }.build())
102+
}
103+
104+
/// Parses a feature gate from the line-based `/metrics` response.
105+
///
106+
/// This function expects feature gates to be passed as individual lines.
107+
fn parse_from_metric(input: &mut &str) -> winnow::Result<Self> {
108+
(
109+
Self::parse_metric_name,
110+
delimited('{', Self::parse_labels, '}'),
111+
// At least one space after the metric and the value
112+
space1,
113+
// The counter value
114+
digit1,
115+
)
116+
.try_map(|((), mut kv_pairs, _, count)| {
117+
let name = kv_pairs
118+
.remove("name")
119+
.context(MissingLabelSnafu)?
120+
.to_owned();
121+
122+
let stage = kv_pairs
123+
.remove("stage")
124+
.context(MissingLabelSnafu)?
125+
.parse()
126+
.context(ParseStageSnafu)?;
127+
128+
let count = count.parse::<u8>().context(ParseIntSnafu)?;
129+
// TODO (@Techassi): Potentially replace this with TryFrom instead.
130+
// The TryFrom<u8> impl for bool is only available in Rust 1.95+
131+
let enabled = count != 0;
132+
133+
Ok::<Self, FeatureGateParseError>(Self {
134+
name,
135+
stage,
136+
enabled,
137+
})
138+
})
139+
.parse_next(input)
140+
}
141+
142+
/// Parses (and removes) the well-known, static metric name.
143+
fn parse_metric_name(input: &mut &str) -> winnow::Result<()> {
144+
Self::METRIC_NAME.void().parse_next(input)
145+
}
146+
147+
/// Parses and collects a list of labels contained within `{` and `}`.
148+
fn parse_labels<'s>(input: &mut &'s str) -> winnow::Result<HashMap<&'s str, &'s str>> {
149+
separated(
150+
// We expect at least two labels: name and stage
151+
2..,
152+
// The value of the label can be empty
153+
separated_pair(alphanumeric1, '=', ('"', alphanumeric0, '"'))
154+
.map(|(key, (_, value, _))| (key, value)),
155+
// There might be spaces between labels (separated by comma)
156+
(',', space0),
157+
)
158+
.parse_next(input)
159+
}
160+
}
161+
162+
/// A feature can be in one of four different stages.
163+
///
164+
/// See the [list of feature gates] and [feature stages] in the official documentation.
165+
///
166+
/// [list of feature gates]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-gates
167+
/// [feature stages]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-stages
168+
#[derive(Debug, strum::Display, strum::EnumString)]
169+
#[strum(serialize_all = "UPPERCASE")]
170+
pub enum FeatureStage {
171+
/// An Alpha feature.
172+
///
173+
/// - Disabled by default.
174+
/// - Might be buggy. Enabling the feature may expose bugs.
175+
/// - Support for feature may be dropped at any time without notice.
176+
/// - The API may change in incompatible ways in a later software release without notice.
177+
/// - Recommended for use only in short-lived testing clusters, due to increased risk of bugs
178+
/// and lack of long-term support.
179+
///
180+
/// Taken from the Kubernetes documentation.
181+
Alpha,
182+
183+
/// A Beta feature.
184+
///
185+
/// - Usually enabled by default. Beta API groups are [disabled by default].
186+
/// - The feature is well tested. Enabling the feature is considered safe.
187+
/// - Support for the overall feature will not be dropped, though details may change.
188+
/// - The schema and/or semantics of objects may change in incompatible ways in a subsequent
189+
/// beta or stable release. When this happens, we will provide instructions for migrating to
190+
/// the next version. This may require deleting, editing, and re-creating API objects. The
191+
/// editing process may require some thought. This may require downtime for applications that
192+
/// rely on the feature.
193+
/// - Recommended for only non-business-critical uses because of potential for incompatible
194+
/// changes in subsequent releases. If you have multiple clusters that can be upgraded
195+
/// independently, you may be able to relax this restriction.
196+
///
197+
/// Taken from the Kubernetes documentation.
198+
Beta,
199+
200+
/// A General Availability feature.
201+
///
202+
/// - The feature is always enabled; you cannot disable it.
203+
/// - The corresponding feature gate is no longer needed.
204+
/// - Stable versions of features will appear in released software for many subsequent versions.
205+
///
206+
/// Taken from the Kubernetes documentation.
207+
#[strum(serialize = "")]
208+
GeneralAvailability,
209+
210+
/// A feature is deprecated.
211+
///
212+
/// The official documentation doesn't explain this stage at all, but it exists (in metrics).
213+
Deprecated,
214+
}
215+
216+
#[cfg(test)]
217+
mod tests {
218+
use indoc::indoc;
219+
use rstest::rstest;
220+
221+
use super::*;
222+
use crate::client::{initialize_operator, tests::test_cluster_info_opts};
223+
224+
#[tokio::test]
225+
#[ignore = "Tests depending on Kubernetes are not ran by default"]
226+
async fn k8s_test_feature_gates() {
227+
let client = initialize_operator(None, &test_cluster_info_opts())
228+
.await
229+
.expect("KUBECONFIG variable must be configured.");
230+
231+
let feature_gates = client
232+
.get_feature_gates()
233+
.await
234+
.expect("list of feature gates must parse");
235+
236+
for feature_gate in feature_gates {
237+
println!("{feature_gate:?}");
238+
}
239+
}
240+
241+
#[test]
242+
fn parse_feature_gates() {
243+
// This snippet is a combination of
244+
//
245+
// - kubectl get --raw /metrics | head
246+
// - kubectl get --raw /metrics | grep kubernetes_feature_enabled | head
247+
let response = indoc! {r#"
248+
# HELP aggregator_discovery_aggregation_count_total [ALPHA] Counter of number of times discovery was aggregated
249+
# TYPE aggregator_discovery_aggregation_count_total counter
250+
aggregator_discovery_aggregation_count_total 614
251+
# HELP aggregator_unavailable_apiservice [ALPHA] Gauge of APIServices which are marked as unavailable broken down by APIService name.
252+
# TYPE aggregator_unavailable_apiservice gauge
253+
aggregator_unavailable_apiservice{name="v1."} 0
254+
aggregator_unavailable_apiservice{name="v1.admissionregistration.k8s.io"} 0
255+
aggregator_unavailable_apiservice{name="v1.apiextensions.k8s.io"} 0
256+
aggregator_unavailable_apiservice{name="v1.apps"} 0
257+
aggregator_unavailable_apiservice{name="v1.authentication.k8s.io"} 0
258+
# ...
259+
# HELP kubernetes_feature_enabled [BETA] This metric records the data about the stage and enablement of a k8s feature.
260+
# TYPE kubernetes_feature_enabled gauge
261+
kubernetes_feature_enabled{name="APIResponseCompression",stage="BETA"} 1
262+
kubernetes_feature_enabled{name="APIServerIdentity",stage="BETA"} 1
263+
kubernetes_feature_enabled{name="APIServerTracing",stage="BETA"} 1
264+
kubernetes_feature_enabled{name="APIServingWithRoutine",stage="ALPHA"} 0
265+
kubernetes_feature_enabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1
266+
kubernetes_feature_enabled{name="AllAlpha",stage="ALPHA"} 0
267+
kubernetes_feature_enabled{name="AllBeta",stage="BETA"} 0
268+
kubernetes_feature_enabled{name="AllowDNSOnlyNodeCSR",stage="DEPRECATED"} 0
269+
"#};
270+
271+
assert!(FeatureGate::parse_from_metrics(response).is_ok());
272+
}
273+
274+
#[rstest]
275+
#[case(r#"kubernetes_feature_disabled{name="AggregatedDiscoveryRemoveBetaType",stage="DEPRECATED"} 1"#)]
276+
#[case(r#"kubernetes_feature_enabled{name="APIResponseCompression",stage="GAMMA"} 1"#)]
277+
#[case(r#"kubernetes_feature_enabled{name="APIResponseCompression"} 1"#)]
278+
#[case(r#"kubernetes_feature_enabled{="APIResponseCompression",="ALPHA"} 1"#)]
279+
#[case("kubernetes_feature_enabled{} 0")]
280+
#[case("")]
281+
fn parse_feature_gate_invalid(#[case] input: &str) {
282+
assert!(FeatureGate::from_str(input).is_err());
283+
}
284+
}

crates/stackable-operator/src/client.rs renamed to crates/stackable-operator/src/client/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use crate::{
2424
utils::cluster_info::{KubernetesClusterInfo, KubernetesClusterInfoOptions},
2525
};
2626

27+
mod feature_gates;
28+
2729
pub type Result<T, E = Error> = std::result::Result<T, E>;
2830

2931
#[derive(Debug, Snafu)]
@@ -89,6 +91,18 @@ pub enum Error {
8991
NewKubeletClusterInfo {
9092
source: crate::utils::cluster_info::Error,
9193
},
94+
95+
#[snafu(display("failed to create raw {method} request"))]
96+
CreateRawRequest {
97+
source: http::Error,
98+
method: http::Method,
99+
},
100+
101+
#[snafu(display("failed to perform raw request"))]
102+
PerformRawRequest { source: kube::Error },
103+
104+
#[snafu(display("failed to parse feature gate: {error}"))]
105+
ParseFeatureGate { error: String },
92106
}
93107

94108
/// This `Client` can be used to access Kubernetes.
@@ -708,7 +722,7 @@ mod tests {
708722

709723
use crate::utils::cluster_info::KubernetesClusterInfoOptions;
710724

711-
fn test_cluster_info_opts() -> KubernetesClusterInfoOptions {
725+
pub(super) fn test_cluster_info_opts() -> KubernetesClusterInfoOptions {
712726
KubernetesClusterInfoOptions {
713727
// We have to hard-code a made-up cluster domain,
714728
// since kubernetes_node_name (probably) won't be a valid Node that we can query.

0 commit comments

Comments
 (0)