datadog_agent_config/
env_reader.rs

1//! Builds the typed configuration base from environment variables.
2//!
3//! The Datadog Agent reads configuration by iterating its table of known keys
4//! and, for each, looking up that key's exact environment variable names, never by scanning the
5//! environment or splitting on a separator. This module mirrors that: [`DATADOG_ENV_KEYS`] is
6//! generated from the vendored schema, and [`apply_datadog_env`] looks up each key's real names,
7//! decodes the first non-empty value into the JSON shape the schema declares (see
8//! [`crate::env_decode`]), and writes it at the key's nested path.
9//!
10//! Each value is decoded and written directly to the nested path the typed deserializer reads.
11//! Precedence relative to the config file is controlled by the caller through `overwrite`.
12
13use std::collections::HashMap;
14
15use serde_json::{Map, Value};
16
17use crate::env_decode::{decode, EnvDecode};
18use crate::generated::env_keys::DATADOG_ENV_KEYS;
19
20/// One modeled configuration key and how to read it from the environment.
21#[derive(Clone, Copy, Debug)]
22pub struct EnvKey {
23    /// The environment variable names bound to this key, highest priority first. The first one
24    /// set to a non-empty value supplies the key's value, matching the Agent's per-key lookup.
25    pub env_vars: &'static [&'static str],
26    /// The nested path the typed deserializer reads (`["dogstatsd", "port"]`).
27    pub path: &'static [&'static str],
28    /// How to decode the raw string into the schema-declared JSON shape.
29    pub decode: EnvDecode,
30}
31
32/// Reads every modeled Datadog key from the environment and writes decoded values into `base`.
33///
34/// Applies the canonical proxy variables (`HTTP_PROXY`/`HTTPS_PROXY`) as well. When `overwrite` is
35/// true an environment value replaces whatever is already at its path (environment wins over the
36/// file); when false it fills only an absent path (the file wins).
37///
38/// # Errors
39///
40/// Returns a message naming the environment variable when its value is malformed for the key's
41/// declared shape. At startup this aborts the boot rather than silently dropping the value.
42pub fn apply_datadog_env(base: &mut Value, overwrite: bool) -> Result<(), String> {
43    apply_env_snapshot(base, EnvSnapshot::capture(), overwrite)
44}
45
46/// Reads every modeled Datadog key from explicitly provided environment variable name/value pairs.
47///
48/// Identical to [`apply_datadog_env`] except for where the variables come from. Tests use this to
49/// avoid depending on the ambient process environment.
50///
51/// # Errors
52///
53/// Returns a message naming the environment variable when its value is malformed for the key's
54/// declared shape.
55pub fn apply_datadog_env_vars(
56    base: &mut Value, vars: impl IntoIterator<Item = (String, String)>, overwrite: bool,
57) -> Result<(), String> {
58    apply_env_snapshot(base, EnvSnapshot::from_vars(vars), overwrite)
59}
60
61fn apply_env_snapshot(base: &mut Value, env: EnvSnapshot, overwrite: bool) -> Result<(), String> {
62    let Some(root) = base.as_object_mut() else {
63        return Ok(());
64    };
65    for key in DATADOG_ENV_KEYS {
66        apply_one(root, &env, key.env_vars, key.path, key.decode, overwrite)?;
67    }
68    apply_proxy_env(root, &env, overwrite);
69    Ok(())
70}
71
72/// The nested path of every modeled Datadog key.
73///
74/// The merge that layers the Agent config stream over the base uses these to tell a schema section
75/// (an intermediate object it descends into) from a leaf (a value it replaces wholesale), so a
76/// map-typed leaf is never key-unioned across sources.
77pub fn datadog_leaf_paths() -> impl Iterator<Item = &'static [&'static str]> {
78    DATADOG_ENV_KEYS.iter().map(|key| key.path)
79}
80
81/// Reads one key from the environment and writes it into `root`.
82///
83/// The public entry for callers with a runtime-built key set (the Saluki-only model), which cannot
84/// use the `'static` [`EnvKey`] table. Same semantics as [`apply_datadog_env`] for a single key.
85///
86/// # Errors
87///
88/// Returns a message when the environment value is malformed for `decode`.
89pub fn apply_env_at_path(
90    base: &mut Value, env_vars: &[&str], path: &[&str], decode: EnvDecode, overwrite: bool,
91) -> Result<(), String> {
92    let Some(root) = base.as_object_mut() else {
93        return Ok(());
94    };
95    let env = EnvSnapshot::capture();
96    apply_one(root, &env, env_vars, path, decode, overwrite)
97}
98
99/// The canonical proxy variables that carry no `DD_` prefix.
100///
101/// `HTTP_PROXY` and `HTTPS_PROXY` are honored by the Datadog Agent but are not declared by the
102/// vendored schema (they are wired only by the Agent's bespoke proxy handling), so they are not in
103/// [`DATADOG_ENV_KEYS`] and are injected here by hand. They are a fallback below the schema's
104/// `DD_PROXY_HTTP`/`DD_PROXY_HTTPS`: when the `DD_`-prefixed form is set, the canonical form is
105/// ignored, matching the Agent's ordering. Canonical `NO_PROXY` is intentionally unsupported.
106///
107/// The canonical names are matched case-insensitively through [`EnvSnapshot`], so the common
108/// lowercase Unix convention (`http_proxy`/`https_proxy`) is honored alongside the uppercase form.
109fn apply_proxy_env(root: &mut Map<String, Value>, env: &EnvSnapshot, overwrite: bool) {
110    const PROXY: &[(&str, &str, &[&str])] = &[
111        ("DD_PROXY_HTTP", "HTTP_PROXY", &["proxy", "http"]),
112        ("DD_PROXY_HTTPS", "HTTPS_PROXY", &["proxy", "https"]),
113    ];
114    for (dd_var, canonical_var, path) in PROXY {
115        // The DD_-prefixed form is handled by the generated table and wins; only fill from the
116        // canonical form when the DD_ form is unset.
117        if env.contains(dd_var) {
118            continue;
119        }
120        if let Some(raw) = env.lookup(&[canonical_var]) {
121            write_at_path(root, path, Value::String(raw), overwrite);
122        }
123    }
124}
125
126/// Reads, decodes, and writes one key.
127fn apply_one(
128    root: &mut Map<String, Value>, env: &EnvSnapshot, env_vars: &[&str], path: &[&str], how: EnvDecode, overwrite: bool,
129) -> Result<(), String> {
130    let Some(raw) = env.lookup(env_vars) else {
131        return Ok(());
132    };
133    let value = decode(&raw, how).map_err(|msg| {
134        let name = env_vars.first().copied().unwrap_or_default();
135        format!("environment variable `{name}` ({}): {msg}", path.join("."))
136    })?;
137    write_at_path(root, path, value, overwrite);
138    Ok(())
139}
140
141/// A case-insensitive snapshot of the process environment.
142///
143/// The figment loader reads environment variables case-insensitively (via `uncased`). Because
144/// `std::env::var` is case-sensitive, this reader captures the environment with lowercase names to
145/// preserve parity. `DD_DOGSTATSD_PORT`, `dd_dogstatsd_port`, and `Dd_Dogstatsd_Port` all resolve to
146/// the same key.
147struct EnvSnapshot {
148    /// Environment values keyed by lowercase variable name. Only non-empty values are kept.
149    vars: HashMap<String, String>,
150}
151
152impl EnvSnapshot {
153    /// Captures the environment, converting names to lowercase and dropping empty values.
154    fn capture() -> Self {
155        Self::from_vars(std::env::vars())
156    }
157
158    /// Builds a snapshot from environment variable pairs.
159    ///
160    /// An empty string counts as unset, matching the Agent (`os.LookupEnv` plus an emptiness
161    /// check). If several case variants of one name are set, the first seen wins.
162    fn from_vars(vars: impl IntoIterator<Item = (String, String)>) -> Self {
163        let mut snapshot = HashMap::new();
164        for (name, value) in vars {
165            if value.is_empty() {
166                continue;
167            }
168            snapshot.entry(name.to_lowercase()).or_insert(value);
169        }
170        Self { vars: snapshot }
171    }
172
173    /// Returns the value of the first name in `names` (highest priority first) that is set to a
174    /// non-empty value, matched case-insensitively.
175    fn lookup(&self, names: &[&str]) -> Option<String> {
176        names
177            .iter()
178            .find_map(|name| self.vars.get(&name.to_lowercase()).cloned())
179    }
180
181    /// Whether any case variant of `name` is set to a non-empty value.
182    fn contains(&self, name: &str) -> bool {
183        self.vars.contains_key(&name.to_lowercase())
184    }
185}
186
187/// Writes `value` at `path`, creating intermediate objects. When `overwrite` is false, an existing
188/// value at the full path is left in place (the file wins).
189fn write_at_path(root: &mut Map<String, Value>, path: &[&str], value: Value, overwrite: bool) {
190    let Some((leaf, sections)) = path.split_last() else {
191        return;
192    };
193    if !overwrite && path_present(root, path) {
194        return;
195    }
196    let mut current = root;
197    for segment in sections {
198        let node = current
199            .entry((*segment).to_string())
200            .or_insert_with(|| Value::Object(Map::new()));
201        if !node.is_object() {
202            *node = Value::Object(Map::new());
203        }
204        current = node.as_object_mut().expect("node was just ensured to be an object");
205    }
206    current.insert((*leaf).to_string(), value);
207}
208
209/// Returns whether a leaf already exists at `path`, walking object nodes from `root`.
210fn path_present(root: &Map<String, Value>, path: &[&str]) -> bool {
211    let Some((first, rest)) = path.split_first() else {
212        return false;
213    };
214    let mut current = match root.get(*first) {
215        Some(v) => v,
216        None => return false,
217    };
218    for segment in rest {
219        match current.get(*segment) {
220            Some(v) => current = v,
221            None => return false,
222        }
223    }
224    true
225}
226
227#[cfg(test)]
228mod tests {
229    use serde_json::json;
230
231    use super::*;
232
233    // Process environment is global; serialize the tests that mutate it. Each test removes the
234    // variables it sets so they do not leak into sibling tests.
235    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237    fn at<'a>(base: &'a Value, path: &[&str]) -> Option<&'a Value> {
238        let mut cur = base;
239        for seg in path {
240            cur = cur.get(seg)?;
241        }
242        Some(cur)
243    }
244
245    #[test]
246    fn decodes_scalars_and_lists_to_shape() {
247        let _guard = ENV_MUTEX.lock().unwrap();
248        std::env::set_var("DD_DOGSTATSD_PORT", "9125");
249        std::env::set_var("DD_API_KEY", "00000");
250        std::env::set_var("DD_DOGSTATSD_TAGS", "env:prod team:core");
251
252        let mut base = json!({});
253        apply_datadog_env(&mut base, true).unwrap();
254
255        std::env::remove_var("DD_DOGSTATSD_PORT");
256        std::env::remove_var("DD_API_KEY");
257        std::env::remove_var("DD_DOGSTATSD_TAGS");
258
259        // Integer decodes to a JSON number; a numeric-looking API key stays a string (the schema
260        // says `api_key` is a string); a string list splits into a real array.
261        assert_eq!(at(&base, &["dogstatsd_port"]), Some(&json!(9125)));
262        assert_eq!(at(&base, &["api_key"]), Some(&json!("00000")));
263        assert_eq!(at(&base, &["dogstatsd_tags"]), Some(&json!(["env:prod", "team:core"])));
264    }
265
266    #[test]
267    fn writes_at_nested_path() {
268        let _guard = ENV_MUTEX.lock().unwrap();
269        std::env::set_var("DD_APM_ERROR_TPS", "12.5");
270
271        let mut base = json!({});
272        apply_datadog_env(&mut base, true).unwrap();
273        std::env::remove_var("DD_APM_ERROR_TPS");
274
275        assert_eq!(at(&base, &["apm_config", "errors_per_second"]), Some(&json!(12.5)));
276    }
277
278    #[test]
279    fn precedence_overwrite_versus_fill() {
280        let _guard = ENV_MUTEX.lock().unwrap();
281        std::env::set_var("DD_DOGSTATSD_PORT", "9125");
282
283        let mut env_wins = json!({ "dogstatsd_port": 8125 });
284        apply_datadog_env(&mut env_wins, true).unwrap();
285        assert_eq!(at(&env_wins, &["dogstatsd_port"]), Some(&json!(9125)));
286
287        let mut file_wins = json!({ "dogstatsd_port": 8125 });
288        apply_datadog_env(&mut file_wins, false).unwrap();
289        assert_eq!(at(&file_wins, &["dogstatsd_port"]), Some(&json!(8125)));
290
291        std::env::remove_var("DD_DOGSTATSD_PORT");
292    }
293
294    #[test]
295    fn env_var_names_are_matched_case_insensitively() {
296        let env = EnvSnapshot::from_vars([
297            ("dd_dogstatsd_port".to_string(), "9125".to_string()),
298            ("Dd_Api_Key".to_string(), "00000".to_string()),
299        ]);
300
301        assert_eq!(env.lookup(&["DD_DOGSTATSD_PORT"]).as_deref(), Some("9125"));
302        assert_eq!(env.lookup(&["DD_API_KEY"]).as_deref(), Some("00000"));
303    }
304
305    #[test]
306    fn canonical_proxy_is_a_fallback_below_dd_form() {
307        let _guard = ENV_MUTEX.lock().unwrap();
308
309        // Canonical form alone fills the slot.
310        std::env::set_var("HTTP_PROXY", "http://canonical:3128");
311        let mut base = json!({});
312        apply_datadog_env(&mut base, true).unwrap();
313        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://canonical:3128")));
314
315        // The DD_ form wins when both are set.
316        std::env::set_var("DD_PROXY_HTTP", "http://prefixed:3128");
317        let mut base = json!({});
318        apply_datadog_env(&mut base, true).unwrap();
319        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://prefixed:3128")));
320
321        std::env::remove_var("HTTP_PROXY");
322        std::env::remove_var("DD_PROXY_HTTP");
323    }
324
325    #[test]
326    fn lowercase_proxy_env_is_honored() {
327        let env = EnvSnapshot::from_vars([("http_proxy".to_string(), "http://lower:3128".to_string())]);
328        let mut base = json!({});
329        apply_proxy_env(base.as_object_mut().unwrap(), &env, true);
330
331        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://lower:3128")));
332    }
333
334    #[test]
335    fn malformed_value_propagates() {
336        let _guard = ENV_MUTEX.lock().unwrap();
337        std::env::set_var("DD_DOGSTATSD_PORT", "not-a-number");
338        let mut base = json!({});
339        let result = apply_datadog_env(&mut base, true);
340        std::env::remove_var("DD_DOGSTATSD_PORT");
341        assert!(result.is_err());
342    }
343}