datadog_agent_config_testing/
smoke_test.rs1use 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
8pub const TEST_STRING_VALUE: &str = "http://smoke-proxy.example.com:3128";
10pub const TEST_BOOL_VALUE: bool = true;
12pub 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
115async 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
142pub 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 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 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 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 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 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 run_config_smoke_tests(UNREGISTERED_STRUCT, &[], json!({}), |_cfg: GenericConfiguration| {
393 json!({})
394 })
395 .await;
396 }
397}