|
| 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 | +} |
0 commit comments