Skip to main content

saluki_components/config_registry/
classifier.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use super::{PipelineAffinity, SchemaEntry, SupportLevel, ALL_ANNOTATIONS};
6
7/// Result of classifying a single config key/value pair against the registry.
8pub struct Classification {
9    /// The level at which this config key is supported by Saluki.
10    pub support_level: SupportLevel,
11    /// Whether the value matches the schema default.
12    pub is_default: bool,
13    /// Which pipelines this key's incompatibility warning applies to.
14    pub pipeline_affinity: PipelineAffinity,
15}
16
17/// Classifies the support level of config keys and determines if a value is default.
18///
19/// Only knows about annotated keys (`ALL_ANNOTATIONS`). Keys not in the registry - whether ignored,
20/// unrecognized, or anything else - return `None` from [`classify`](Self::classify).
21pub struct ConfigClassifier {
22    lookup: HashMap<&'static str, (&'static SchemaEntry, SupportLevel, PipelineAffinity)>,
23}
24
25impl ConfigClassifier {
26    /// Builds a classifier from all annotated config keys.
27    pub fn new() -> Self {
28        let mut lookup = HashMap::new();
29
30        for &annotation in ALL_ANNOTATIONS.iter() {
31            let entry = (
32                annotation.schema,
33                annotation.support_level,
34                annotation.pipeline_affinity,
35            );
36            lookup.insert(annotation.yaml_path(), entry);
37            for alias in annotation.additional_yaml_paths {
38                lookup.insert(alias, entry);
39            }
40        }
41
42        Self { lookup }
43    }
44
45    /// Classifies a single config key/value pair against the registry.
46    ///
47    /// Returns `None` for keys not in `ALL_ANNOTATIONS` (ignored, unrecognized, etc.).
48    pub fn classify(&self, key: &str, value: &Value) -> Option<Classification> {
49        let &(schema, support_level, pipeline_affinity) = self.lookup.get(key)?;
50        Some(Classification {
51            support_level,
52            is_default: is_default_value(schema, value),
53            pipeline_affinity,
54        })
55    }
56}
57
58/// Determines whether the runtime `value` matches the default value according to config registry.
59/// We rely on the schema's default and the runtime value deserializing to the same `serde_json::Value`.
60fn is_default_value(schema: &SchemaEntry, value: &Value) -> bool {
61    match schema.default {
62        Some(default_str) => match serde_json::from_str::<Value>(default_str) {
63            Ok(default_value) => *value == default_value,
64            Err(_) => false,
65        },
66        None => match value {
67            Value::Null => true,
68            // Empty string is considered equivalent to null in config.
69            Value::String(s) => s.is_empty(),
70            _ => false,
71        },
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::config_registry::datadog::unsupported;
79    use crate::config_registry::SUPPORTED_ANNOTATIONS;
80
81    fn classifier() -> ConfigClassifier {
82        ConfigClassifier::new()
83    }
84
85    #[test]
86    fn full_non_default() {
87        let c = classifier();
88        let result = c.classify("dogstatsd_port", &Value::Number(9999.into())).unwrap();
89        assert_eq!(result.support_level, SupportLevel::Full);
90        assert!(!result.is_default);
91    }
92
93    #[test]
94    fn full_default() {
95        let c = classifier();
96        let result = c.classify("dogstatsd_port", &Value::Number(8125.into())).unwrap();
97        assert_eq!(result.support_level, SupportLevel::Full);
98        assert!(result.is_default);
99    }
100
101    #[test]
102    fn partial_non_default() {
103        let c = classifier();
104        let partial = SUPPORTED_ANNOTATIONS
105            .iter()
106            .find(|a| a.support_level == SupportLevel::Partial)
107            .expect("need at least one Partial annotation for this test");
108        let result = c
109            .classify(partial.yaml_path(), &Value::String("non_default_value".into()))
110            .unwrap();
111        assert_eq!(result.support_level, SupportLevel::Partial);
112    }
113
114    #[test]
115    // To whoever implements this config in the future: sorry! We just wanted to make sure this is
116    // working correctly by giving it a currently unsupported key. You can delete the unsupported
117    // tests or choose a different key.
118    fn incompatible_non_default() {
119        let c = classifier();
120        let key = unsupported::TLS_HANDSHAKE_TIMEOUT.yaml_path();
121        let result = c.classify(key, &Value::Number(999.into())).unwrap();
122        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
123        assert!(!result.is_default);
124    }
125
126    #[test]
127    fn incompatible_default() {
128        let c = classifier();
129        let ann = &unsupported::TLS_HANDSHAKE_TIMEOUT;
130        let result = c.classify(ann.yaml_path(), &Value::String("".into())).unwrap();
131        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
132        assert!(result.is_default);
133    }
134
135    #[test]
136    fn not_in_registry_returns_none() {
137        let c = classifier();
138        assert!(c.classify("totally_made_up_key", &Value::Bool(true)).is_none());
139        assert!(c.classify("GUI_host", &Value::String("localhost".into())).is_none());
140    }
141
142    #[test]
143    fn alias_resolves_to_annotation() {
144        let c = classifier();
145        let result = c
146            .classify("dogstatsd_expiry_seconds", &Value::Number(999.into()))
147            .unwrap();
148        assert_eq!(result.support_level, SupportLevel::Full);
149        assert!(!result.is_default);
150    }
151
152    #[test]
153    fn alias_default_matches_canonical() {
154        let c = classifier();
155        let canonical = c.classify("counter_expiry_seconds", &Value::Null).unwrap();
156        let alias = c.classify("dogstatsd_expiry_seconds", &Value::Null).unwrap();
157        assert!(canonical.is_default);
158        assert!(alias.is_default);
159    }
160
161    #[test]
162    fn log_payloads_is_supported() {
163        let c = classifier();
164        let result = c.classify("log_payloads", &Value::Bool(true)).unwrap();
165        assert_eq!(result.support_level, SupportLevel::Full);
166        assert!(!result.is_default);
167    }
168
169    #[test]
170    fn none_default_null_is_default() {
171        let c = classifier();
172        let ann = ALL_ANNOTATIONS
173            .iter()
174            .find(|a| a.schema.default.is_none())
175            .expect("need an annotation with default: None");
176        let result = c.classify(ann.yaml_path(), &Value::Null).unwrap();
177        assert!(result.is_default);
178    }
179
180    #[test]
181    fn none_default_empty_string_is_default() {
182        let c = classifier();
183        let ann = ALL_ANNOTATIONS
184            .iter()
185            .find(|a| a.schema.default.is_none())
186            .expect("need an annotation with default: None");
187        let result = c.classify(ann.yaml_path(), &Value::String("".into())).unwrap();
188        assert!(result.is_default);
189    }
190
191    #[test]
192    fn none_default_non_empty_is_not_default() {
193        let c = classifier();
194        let ann = ALL_ANNOTATIONS
195            .iter()
196            .find(|a| a.schema.default.is_none())
197            .expect("need an annotation with default: None");
198        let result = c.classify(ann.yaml_path(), &Value::String("something".into())).unwrap();
199        assert!(!result.is_default);
200    }
201}