datadog_agent_config_testing/
smoke_test.rs1use figment::Provider;
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 }
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
142pub 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}