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    }
23}
24
25fn effective_test_value(annotation: &SalukiAnnotation) -> serde_json::Value {
26    let v = test_json_value(annotation.value_type());
27    if let Some(default_raw) = annotation.schema.default {
28        if let Ok(default_val) = serde_json::from_str::<serde_json::Value>(default_raw) {
29            if v == default_val {
30                return match annotation.value_type() {
31                    ValueType::Bool => json!(!default_val.as_bool().unwrap_or(false)),
32                    _ => v,
33                };
34            }
35        }
36    }
37    v
38}
39
40fn json_value_to_env_string(value: &serde_json::Value, value_type: ValueType) -> String {
41    match value_type {
42        ValueType::Bool => value
43            .as_bool()
44            .map(|b| b.to_string())
45            .unwrap_or_else(|| "true".to_string()),
46        ValueType::Integer => value
47            .as_i64()
48            .map(|n| n.to_string())
49            .unwrap_or_else(|| "42".to_string()),
50        ValueType::Float => value
51            .as_f64()
52            .map(|f| f.to_string())
53            .unwrap_or_else(|| "1.5".to_string()),
54        ValueType::String => value.as_str().unwrap_or(TEST_STRING_VALUE).to_string(),
55        ValueType::StringList => value
56            .as_array()
57            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" "))
58            .unwrap_or_else(|| TEST_STRING_LIST_VALUE.join(" ")),
59    }
60}
61
62fn collect_unchanged_leaves(
63    full: &serde_json::Value, default: &serde_json::Value, path: &str, unchanged: &mut Vec<String>,
64) {
65    match (full, default) {
66        (serde_json::Value::Object(f), serde_json::Value::Object(d)) => {
67            for (key, full_val) in f {
68                let child_path = if path.is_empty() {
69                    key.clone()
70                } else {
71                    format!("{}.{}", path, key)
72                };
73                let def_val = d.get(key).unwrap_or(&serde_json::Value::Null);
74                collect_unchanged_leaves(full_val, def_val, &child_path, unchanged);
75            }
76        }
77        (full_val, def_val) => {
78            if full_val == def_val {
79                unchanged.push(path.to_string());
80            }
81        }
82    }
83}
84
85fn yaml_path_to_json(yaml_path: &str, value: serde_json::Value) -> serde_json::Value {
86    let mut root = json!({});
87    saluki_config::upsert(&mut root, yaml_path, value);
88    root
89}
90
91fn merge_over_base(base: &serde_json::Value, overlay: serde_json::Value) -> serde_json::Value {
92    let mut merged = base.clone();
93    if let (Some(base_obj), Some(overlay_obj)) = (merged.as_object_mut(), overlay.as_object()) {
94        for (k, v) in overlay_obj {
95            base_obj.insert(k.clone(), v.clone());
96        }
97    }
98    merged
99}
100
101fn dd_env_var_to_test_key(env_var: &str) -> &str {
102    env_var.strip_prefix("DD_").unwrap_or(env_var)
103}
104
105async fn make_config_from_file<P, F>(
106    file_values: serde_json::Value, key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
107) -> GenericConfiguration
108where
109    P: Provider + Send + Sync + 'static,
110    F: FnOnce() -> P,
111{
112    let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
113        Some(file_values),
114        None,
115        false,
116        key_aliases,
117        provider_factory,
118    )
119    .await;
120    cfg
121}
122
123async fn make_config_from_env<P, F>(
124    base_file_values: &serde_json::Value, env_vars: &[(String, String)],
125    key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
126) -> GenericConfiguration
127where
128    P: Provider + Send + Sync + 'static,
129    F: FnOnce() -> P,
130{
131    let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
132        Some(base_file_values.clone()),
133        Some(env_vars),
134        false,
135        key_aliases,
136        provider_factory,
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/// `key_aliases` and `provider_factory` configure the test config loader. Pass the same aliases and
157/// remapper factory used in production config loading.
158pub async fn run_config_smoke_tests<T, Factory, P, PF>(
159    struct_name: &'static str, non_config_fields: &[&str], base_config: serde_json::Value, config_factory: Factory,
160    key_aliases: &'static [(&'static str, &'static str)], provider_factory: PF,
161) where
162    T: PartialEq + Serialize,
163    Factory: Fn(GenericConfiguration) -> T,
164    P: Provider + Send + Sync + 'static,
165    PF: Fn() -> P,
166{
167    let keys: Vec<&'static SalukiAnnotation> = SUPPORTED_ANNOTATIONS
168        .iter()
169        .copied()
170        .filter(|a| a.used_by.contains(&struct_name))
171        .collect();
172
173    let default_struct =
174        config_factory(make_config_from_file(base_config.clone(), key_aliases, &provider_factory).await);
175    let mut failures: Vec<String> = Vec::new();
176
177    for annotation in &keys {
178        let canonical_path = annotation.yaml_path();
179        let injected_value = match annotation.test_json {
180            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
181            None => effective_test_value(annotation),
182        };
183        let reference = config_factory(
184            make_config_from_file(
185                merge_over_base(&base_config, yaml_path_to_json(canonical_path, injected_value.clone())),
186                key_aliases,
187                &provider_factory,
188            )
189            .await,
190        );
191
192        if reference == default_struct {
193            failures.push(format!(
194                "yaml_path '{}': struct did not change from its default—\
195                 is the test value the same as the default, or is the key not wired up?",
196                canonical_path,
197            ));
198            continue;
199        }
200
201        for yaml_path in annotation.additional_yaml_paths {
202            let from_path = config_factory(
203                make_config_from_file(
204                    merge_over_base(&base_config, yaml_path_to_json(yaml_path, injected_value.clone())),
205                    key_aliases,
206                    &provider_factory,
207                )
208                .await,
209            );
210            if from_path != reference {
211                failures.push(format!(
212                    "yaml_path '{}' produced a different struct than canonical yaml_path '{}'",
213                    yaml_path, canonical_path,
214                ));
215            }
216        }
217
218        for env_var in annotation.effective_env_vars() {
219            let env_pairs = [(
220                dd_env_var_to_test_key(env_var).to_string(),
221                json_value_to_env_string(&injected_value, annotation.value_type()),
222            )];
223            let from_env =
224                config_factory(make_config_from_env(&base_config, &env_pairs, key_aliases, &provider_factory).await);
225            if from_env != reference {
226                failures.push(format!(
227                    "env var '{}' produced a different struct than yaml_path '{}'",
228                    env_var, canonical_path,
229                ));
230            }
231        }
232    }
233
234    for annotation in SUPPORTED_ANNOTATIONS
235        .iter()
236        .filter(|a| !a.used_by.contains(&struct_name))
237    {
238        for yaml_path in annotation.all_yaml_paths() {
239            let with_foreign = config_factory(
240                make_config_from_file(
241                    merge_over_base(
242                        &base_config,
243                        yaml_path_to_json(yaml_path, test_json_value(annotation.value_type())),
244                    ),
245                    key_aliases,
246                    &provider_factory,
247                )
248                .await,
249            );
250            if with_foreign != default_struct {
251                failures.push(format!(
252                    "yaml_path '{}' is not registered for '{}' but unexpectedly changed the struct",
253                    yaml_path, struct_name,
254                ));
255            }
256        }
257    }
258
259    let mut all_vals = base_config.clone();
260    for annotation in keys {
261        let val = match annotation.test_json {
262            Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
263            None => effective_test_value(annotation),
264        };
265        saluki_config::upsert(&mut all_vals, annotation.yaml_path(), val);
266    }
267    let all_keys_struct = config_factory(make_config_from_file(all_vals, key_aliases, &provider_factory).await);
268    let full_map = serde_json::to_value(&all_keys_struct).expect("failed to serialize struct with all keys set");
269    let default_map = serde_json::to_value(&default_struct).expect("failed to serialize default struct");
270    let mut unchanged = Vec::new();
271    collect_unchanged_leaves(&full_map, &default_map, "", &mut unchanged);
272    unchanged.retain(|path| !non_config_fields.contains(&path.as_str()));
273    if !unchanged.is_empty() {
274        failures.push(format!(
275            "{} serialized field(s) are never changed by any registered config key: [{}]\n  \
276             Fix: add a SalukiAnnotation for each field and include '{}' in its used_by list.\n  \
277             Fix: if a field is intentionally not config-driven (for example, injected at runtime), \
278             add its serialized name to the `non_config_fields` slice in this test call.",
279            unchanged.len(),
280            unchanged.join(", "),
281            struct_name,
282        ));
283    }
284
285    if !failures.is_empty() {
286        panic!(
287            "config smoke tests for '{}' failed with {} error(s):\n\n{}",
288            struct_name,
289            failures.len(),
290            failures
291                .iter()
292                .enumerate()
293                .map(|(i, msg)| format!("  [{}] {}", i + 1, msg))
294                .collect::<Vec<_>>()
295                .join("\n\n"),
296        );
297    }
298}