Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions crates/stackable-operator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
229 changes: 229 additions & 0 deletions crates/stackable-operator/src/client/feature_gates.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<FeatureGate>> {
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::<Result<Vec<FeatureGate>, _>>()
.map_err(|error| ParseFeatureGateSnafu { error }.build())
}
Comment thread
NickLarsenNZ marked this conversation as resolved.
Outdated

/// Retrieves enabled feature gates.
///
/// Uses [`Client::get_feature_gates`] internally.
pub async fn get_enabled_feature_gates(&self) -> Result<Vec<FeatureGate>> {
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<Vec<FeatureGate>> {
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, Self::Err> {
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> {
(
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::<u8>().context(ParseIntSnafu)?;
// TODO (@Techassi): Potentially replace this with TryFrom instead.
// The TryFrom<u8> impl for bool is only available in Rust 1.95+
let enabled = count != 0;

Ok::<Self, FeatureGateParseError>(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<HashMap<&'s str, &'s str>> {
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:?}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ use crate::{
utils::cluster_info::{KubernetesClusterInfo, KubernetesClusterInfoOptions},
};

mod feature_gates;

pub type Result<T, E = Error> = std::result::Result<T, E>;

#[derive(Debug, Snafu)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading