datadog_agent_config_testing/
smoke_test.rs

1use datadog_agent_config::DatadogEnvProvider;
2use saluki_config::{ConfigurationLoader, GenericConfiguration};
3use serde::Serialize;
4use serde_json::json;
5
6use crate::config_registry::{SalukiAnnotation, ValueType, SUPPORTED_ANNOTATIONS};
7
8/// Test value injected for `String` keys.
9pub const TEST_STRING_VALUE: &str = "http://smoke-proxy.example.com:3128";
10/// Test value injected for `Bool` keys.
11pub const TEST_BOOL_VALUE: bool = true;
12/// Test value injected for `StringList` keys.
13pub const TEST_STRING_LIST_VALUE: &[&str] = &["smoke-host-1.example.com", "smoke-host-2.example.com"];
14
15fn test_json_value(value_type: ValueType) -> serde_json::Value {
16    match value_type {
17        ValueType::String => json!(TEST_STRING_VALUE),
18        ValueType::Bool => json!(TEST_BOOL_VALUE),
19        ValueType::StringList => json!(TEST_STRING_LIST_VALUE),
20        ValueType::Integer => json!(42i64),
21        ValueType::Float => json!(1.5f64),
22        ValueType::Duration => json!("42s"),
23    }
24}
25
26fn effective_test_value(annotation: &SalukiAnnotation) -> serde_json::Value {
27    let v = test_json_value(annotation.value_type());
28    if let Some(default_raw) = annotation.schema.default {
29        if let Ok(default_val) = serde_json::from_str::<serde_json::Value>(default_raw) {
30            if v == default_val {
31                return match annotation.value_type() {
32                    ValueType::Bool => json!(!default_val.as_bool().unwrap_or(false)),
33                    _ => v,
34                };
35            }
36        }
37    }
38    v
39}
40
41fn json_value_to_env_string(value: &serde_json::Value, value_type: ValueType) -> String {
42    match value_type {
43        ValueType::Bool => value
44            .as_bool()
45            .map(|b| b.to_string())
46            .unwrap_or_else(|| "true".to_string()),
47        ValueType::Integer => value
48            .as_i64()
49            .map(|n| n.to_string())
50            .unwrap_or_else(|| "42".to_string()),
51        ValueType::Float => value
52            .as_f64()
53            .map(|f| f.to_string())
54            .unwrap_or_else(|| "1.5".to_string()),
55        ValueType::String => value.as_str().unwrap_or(TEST_STRING_VALUE).to_string(),
56        ValueType::StringList => value
57            .as_array()
58            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" "))
59            .unwrap_or_else(|| TEST_STRING_LIST_VALUE.join(" ")),
60        ValueType::Duration => value.as_str().unwrap_or("42s").to_string(),
61    }
62}
63
64fn collect_unchanged_leaves(
65    full: &serde_json::Value, default: &serde_json::Value, path: &str, unchanged: &mut Vec<String>,
66) {
67    match (full, default) {
68        (serde_json::Value::Object(f), serde_json::Value::Object(d)) => {
69            for (key, full_val) in f {
70                let child_path = if path.is_empty() {
71                    key.clone()
72                } else {
73                    format!("{}.{}", path, key)
74                };
75                let def_val = d.get(key).unwrap_or(&serde_json::Value::Null);
76                collect_unchanged_leaves(full_val, def_val, &child_path, unchanged);
77            }
78        }
79        (full_val, def_val) => {
80            if full_val == def_val {
81                unchanged.push(path.to_string());
82            }
83        }
84    }
85}
86
87fn yaml_path_to_json(yaml_path: &str, value: serde_json::Value) -> serde_json::Value {
88    let mut root = json!({});
89    saluki_config::upsert(&mut root, yaml_path, value);
90    root
91}
92
93fn merge_over_base(base: &serde_json::Value, overlay: serde_json::Value) -> serde_json::Value {
94    let mut merged = base.clone();
95    if let (Some(base_obj), Some(overlay_obj)) = (merged.as_object_mut(), overlay.as_object()) {
96        for (k, v) in overlay_obj {
97            base_obj.insert(k.clone(), v.clone());
98        }
99    }
100    merged
101}
102
103fn dd_env_var_to_test_key(env_var: &str) -> &str {
104    env_var.strip_prefix("DD_").unwrap_or(env_var)
105}
106
107async fn make_config_from_file(file_values: serde_json::Value) -> GenericConfiguration {
108    make_config(file_values, &[], Vec::new()).await
109}
110
111/// Builds a configuration from `base_file_values` plus one environment variable.
112///
113/// `env_var` is the variable's real name, as the Datadog Agent spells it. It is fed to
114/// [`DatadogEnvProvider`] under that name so the schema-driven reader can resolve it to the key's
115/// canonical path, exactly as it does in production. The same value is additionally handed to the
116/// test loader's prefix-scanning environment provider under the `DD_`-stripped name, which is the
117/// only spelling that provider can consume.
118async fn make_config_from_env(
119    base_file_values: &serde_json::Value, env_var: &str, value: &str,
120) -> GenericConfiguration {
121    let scanned = [(dd_env_var_to_test_key(env_var).to_string(), value.to_string())];
122    let modeled = vec![(env_var.to_string(), value.to_string())];
123    make_config(base_file_values.clone(), &scanned, modeled).await
124}
125
126async fn make_config(
127    file_values: serde_json::Value, scanned_env_vars: &[(String, String)], modeled_env_vars: Vec<(String, String)>,
128) -> GenericConfiguration {
129    let (cfg, _) =
130        ConfigurationLoader::for_tests_with_provider_factory(Some(file_values), Some(scanned_env_vars), false, |_| {
131            DatadogEnvProvider::from_env_vars(modeled_env_vars)
132                .expect("test environment values should decode into their declared shapes")
133        })
134        .await;
135    cfg
136}
137
138/// Runs smoke tests for all annotations registered to `struct_name` against a deserialized config struct `T`.
139///
140/// Verifies three properties:
141///
142/// **Supported keys**: loading the struct with the test value set via the annotation's `yaml_path`
143/// and via each of its effective env vars must all produce identical structs, and each must differ
144/// from the default (empty-config) struct.
145///
146/// **Unsupported keys**: loading the struct with that key set must produce a struct identical to the
147/// default struct.
148///
149/// **Full field coverage**: loading the struct with all supported keys set simultaneously must
150/// produce a struct where every serialized leaf field differs from the default.
151///
152/// Environment values are supplied per test case rather than read from the ambient process
153/// environment, so an unrelated variable set by the surrounding shell cannot influence a result.
154pub async fn run_config_smoke_tests<T, Factory>(
155    struct_name: &'static str, non_config_fields: &[&str], base_config: serde_json::Value, config_factory: Factory,
156) where
157    T: PartialEq + Serialize,
158    Factory: Fn(GenericConfiguration) -> T,
159{
160    let keys: Vec<&'static SalukiAnnotation> = SUPPORTED_ANNOTATIONS
161        .iter()
162        .copied()
163        .filter(|a| a.used_by.contains(&struct_name))
164        .collect();
165
166    let default_struct = config_factory(make_config_from_file(base_config.clone()).await);
167    let mut failures: Vec<String> = Vec::new();
168
169    for annotation in &keys {
170        let canonical_path = annotation.yaml_path();
171        let injected_value = match annotation.test_json {
172            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
173            None => effective_test_value(annotation),
174        };
175        let reference = config_factory(
176            make_config_from_file(merge_over_base(
177                &base_config,
178                yaml_path_to_json(canonical_path, injected_value.clone()),
179            ))
180            .await,
181        );
182
183        if reference == default_struct {
184            failures.push(format!(
185                "yaml_path '{}': struct did not change from its default—\
186                 is the test value the same as the default, or is the key not wired up?",
187                canonical_path,
188            ));
189            continue;
190        }
191
192        for yaml_path in annotation.additional_yaml_paths {
193            let from_path = config_factory(
194                make_config_from_file(merge_over_base(
195                    &base_config,
196                    yaml_path_to_json(yaml_path, injected_value.clone()),
197                ))
198                .await,
199            );
200            if from_path != reference {
201                failures.push(format!(
202                    "yaml_path '{}' produced a different struct than canonical yaml_path '{}'",
203                    yaml_path, canonical_path,
204                ));
205            }
206        }
207
208        for env_var in annotation.effective_env_vars() {
209            let value = json_value_to_env_string(&injected_value, annotation.value_type());
210            let from_env = config_factory(make_config_from_env(&base_config, env_var, &value).await);
211            if from_env != reference {
212                failures.push(format!(
213                    "env var '{}' produced a different struct than yaml_path '{}'",
214                    env_var, canonical_path,
215                ));
216            }
217        }
218    }
219
220    for annotation in SUPPORTED_ANNOTATIONS
221        .iter()
222        .filter(|a| !a.used_by.contains(&struct_name))
223    {
224        for yaml_path in annotation.all_yaml_paths() {
225            let with_foreign = config_factory(
226                make_config_from_file(merge_over_base(
227                    &base_config,
228                    yaml_path_to_json(yaml_path, test_json_value(annotation.value_type())),
229                ))
230                .await,
231            );
232            if with_foreign != default_struct {
233                failures.push(format!(
234                    "yaml_path '{}' is not registered for '{}' but unexpectedly changed the struct",
235                    yaml_path, struct_name,
236                ));
237            }
238        }
239    }
240
241    let mut all_vals = base_config.clone();
242    for annotation in keys {
243        let val = match annotation.test_json {
244            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
245            None => effective_test_value(annotation),
246        };
247        saluki_config::upsert(&mut all_vals, annotation.yaml_path(), val);
248    }
249    let all_keys_struct = config_factory(make_config_from_file(all_vals).await);
250    let full_map = serde_json::to_value(&all_keys_struct).expect("failed to serialize struct with all keys set");
251    let default_map = serde_json::to_value(&default_struct).expect("failed to serialize default struct");
252    let mut unchanged = Vec::new();
253    collect_unchanged_leaves(&full_map, &default_map, "", &mut unchanged);
254    unchanged.retain(|path| !non_config_fields.contains(&path.as_str()));
255    if !unchanged.is_empty() {
256        failures.push(format!(
257            "{} serialized field(s) are never changed by any registered config key: [{}]\n  \
258             Fix: add a SalukiAnnotation for each field and include '{}' in its used_by list.\n  \
259             Fix: if a field is intentionally not config-driven (for example, injected at runtime), \
260             add its serialized name to the `non_config_fields` slice in this test call.",
261            unchanged.len(),
262            unchanged.join(", "),
263            struct_name,
264        ));
265    }
266
267    if !failures.is_empty() {
268        panic!(
269            "config smoke tests for '{}' failed with {} error(s):\n\n{}",
270            struct_name,
271            failures.len(),
272            failures
273                .iter()
274                .enumerate()
275                .map(|(i, msg)| format!("  [{}] {}", i + 1, msg))
276                .collect::<Vec<_>>()
277                .join("\n\n"),
278        );
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    //! Meta-tests for the [`run_config_smoke_tests`] harness itself.
285    //!
286    //! [`run_config_smoke_tests`] documents three guarantees (supported keys, unsupported keys, full
287    //! field coverage). These tests verify the harness actually *enforces* each guarantee by feeding it a
288    //! `config_factory` that deliberately violates exactly one and asserting the harness reports that
289    //! guarantee's specific failure, plus one case where no guarantee is violated and the harness passes.
290    //!
291    //! We drive the negative cases against a struct name with no registered keys (so every registered key
292    //! is "foreign") or an ignore-everything factory, rather than a real config type. Faithfully
293    //! reproducing a *passing* struct here would require a real component config type, which lives in
294    //! `saluki-components` and isn't a dependency of this crate; the guarantee-1 passing path is therefore
295    //! left to the real per-component smoke tests that call this harness.
296
297    use saluki_config::GenericConfiguration;
298    use serde_json::json;
299
300    use super::run_config_smoke_tests;
301    use crate::config_registry::structs;
302
303    /// Struct name that no annotation's `used_by` references, so every registered key is "foreign" to it.
304    const UNREGISTERED_STRUCT: &str = "NonExistentConfiguration";
305
306    fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
307        payload
308            .downcast_ref::<String>()
309            .cloned()
310            .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
311            .unwrap_or_else(|| "<non-string panic payload>".to_string())
312    }
313
314    #[tokio::test]
315    async fn flags_a_supported_key_that_never_changes_the_struct() {
316        // `DOGSTATSD_CONFIGURATION` has registered (supported) keys. A factory that ignores the config
317        // entirely means each supported key "produces the default struct", violating the supported-key
318        // guarantee, which the harness must flag.
319        let outcome = tokio::spawn(async {
320            run_config_smoke_tests(
321                structs::DOGSTATSD_CONFIGURATION,
322                &[],
323                json!({}),
324                |_cfg: GenericConfiguration| json!({}),
325            )
326            .await
327        })
328        .await;
329
330        let panic = outcome.expect_err("harness should panic when a supported key never changes the struct");
331        let message = panic_message(panic.into_panic());
332        assert!(
333            message.contains("did not change from its default"),
334            "expected supported-key failure, got: {message}"
335        );
336    }
337
338    #[tokio::test]
339    async fn flags_a_foreign_key_that_changes_the_struct() {
340        // For a struct with no registered keys, every key is foreign and must leave the struct at its
341        // default. A factory that reflects the entire merged config changes for any foreign key, violating
342        // the unsupported-key guarantee.
343        let outcome = tokio::spawn(async {
344            run_config_smoke_tests(UNREGISTERED_STRUCT, &[], json!({}), |cfg: GenericConfiguration| {
345                cfg.as_typed::<serde_json::Value>().unwrap_or(serde_json::Value::Null)
346            })
347            .await
348        })
349        .await;
350
351        let panic = outcome.expect_err("harness should panic when a foreign key changes the struct");
352        let message = panic_message(panic.into_panic());
353        assert!(
354            message.contains("unexpectedly changed the struct"),
355            "expected unsupported-key failure, got: {message}"
356        );
357    }
358
359    #[tokio::test]
360    async fn flags_a_serialized_field_no_key_ever_changes() {
361        // A factory that always serializes a constant field means that field is never driven by any
362        // registered key, violating the full-field-coverage guarantee.
363        let outcome = tokio::spawn(async {
364            run_config_smoke_tests(
365                UNREGISTERED_STRUCT,
366                &[],
367                json!({}),
368                |_cfg: GenericConfiguration| json!({ "phantom": "constant" }),
369            )
370            .await
371        })
372        .await;
373
374        let panic = outcome.expect_err("harness should panic about a serialized field no key changes");
375        let message = panic_message(panic.into_panic());
376        assert!(
377            message.contains("never changed by any registered config key"),
378            "expected full-field-coverage failure, got: {message}"
379        );
380    }
381
382    #[tokio::test]
383    async fn passes_when_no_guarantee_is_violated() {
384        // A factory that ignores every (foreign) key and serializes no fields satisfies both the
385        // unsupported-key and full-field-coverage guarantees for a struct with no registered keys, so the
386        // harness returns without panicking.
387        run_config_smoke_tests(UNREGISTERED_STRUCT, &[], json!({}), |_cfg: GenericConfiguration| {
388            json!({})
389        })
390        .await;
391    }
392}