Skip to main content

datadog_agent_config/classifier/
classifier.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use super::{ClassifierEntry, DefaultValue, PipelineAffinity, SupportLevel, CLASSIFIER_ENTRIES};
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 (supported + unsupported). Keys not in the registry - whether
20/// ignored, unrecognized, or anything else - return `None` from
21/// [`classify`](Self::classify).
22pub struct ConfigClassifier {
23    lookup: HashMap<&'static str, &'static ClassifierEntry>,
24}
25
26impl ConfigClassifier {
27    /// Builds a classifier from all annotated config keys.
28    pub fn new() -> Self {
29        let mut lookup = HashMap::new();
30
31        for entry in CLASSIFIER_ENTRIES {
32            lookup.insert(entry.yaml_path, entry);
33            for alias in entry.aliases {
34                lookup.insert(alias, entry);
35            }
36        }
37
38        Self { lookup }
39    }
40
41    /// Classifies a single config key/value pair against the registry.
42    ///
43    /// Returns `None` for keys not in the registry (ignored, unrecognized, etc.).
44    pub fn classify(&self, key: &str, value: &Value) -> Option<Classification> {
45        let entry = self.lookup.get(key)?;
46        Some(Classification {
47            support_level: entry.support_level,
48            is_default: is_default_value(entry.default, value),
49            pipeline_affinity: entry.pipeline_affinity,
50        })
51    }
52}
53
54fn is_default_value(default: DefaultValue, value: &Value) -> bool {
55    match default {
56        DefaultValue::Json(default_str) => serde_json::from_str::<Value>(default_str)
57            .map(|default_value| *value == default_value)
58            .unwrap_or(false),
59
60        // Duration defaults are canonicalized to nanoseconds at build time. The Agent transmits
61        // durations as integer nanoseconds (and occasionally as a Go duration string), so normalize
62        // the incoming value the same way before comparing.
63        DefaultValue::DurationNanos(default_ns) => duration_value_as_nanos(value)
64            .map(|value_ns| value_ns == default_ns)
65            .unwrap_or(false),
66
67        DefaultValue::Missing => match value {
68            Value::Null => true,
69            Value::String(s) => s.is_empty(),
70            _ => false,
71        },
72    }
73}
74
75/// Normalizes a duration-typed config value to nanoseconds.
76///
77/// Accepts a JSON number already expressed in nanoseconds (the form the Datadog Agent sends over
78/// the config stream) or a Go duration string (for example, `"10s"`). Returns `None` for any other
79/// shape or an out-of-range/invalid value.
80fn duration_value_as_nanos(value: &Value) -> Option<u64> {
81    match value {
82        Value::Number(n) => n.as_u64(),
83        Value::String(s) => go_duration::parse_duration(s).ok().map(|d| d.as_nanos() as u64),
84        _ => None,
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::classifier::Severity;
92
93    fn classifier() -> ConfigClassifier {
94        ConfigClassifier::new()
95    }
96
97    #[test]
98    fn full_support_keys_not_in_classifier() {
99        // Full-support keys are not actionable at the call site; the classifier omits them.
100        // The call site treats None identically to Full (silently continues).
101        let c = classifier();
102        assert!(c.classify("dogstatsd_port", &Value::Number(9999.into())).is_none());
103        assert!(c.classify("log_payloads", &Value::Bool(true)).is_none());
104    }
105
106    #[test]
107    fn partial_non_default() {
108        let c = classifier();
109        let result = c
110            .classify("min_tls_version", &Value::String("non_default_value".into()))
111            .unwrap();
112        assert_eq!(result.support_level, SupportLevel::Partial);
113    }
114
115    #[test]
116    fn incompatible_non_default() {
117        let c = classifier();
118        let result = c.classify("tls_handshake_timeout", &Value::Number(999.into())).unwrap();
119        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
120        assert!(!result.is_default);
121    }
122
123    #[test]
124    fn incompatible_default() {
125        let c = classifier();
126        // config_id has schema default "" (empty string)
127        let result = c.classify("config_id", &Value::String("".into())).unwrap();
128        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
129        assert!(result.is_default);
130    }
131
132    #[test]
133    fn not_in_registry_returns_none() {
134        let c = classifier();
135        assert!(c.classify("totally_made_up_key", &Value::Bool(true)).is_none());
136        assert!(c.classify("GUI_host", &Value::String("localhost".into())).is_none());
137    }
138
139    #[test]
140    fn duration_default_null_is_not_default() {
141        let c = classifier();
142        // tls_handshake_timeout has a duration default (10s); a null value can't be normalized.
143        let result = c.classify("tls_handshake_timeout", &Value::Null).unwrap();
144        assert!(!result.is_default);
145    }
146
147    #[test]
148    fn duration_default_non_duration_string_is_not_default() {
149        let c = classifier();
150        // Neither an empty string nor arbitrary text parses as a duration, so neither matches.
151        assert!(
152            !c.classify("tls_handshake_timeout", &Value::String("".into()))
153                .unwrap()
154                .is_default
155        );
156        assert!(
157            !c.classify("tls_handshake_timeout", &Value::String("something".into()))
158                .unwrap()
159                .is_default
160        );
161    }
162
163    #[test]
164    fn duration_default_matches_go_duration_string() {
165        let c = classifier();
166        // The default is also matched when supplied as a Go duration string rather than nanoseconds.
167        let result = c
168            .classify("tls_handshake_timeout", &Value::String("10s".into()))
169            .unwrap();
170        assert!(result.is_default);
171    }
172
173    #[test]
174    fn incompatible_severity_levels() {
175        let c = classifier();
176        let result = c.classify("tls_handshake_timeout", &Value::Number(30.into())).unwrap();
177        assert!(matches!(
178            result.support_level,
179            SupportLevel::Incompatible(Severity::Medium)
180        ));
181    }
182
183    #[test]
184    fn duration_default_as_nanoseconds_is_default() {
185        let c = classifier();
186        // The Agent transmits tls_handshake_timeout (schema default "10s") as integer nanoseconds.
187        // The classifier must recognize this as the default and not flag it as an override.
188        let result = c
189            .classify("tls_handshake_timeout", &Value::Number(10_000_000_000i64.into()))
190            .unwrap();
191        assert!(result.is_default);
192    }
193
194    #[test]
195    fn duration_non_default_nanoseconds_is_not_default() {
196        let c = classifier();
197        // 5s in nanoseconds is not the 10s default.
198        let result = c
199            .classify("tls_handshake_timeout", &Value::Number(5_000_000_000i64.into()))
200            .unwrap();
201        assert!(!result.is_default);
202    }
203
204    #[test]
205    fn is_default_value_by_variant() {
206        // Json: structural equality against the decoded JSON literal.
207        assert!(is_default_value(
208            DefaultValue::Json("\"tlsv1.2\""),
209            &Value::String("tlsv1.2".into())
210        ));
211        assert!(!is_default_value(
212            DefaultValue::Json("\"tlsv1.2\""),
213            &Value::String("tlsv1.3".into())
214        ));
215        assert!(is_default_value(DefaultValue::Json("1"), &Value::Number(1.into())));
216
217        // DurationNanos: matches both the nanosecond number and the equivalent Go duration string.
218        assert!(is_default_value(
219            DefaultValue::DurationNanos(10_000_000_000),
220            &Value::Number(10_000_000_000u64.into())
221        ));
222        assert!(is_default_value(
223            DefaultValue::DurationNanos(10_000_000_000),
224            &Value::String("10s".into())
225        ));
226        assert!(!is_default_value(
227            DefaultValue::DurationNanos(10_000_000_000),
228            &Value::Number(5_000_000_000u64.into())
229        ));
230        assert!(!is_default_value(
231            DefaultValue::DurationNanos(10_000_000_000),
232            &Value::String("nope".into())
233        ));
234
235        // Missing: only null or an empty string counts as "default".
236        assert!(is_default_value(DefaultValue::Missing, &Value::Null));
237        assert!(is_default_value(DefaultValue::Missing, &Value::String("".into())));
238        assert!(!is_default_value(DefaultValue::Missing, &Value::String("x".into())));
239    }
240}