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::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
111async 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
138pub 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 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 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 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 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 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 run_config_smoke_tests(UNREGISTERED_STRUCT, &[], json!({}), |_cfg: GenericConfiguration| {
388 json!({})
389 })
390 .await;
391 }
392}