Skip to content

Commit b1199d3

Browse files
committed
feat(operator/client): Support feature gate retrieval
1 parent 57d8d12 commit b1199d3

5 files changed

Lines changed: 249 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/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: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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+
response
34+
.lines()
35+
.filter(|l| l.starts_with("kubernetes_feature_enabled"))
36+
.map(FeatureGate::from_str)
37+
.collect::<Result<Vec<FeatureGate>, _>>()
38+
.map_err(|error| ParseFeatureGateSnafu { error }.build())
39+
}
40+
41+
/// Retrieves enabled feature gates.
42+
///
43+
/// Uses [`Client::get_feature_gates`] internally.
44+
pub async fn get_enabled_feature_gates(&self) -> Result<Vec<FeatureGate>> {
45+
let feature_gates = self.get_feature_gates().await?;
46+
let enabled_feature_gates = feature_gates.into_iter().filter(|fg| fg.enabled).collect();
47+
48+
Ok(enabled_feature_gates)
49+
}
50+
51+
/// Retrieves disabled feature gates.
52+
///
53+
/// Uses [`Client::get_feature_gates`] internally.
54+
pub async fn get_disabled_feature_gates(&self) -> Result<Vec<FeatureGate>> {
55+
let feature_gates = self.get_feature_gates().await?;
56+
let disabled_feature_gates = feature_gates.into_iter().filter(|fg| !fg.enabled).collect();
57+
58+
Ok(disabled_feature_gates)
59+
}
60+
}
61+
62+
#[derive(Debug, Snafu)]
63+
enum FeatureGateParseError {
64+
#[snafu(display("required feature gate metric label missing, expected 'name' and 'stage'"))]
65+
MissingLabel,
66+
67+
#[snafu(display("failed to parse feature stage"))]
68+
ParseStage { source: strum::ParseError },
69+
70+
#[snafu(display("failed to parse string as integer"))]
71+
ParseInt { source: std::num::ParseIntError },
72+
}
73+
74+
#[derive(Debug)]
75+
pub struct FeatureGate {
76+
/// The name of the feature gate, eg. `AllowDNSOnlyNodeCSR`.
77+
pub name: String,
78+
79+
/// In which stage the feature is, eg. `ALPHA`.
80+
pub stage: FeatureStage,
81+
82+
/// Whether the feature is enabled or disabled.
83+
pub enabled: bool,
84+
}
85+
86+
impl FromStr for FeatureGate {
87+
type Err = String;
88+
89+
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
90+
Self::parse_from_metric
91+
.parse(s)
92+
.map_err(|err| err.to_string())
93+
}
94+
}
95+
96+
impl FeatureGate {
97+
/// Parses a feature gate from the line-based `/metrics` response.
98+
///
99+
/// This function expects feature gates to be passed as individual lines.
100+
fn parse_from_metric(input: &mut &str) -> winnow::Result<Self> {
101+
(
102+
Self::parse_metric_name,
103+
delimited('{', Self::parse_labels, '}'),
104+
// At least one space after the metric and the value
105+
space1,
106+
// The counter value
107+
digit1,
108+
)
109+
.try_map(|((), mut kv_pairs, _, count)| {
110+
let name = kv_pairs
111+
.remove("name")
112+
.context(MissingLabelSnafu)?
113+
.to_owned();
114+
115+
let stage = kv_pairs
116+
.remove("stage")
117+
.context(MissingLabelSnafu)?
118+
.parse()
119+
.context(ParseStageSnafu)?;
120+
121+
let count = count.parse::<u8>().context(ParseIntSnafu)?;
122+
// TODO (@Techassi): Potentially replace this with TryFrom instead.
123+
// The TryFrom<u8> impl for bool is only available in Rust 1.95+
124+
let enabled = count != 0;
125+
126+
Ok::<Self, FeatureGateParseError>(Self {
127+
name,
128+
stage,
129+
enabled,
130+
})
131+
})
132+
.parse_next(input)
133+
}
134+
135+
/// Parses (and removes) the well-known, static metric name.
136+
fn parse_metric_name(input: &mut &str) -> winnow::Result<()> {
137+
"kubernetes_feature_enabled".void().parse_next(input)
138+
}
139+
140+
/// Parses and collects a list of labels contained within `{` and `}`.
141+
fn parse_labels<'s>(input: &mut &'s str) -> winnow::Result<HashMap<&'s str, &'s str>> {
142+
separated(
143+
// We expect at least two labels: name and stage
144+
2..,
145+
// The value of the label can be empty
146+
separated_pair(alphanumeric1, '=', ('"', alphanumeric0, '"'))
147+
.map(|(key, (_, value, _))| (key, value)),
148+
// There might be spaces between labels (separated by comma)
149+
(',', space0),
150+
)
151+
.parse_next(input)
152+
}
153+
}
154+
155+
/// A feature can be in one of four different stages.
156+
///
157+
/// See the [list of feature gates] and [feature stages] in the official documentation.
158+
///
159+
/// [list of feature gates]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-gates
160+
/// [feature stages]: https://v1-35.docs.kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-stages
161+
#[derive(Debug, strum::Display, strum::EnumString)]
162+
#[strum(serialize_all = "UPPERCASE")]
163+
pub enum FeatureStage {
164+
/// An Alpha feature.
165+
///
166+
/// - Disabled by default.
167+
/// - Might be buggy. Enabling the feature may expose bugs.
168+
/// - Support for feature may be dropped at any time without notice.
169+
/// - The API may change in incompatible ways in a later software release without notice.
170+
/// - Recommended for use only in short-lived testing clusters, due to increased risk of bugs
171+
/// and lack of long-term support.
172+
///
173+
/// Taken from the Kubernetes documentation.
174+
Alpha,
175+
176+
/// A Beta feature.
177+
///
178+
/// - Usually enabled by default. Beta API groups are [disabled by default].
179+
/// - The feature is well tested. Enabling the feature is considered safe.
180+
/// - Support for the overall feature will not be dropped, though details may change.
181+
/// - The schema and/or semantics of objects may change in incompatible ways in a subsequent
182+
/// beta or stable release. When this happens, we will provide instructions for migrating to
183+
/// the next version. This may require deleting, editing, and re-creating API objects. The
184+
/// editing process may require some thought. This may require downtime for applications that
185+
/// rely on the feature.
186+
/// - Recommended for only non-business-critical uses because of potential for incompatible
187+
/// changes in subsequent releases. If you have multiple clusters that can be upgraded
188+
/// independently, you may be able to relax this restriction.
189+
///
190+
/// Taken from the Kubernetes documentation.
191+
Beta,
192+
193+
/// A General Availability feature.
194+
///
195+
/// - The feature is always enabled; you cannot disable it.
196+
/// - The corresponding feature gate is no longer needed.
197+
/// - Stable versions of features will appear in released software for many subsequent versions.
198+
///
199+
/// Taken from the Kubernetes documentation.
200+
#[strum(serialize = "")]
201+
GeneralAvailability,
202+
203+
/// A feature is deprecated.
204+
///
205+
/// The official documentation doesn't explain this stage at all, but it exists (in metrics).
206+
Deprecated,
207+
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use crate::client::{initialize_operator, tests::test_cluster_info_opts};
212+
213+
#[tokio::test]
214+
#[ignore = "Tests depending on Kubernetes are not ran by default"]
215+
async fn k8s_test_feature_gates() {
216+
let client = initialize_operator(None, &test_cluster_info_opts())
217+
.await
218+
.expect("KUBECONFIG variable must be configured.");
219+
220+
let feature_gates = client
221+
.get_feature_gates()
222+
.await
223+
.expect("list of feature gates must parse");
224+
225+
for feature_gate in feature_gates {
226+
println!("{feature_gate:?}");
227+
}
228+
}
229+
}

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)