datadog_agent_config/env_overlay.rs
1//! Overlay flat environment-variable keys onto the nested Datadog configuration shape.
2//!
3//! Environment variables reach the config map as flat, underscore-joined top-level keys:
4//! `DD_AUTOSCALING_FAILOVER_ENABLED` lands as `autoscaling_failover_enabled`, not as the nested
5//! `autoscaling.failover.enabled` object. [`DatadogConfiguration`](crate::DatadogConfiguration)
6//! deserializes the nested shape, so it never reads those flat keys and the value is silently lost.
7//!
8//! The by-key configuration view can resolve a dotted path from its underscore-joined form. The
9//! typed path deserializes the whole structure at once, so it needs the same relocation performed
10//! before deserialization. This module applies that one-time pass over the merged value, driven by
11//! the generated table of supported multi-segment keys
12//! ([`ENV_OVERLAY_KEYS`]): for each known dotted path, it relocates any matching flat key into the
13//! nested slot the deserializer reads.
14//!
15//! The relocation runs the safe, unambiguous direction (known path to its single flat form), so it
16//! never has to guess where the underscores in an arbitrary flat key are nesting boundaries.
17//!
18//! This pass only *relocates*; it never reshapes the value. A flat env value is moved verbatim, even
19//! for string lists: `DatadogConfiguration`'s string-list leaves deserialize with a shape-tolerant
20//! reader (see the generated crate's `list_de`) that splits a space-separated env string into a
21//! sequence. Keeping shape at the deserializer means this table needs to know only the path, not the
22//! type.
23
24use serde_json::{Map, Value};
25
26use crate::generated::env_overlay_keys::ENV_OVERLAY_KEYS;
27
28/// How a flat environment value relates to the value already in the nested slot.
29///
30/// Chosen at the point of deserialization, once the merged map is in hand.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum EnvOverlayMode {
33 /// Do not relocate anything. The typed deserializer sees only the nested shape.
34 Disabled,
35 /// Fill a nested slot only when it is absent, so a value already supplied by another source
36 /// wins over the flat environment key.
37 Fallback,
38 /// Relocate the flat env value even when the nested slot is already populated, so an env var
39 /// overrides an Agent-supplied value.
40 Override,
41}
42
43/// One supported Datadog key: the flat env forms that reach it and its nested path.
44#[derive(Clone, Copy, Debug)]
45pub struct EnvOverlayKey {
46 /// The flat, underscore-joined keys an environment variable produces for this field
47 /// (`autoscaling_failover_enabled`). A field may be reachable through several env vars; they are
48 /// listed here in schema priority order, **highest priority first**. When more than one is
49 /// present in the merged map, [`apply_env_overlay`] takes the value from the first that appears,
50 /// matching the Agent (for example `dd_url` prefers `DD_DD_URL` over `DD_URL`).
51 pub flats: &'static [&'static str],
52 /// The nested path the typed deserializer reads (`["autoscaling", "failover", "enabled"]`).
53 pub path: &'static [&'static str],
54}
55
56/// Relocates flat environment-variable keys in `merged` into the nested slots the Datadog
57/// deserializer reads, according to `mode`.
58///
59/// Operates on the caller's owned `merged` value; it never touches the shared source map. Apply it
60/// only to the value fed to [`DatadogConfiguration`](crate::DatadogConfiguration): the table covers
61/// only supported Datadog keys.
62pub fn apply_env_overlay(merged: &mut Value, mode: EnvOverlayMode) {
63 let clobber = match mode {
64 EnvOverlayMode::Disabled => return,
65 EnvOverlayMode::Override => true,
66 EnvOverlayMode::Fallback => false,
67 };
68
69 let Some(root) = merged.as_object_mut() else {
70 return;
71 };
72
73 for key in ENV_OVERLAY_KEYS {
74 // Pick the source from the highest-priority present flat; `clobber` then only decides whether
75 // it replaces a value already sitting in the nested slot.
76 let Some(flat) = select_flat(root, key.flats) else {
77 continue;
78 };
79 let value = flat.clone();
80
81 if !clobber && path_present(root, key.path) {
82 continue;
83 }
84
85 set_at_path(root, key.path, value);
86 }
87}
88
89/// Chooses the source value for a key from its flat env forms, honoring priority.
90///
91/// `flats` is ordered highest priority first, so this returns the value of the first form present in
92/// `root`. That makes the winning env var independent of the overlay mode: when several env vars for
93/// one key are set, the highest-priority one always supplies the value (for example `dd_url` prefers
94/// `DD_DD_URL` over `DD_URL`).
95fn select_flat<'a>(root: &'a Map<String, Value>, flats: &[&str]) -> Option<&'a Value> {
96 flats.iter().find_map(|flat| root.get(*flat))
97}
98
99/// Returns whether a leaf already exists at `path`, walking object nodes from `root`.
100fn path_present(root: &Map<String, Value>, path: &[&str]) -> bool {
101 let Some((first, rest)) = path.split_first() else {
102 return false;
103 };
104 let mut current = match root.get(*first) {
105 Some(v) => v,
106 None => return false,
107 };
108 for segment in rest {
109 match current.get(*segment) {
110 Some(v) => current = v,
111 None => return false,
112 }
113 }
114 true
115}
116
117/// Sets `value` at `path`, creating intermediate object nodes as needed and clobbering the leaf.
118///
119/// An intermediate segment that exists but is not an object is replaced with one; this cannot lose
120/// a real value, since a supported key's parents are always sections (objects).
121fn set_at_path(root: &mut Map<String, Value>, path: &[&str], value: Value) {
122 let Some((leaf, sections)) = path.split_last() else {
123 return;
124 };
125
126 let mut current = root;
127 for segment in sections {
128 let node = current
129 .entry((*segment).to_string())
130 .or_insert_with(|| Value::Object(Map::new()));
131 if !node.is_object() {
132 *node = Value::Object(Map::new());
133 }
134 current = node.as_object_mut().expect("node was just ensured to be an object");
135 }
136
137 current.insert((*leaf).to_string(), value);
138}
139
140#[cfg(test)]
141mod tests {
142 use serde_json::json;
143
144 use super::*;
145
146 // Real supported keys the generated table carries: a nested boolean and a nested string list.
147 const ENABLED_FLAT: &str = "autoscaling_failover_enabled";
148 const METRICS_FLAT: &str = "autoscaling_failover_metrics";
149
150 fn enabled(v: &Value) -> Option<&Value> {
151 v.get("autoscaling")?.get("failover")?.get("enabled")
152 }
153
154 #[test]
155 fn disabled_relocates_nothing() {
156 let mut v = json!({ ENABLED_FLAT: true });
157 apply_env_overlay(&mut v, EnvOverlayMode::Disabled);
158 assert!(enabled(&v).is_none());
159 }
160
161 #[test]
162 fn fallback_fills_absent_nested_slot() {
163 let mut v = json!({ ENABLED_FLAT: true });
164 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
165 assert_eq!(enabled(&v), Some(&Value::Bool(true)));
166 }
167
168 #[test]
169 fn fallback_keeps_present_nested_value() {
170 let mut v = json!({ ENABLED_FLAT: true, "autoscaling": { "failover": { "enabled": false } } });
171 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
172 assert_eq!(enabled(&v), Some(&Value::Bool(false)));
173 }
174
175 #[test]
176 fn override_clobbers_present_nested_value() {
177 let mut v = json!({ ENABLED_FLAT: true, "autoscaling": { "failover": { "enabled": false } } });
178 apply_env_overlay(&mut v, EnvOverlayMode::Override);
179 assert_eq!(enabled(&v), Some(&Value::Bool(true)));
180 }
181
182 #[test]
183 fn string_list_env_value_is_relocated_verbatim() {
184 // The overlay only relocates; it does not reshape. The space-separated env string lands at
185 // the nested path unchanged — splitting into a sequence is the deserializer's job (see the
186 // string-list leaf's shape-tolerant reader, tested in `list_de`).
187 let mut v = json!({ METRICS_FLAT: "container.memory.usage container.cpu.usage" });
188 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
189 let metrics = v
190 .get("autoscaling")
191 .unwrap()
192 .get("failover")
193 .unwrap()
194 .get("metrics")
195 .unwrap();
196 assert_eq!(metrics, &json!("container.memory.usage container.cpu.usage"));
197 }
198
199 #[test]
200 fn set_at_path_creates_intermediate_objects() {
201 let mut root = Map::new();
202 set_at_path(&mut root, &["a", "b", "c"], Value::Bool(true));
203 assert_eq!(Value::Object(root), json!({ "a": { "b": { "c": true } } }));
204 }
205
206 // The following tests guard the fix for the `apm_config.*` keys whose environment variables
207 // diverge from the mechanical `a.b.c` -> `a_b_c` form. Their env vars drop the `_config`
208 // segment (`DD_APM_ERROR_TRACKING_STANDALONE_ENABLED` -> `apm_error_tracking_standalone_enabled`),
209 // and two are renamed outright (`errors_per_second` -> `DD_APM_ERROR_TPS`). The generated table
210 // must carry the schema's real env names, not the dotted path, or these fields stay unreachable.
211
212 fn overlay_key(flat: &str) -> Option<&'static EnvOverlayKey> {
213 ENV_OVERLAY_KEYS.iter().find(|k| k.flats.contains(&flat))
214 }
215
216 #[test]
217 fn table_uses_schema_env_names_for_divergent_apm_keys() {
218 // The real (schema-declared) env flat forms are present, mapped to their nested path.
219 let cases = [
220 (
221 "apm_error_tracking_standalone_enabled",
222 &["apm_config", "error_tracking_standalone", "enabled"][..],
223 ),
224 ("apm_enable_rare_sampler", &["apm_config", "enable_rare_sampler"][..]),
225 ("apm_error_tps", &["apm_config", "errors_per_second"][..]),
226 ("apm_target_tps", &["apm_config", "target_traces_per_second"][..]),
227 (
228 "apm_probabilistic_sampler_sampling_percentage",
229 &["apm_config", "probabilistic_sampler", "sampling_percentage"][..],
230 ),
231 ];
232 for (flat, path) in cases {
233 let key = overlay_key(flat).unwrap_or_else(|| panic!("missing overlay key {flat:?}"));
234 assert_eq!(key.path, path, "wrong nested path for {flat:?}");
235 }
236 }
237
238 #[test]
239 fn table_omits_mechanical_forms_for_overridden_apm_keys() {
240 // The old, wrong dot-to-underscore forms must not appear: the Agent does not accept them
241 // once explicit env names are declared, so relocating them would over-accept.
242 for wrong in [
243 "apm_config_error_tracking_standalone_enabled",
244 "apm_config_enable_rare_sampler",
245 "apm_config_errors_per_second",
246 ] {
247 assert!(
248 overlay_key(wrong).is_none(),
249 "unexpected mechanical overlay key {wrong:?}"
250 );
251 }
252 }
253
254 #[test]
255 fn divergent_env_key_reaches_nested_slot() {
256 // Reachability: the env-only flat key lands in the nested slot the typed model reads.
257 let mut v = json!({ "apm_error_tracking_standalone_enabled": true });
258 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
259 assert_eq!(
260 v.get("apm_config")
261 .and_then(|c| c.get("error_tracking_standalone"))
262 .and_then(|e| e.get("enabled")),
263 Some(&Value::Bool(true)),
264 );
265 }
266
267 #[test]
268 fn divergent_env_key_fallback_yields_to_agent_value() {
269 // Precedence under Fallback: an Agent-supplied nested value wins over the env key.
270 let mut v = json!({
271 "apm_enable_rare_sampler": true,
272 "apm_config": { "enable_rare_sampler": false },
273 });
274 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
275 assert_eq!(
276 v.get("apm_config").and_then(|c| c.get("enable_rare_sampler")),
277 Some(&Value::Bool(false)),
278 );
279 }
280
281 #[test]
282 fn divergent_env_key_override_replaces_agent_value() {
283 // Override precedence: the env key clobbers an Agent-supplied nested value.
284 let mut v = json!({
285 "apm_enable_rare_sampler": true,
286 "apm_config": { "enable_rare_sampler": false },
287 });
288 apply_env_overlay(&mut v, EnvOverlayMode::Override);
289 assert_eq!(
290 v.get("apm_config").and_then(|c| c.get("enable_rare_sampler")),
291 Some(&Value::Bool(true)),
292 );
293 }
294
295 #[test]
296 fn select_flat_honors_priority_order() {
297 // `flats` is highest priority first, so the first present form supplies the value even when a
298 // lower-priority form is also set.
299 let root = json!({ "dd_dd_url": "primary", "url": "secondary" });
300 let root = root.as_object().unwrap();
301 assert_eq!(
302 select_flat(root, &["dd_dd_url", "url"]),
303 Some(&Value::String("primary".to_string())),
304 );
305 // With only the lower-priority form present, it is used.
306 let root = json!({ "url": "secondary" });
307 let root = root.as_object().unwrap();
308 assert_eq!(
309 select_flat(root, &["dd_dd_url", "url"]),
310 Some(&Value::String("secondary".to_string())),
311 );
312 }
313
314 #[test]
315 fn aliased_env_key_reaches_single_segment_slot() {
316 // `dd_url` carries two env vars: `DD_DD_URL` (flat `dd_url`, already in place) and `DD_URL`
317 // (flat `url`). Only the divergent `url` alias needs relocation into `dd_url`.
318 assert!(
319 overlay_key("dd_url").is_none(),
320 "no-op alias should not be in the table"
321 );
322 assert_eq!(overlay_key("url").map(|k| k.path), Some(&["dd_url"][..]));
323
324 let mut v = json!({ "url": "https://example.com" });
325 apply_env_overlay(&mut v, EnvOverlayMode::Fallback);
326 assert_eq!(v.get("dd_url"), Some(&Value::String("https://example.com".to_string())));
327 }
328}