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}