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