Skip to main content

datadog_agent_config_testing/
smoke_test.rs

1use figment::Provider;
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<P, F>(
108    file_values: serde_json::Value, key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
109) -> GenericConfiguration
110where
111    P: Provider + Send + Sync + 'static,
112    F: FnOnce() -> P,
113{
114    let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
115        Some(file_values),
116        None,
117        false,
118        key_aliases,
119        provider_factory,
120    )
121    .await;
122    cfg
123}
124
125async fn make_config_from_env<P, F>(
126    base_file_values: &serde_json::Value, env_vars: &[(String, String)],
127    key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
128) -> GenericConfiguration
129where
130    P: Provider + Send + Sync + 'static,
131    F: FnOnce() -> P,
132{
133    let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
134        Some(base_file_values.clone()),
135        Some(env_vars),
136        false,
137        key_aliases,
138        provider_factory,
139    )
140    .await;
141    cfg
142}
143
144/// Runs smoke tests for all annotations registered to `struct_name` against a deserialized config struct `T`.
145///
146/// Verifies three properties:
147///
148/// **Supported keys**: loading the struct with the test value set via the annotation's `yaml_path`
149/// and via each of its effective env vars must all produce identical structs, and each must differ
150/// from the default (empty-config) struct.
151///
152/// **Unsupported keys**: loading the struct with that key set must produce a struct identical to the
153/// default struct.
154///
155/// **Full field coverage**: loading the struct with all supported keys set simultaneously must
156/// produce a struct where every serialized leaf field differs from the default.
157///
158/// `key_aliases` and `provider_factory` configure the test config loader. Pass the same aliases and
159/// remapper factory used in production config loading.
160pub async fn run_config_smoke_tests<T, Factory, P, PF>(
161    struct_name: &'static str, non_config_fields: &[&str], base_config: serde_json::Value, config_factory: Factory,
162    key_aliases: &'static [(&'static str, &'static str)], provider_factory: PF,
163) where
164    T: PartialEq + Serialize,
165    Factory: Fn(GenericConfiguration) -> T,
166    P: Provider + Send + Sync + 'static,
167    PF: Fn() -> P,
168{
169    let keys: Vec<&'static SalukiAnnotation> = SUPPORTED_ANNOTATIONS
170        .iter()
171        .copied()
172        .filter(|a| a.used_by.contains(&struct_name))
173        .collect();
174
175    let default_struct =
176        config_factory(make_config_from_file(base_config.clone(), key_aliases, &provider_factory).await);
177    let mut failures: Vec<String> = Vec::new();
178
179    for annotation in &keys {
180        let canonical_path = annotation.yaml_path();
181        let injected_value = match annotation.test_json {
182            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
183            None => effective_test_value(annotation),
184        };
185        let reference = config_factory(
186            make_config_from_file(
187                merge_over_base(&base_config, yaml_path_to_json(canonical_path, injected_value.clone())),
188                key_aliases,
189                &provider_factory,
190            )
191            .await,
192        );
193
194        if reference == default_struct {
195            failures.push(format!(
196                "yaml_path '{}': struct did not change from its default—\
197                 is the test value the same as the default, or is the key not wired up?",
198                canonical_path,
199            ));
200            continue;
201        }
202
203        for yaml_path in annotation.additional_yaml_paths {
204            let from_path = config_factory(
205                make_config_from_file(
206                    merge_over_base(&base_config, yaml_path_to_json(yaml_path, injected_value.clone())),
207                    key_aliases,
208                    &provider_factory,
209                )
210                .await,
211            );
212            if from_path != reference {
213                failures.push(format!(
214                    "yaml_path '{}' produced a different struct than canonical yaml_path '{}'",
215                    yaml_path, canonical_path,
216                ));
217            }
218        }
219
220        for env_var in annotation.effective_env_vars() {
221            let env_pairs = [(
222                dd_env_var_to_test_key(env_var).to_string(),
223                json_value_to_env_string(&injected_value, annotation.value_type()),
224            )];
225            let from_env =
226                config_factory(make_config_from_env(&base_config, &env_pairs, key_aliases, &provider_factory).await);
227            if from_env != reference {
228                failures.push(format!(
229                    "env var '{}' produced a different struct than yaml_path '{}'",
230                    env_var, canonical_path,
231                ));
232            }
233        }
234    }
235
236    for annotation in SUPPORTED_ANNOTATIONS
237        .iter()
238        .filter(|a| !a.used_by.contains(&struct_name))
239    {
240        for yaml_path in annotation.all_yaml_paths() {
241            let with_foreign = config_factory(
242                make_config_from_file(
243                    merge_over_base(
244                        &base_config,
245                        yaml_path_to_json(yaml_path, test_json_value(annotation.value_type())),
246                    ),
247                    key_aliases,
248                    &provider_factory,
249                )
250                .await,
251            );
252            if with_foreign != default_struct {
253                failures.push(format!(
254                    "yaml_path '{}' is not registered for '{}' but unexpectedly changed the struct",
255                    yaml_path, struct_name,
256                ));
257            }
258        }
259    }
260
261    let mut all_vals = base_config.clone();
262    for annotation in keys {
263        let val = match annotation.test_json {
264            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
265            None => effective_test_value(annotation),
266        };
267        saluki_config::upsert(&mut all_vals, annotation.yaml_path(), val);
268    }
269    let all_keys_struct = config_factory(make_config_from_file(all_vals, key_aliases, &provider_factory).await);
270    let full_map = serde_json::to_value(&all_keys_struct).expect("failed to serialize struct with all keys set");
271    let default_map = serde_json::to_value(&default_struct).expect("failed to serialize default struct");
272    let mut unchanged = Vec::new();
273    collect_unchanged_leaves(&full_map, &default_map, "", &mut unchanged);
274    unchanged.retain(|path| !non_config_fields.contains(&path.as_str()));
275    if !unchanged.is_empty() {
276        failures.push(format!(
277            "{} serialized field(s) are never changed by any registered config key: [{}]\n  \
278             Fix: add a SalukiAnnotation for each field and include '{}' in its used_by list.\n  \
279             Fix: if a field is intentionally not config-driven (for example, injected at runtime), \
280             add its serialized name to the `non_config_fields` slice in this test call.",
281            unchanged.len(),
282            unchanged.join(", "),
283            struct_name,
284        ));
285    }
286
287    if !failures.is_empty() {
288        panic!(
289            "config smoke tests for '{}' failed with {} error(s):\n\n{}",
290            struct_name,
291            failures.len(),
292            failures
293                .iter()
294                .enumerate()
295                .map(|(i, msg)| format!("  [{}] {}", i + 1, msg))
296                .collect::<Vec<_>>()
297                .join("\n\n"),
298        );
299    }
300}