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 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
144pub 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}