agent_data_plane_config_system/
loaded.rs

1//! Loads local configuration before selecting its runtime authority.
2//!
3//! [`LoadedConfiguration::load`] prepares a typed snapshot of the local file and environment, which
4//! [`LoadedConfiguration::local`] exposes before values from the Datadog Agent stream are applied.
5//! [`LoadedConfiguration::run`] layers the Agent's configuration stream over the local sources,
6//! while [`LoadedConfiguration::standalone`] keeps the local sources authoritative. Both methods
7//! consume the loaded sources and return a [`ConfigurationSystem`].
8
9use std::path::Path;
10
11use agent_data_plane_config::SalukiConfiguration;
12use datadog_agent_config::apply_datadog_env;
13// TODO: remove after migration to typed config; these support the legacy flat-key loader.
14use datadog_agent_config::{DatadogRemapper, EnvOverlayMode, KEY_ALIASES};
15use saluki_config::dynamic::ConfigUpdate;
16use saluki_config::{ConfigurationLoader, GenericConfiguration};
17use serde_json::Value;
18use tokio::sync::mpsc;
19
20use crate::saluki_env_overlay;
21use crate::system::{translate_strict, ConfigurationSystem, Error};
22
23// The environment-variable prefix ADP reads (`DD_`). Mirrors
24// `PlatformSettings::get_env_var_prefix()`; hardcoded so the configuration system need not depend on
25// `datadog-agent-commons` for a single constant.
26const ENV_VAR_PREFIX: &str = "DD";
27
28// Bound on the internal channel that forwards Agent updates into the compatibility map. Matches the
29// Agent stream's own channel depth (`RemoteAgentBootstrap::create_config_stream`).
30const COMPAT_FORWARD_CHANNEL_SIZE: usize = 100;
31
32/// Where environment variables sit relative to the configuration file.
33///
34/// One setting, applied identically to the Figment provider order (which flat key wins) and to the
35/// typed env-key overlay. Replaces the raw `EnvOverlayMode` at the configuration system's boundary.
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum EnvPrecedence {
38    /// Environment variables are read below the file: the file wins.
39    ///
40    /// The file takes precedence over environment variables.
41    BeforeFile,
42    /// Environment variables are read after the file: the environment wins. Matches the Datadog
43    /// Agent's precedence and is the value ADP uses.
44    AfterFile,
45    /// Environment variables are not read.
46    Disabled,
47}
48
49impl EnvPrecedence {
50    /// The overlay flag the typed deserializer consumes for the same precedence.
51    //
52    // The remote Agent layer takes precedence over local environment values, so the environment
53    // fills only a nested slot that no higher-authority source supplied. Both file precedence modes
54    // use this rule; `Disabled` suppresses environment relocation entirely.
55    fn overlay_mode(self) -> EnvOverlayMode {
56        match self {
57            EnvPrecedence::Disabled => EnvOverlayMode::Disabled,
58            EnvPrecedence::AfterFile | EnvPrecedence::BeforeFile => EnvOverlayMode::Fallback,
59        }
60    }
61}
62
63/// Local configuration prepared before a runtime authority is selected.
64///
65/// Retains a nested base for the typed path and a loader for the legacy by-key path. Both use the
66/// same source path and precedence but build their representations independently.
67pub struct LoadedConfiguration {
68    loader: ConfigurationLoader,
69    // Nested local base used for typed translation and Agent-layer merges.
70    base: Value,
71    env: EnvPrecedence,
72    // Strictly translated local snapshot exposed before authority selection and used by standalone
73    // mode.
74    local: SalukiConfiguration,
75}
76
77impl LoadedConfiguration {
78    /// Loads and strictly translates the local file and environment using the requested precedence.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if a local source cannot be read, decoded, deserialized, or translated.
83    pub async fn load(path: impl AsRef<Path>, env: EnvPrecedence) -> Result<Self, Error> {
84        let loader = build_loader(path.as_ref(), env)?;
85        let base = build_base(path.as_ref(), env)?;
86        let local = translate_strict(&base, env.overlay_mode())?;
87        Ok(Self {
88            loader,
89            base,
90            env,
91            local,
92        })
93    }
94
95    /// Returns the typed snapshot of the local file and environment.
96    ///
97    /// Values from the Datadog Agent stream have not been applied to this snapshot.
98    pub fn local(&self) -> &SalukiConfiguration {
99        &self.local
100    }
101
102    /// Returns the local file and environment through the legacy by-key configuration API.
103    // TODO: Remove this compatibility view once bootstrap consumers use `local`.
104    pub fn raw_config(&self) -> GenericConfiguration {
105        self.loader.bootstrap_generic()
106    }
107
108    /// Uses the Datadog Agent's configuration stream as the runtime authority.
109    ///
110    /// Waits for the initial Agent snapshot, layers it over the local sources, strictly translates
111    /// the result, and then starts the update task.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the compatibility map cannot be built, the stream closes before its
116    /// initial snapshot, or the merged configuration cannot be deserialized or translated.
117    pub async fn run(self, config_stream: mpsc::Receiver<ConfigUpdate>) -> Result<ConfigurationSystem, Error> {
118        // The configuration system owns the Agent stream and forwards each update into this
119        // compatibility map, so `raw_map()` keeps serving un-migrated components.
120        let (compat_tx, compat_rx) = mpsc::channel(COMPAT_FORWARD_CHANNEL_SIZE);
121        let compat_map = self.loader.with_dynamic_configuration(compat_rx).into_generic().await?;
122
123        ConfigurationSystem::connected(config_stream, compat_tx, compat_map, self.base, self.env.overlay_mode()).await
124    }
125
126    /// Uses the translated local configuration as the runtime authority.
127    ///
128    /// No configuration stream or update task is created.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the compatibility map cannot be built from the local sources.
133    pub async fn standalone(self) -> Result<ConfigurationSystem, Error> {
134        let compat_map = self.loader.into_generic().await?;
135        Ok(ConfigurationSystem::standalone(compat_map, self.local))
136    }
137}
138
139/// Builds the typed base: the configuration file parsed to its nested shape, with environment
140/// variables read directly and decoded into the schema's shapes on top.
141///
142/// It reads the same file as the by-key compatibility view, normalizes it the same way (drop
143/// null-valued keys; an empty file is an empty object), and then overlays the environment via the
144/// generated Datadog reader, the Saluki-only reader, and the
145/// canonical proxy variables. `env` sets whether the environment overwrites the file (`AfterFile`)
146/// or only fills absent keys (`BeforeFile`); `Disabled` skips the environment entirely.
147fn build_base(path: &Path, env: EnvPrecedence) -> Result<Value, Error> {
148    let text = std::fs::read_to_string(path).map_err(|e| Error::Base {
149        message: format!("read `{}`: {e}", path.display()),
150    })?;
151    let mut base: Value = serde_yaml::from_str(&text).map_err(|e| Error::Base {
152        message: format!("parse `{}`: {e}", path.display()),
153    })?;
154    drop_nulls(&mut base);
155    if base.is_null() {
156        base = Value::Object(serde_json::Map::new());
157    }
158
159    let overwrite = match env {
160        EnvPrecedence::Disabled => return Ok(base),
161        EnvPrecedence::AfterFile => true,
162        EnvPrecedence::BeforeFile => false,
163    };
164    apply_datadog_env(&mut base, overwrite).map_err(|message| Error::Base { message })?;
165    saluki_env_overlay::apply_env(&mut base, overwrite).map_err(|message| Error::Base { message })?;
166    Ok(base)
167}
168
169/// Recursively removes object entries whose value is JSON null, mirroring the compatibility loader's
170/// file normalization: an explicitly null YAML key must not override a model default with null.
171fn drop_nulls(value: &mut Value) {
172    if let Value::Object(map) = value {
173        map.retain(|_, v| !v.is_null());
174        for v in map.values_mut() {
175            drop_nulls(v);
176        }
177    }
178}
179
180/// Builds the by-key configuration view at the given precedence. File keys are normalized to the
181/// names used by environment variables, and later sources override earlier ones.
182fn build_loader(path: &Path, env: EnvPrecedence) -> Result<ConfigurationLoader, Error> {
183    let loader = ConfigurationLoader::default().with_key_aliases(KEY_ALIASES);
184    let loader = match env {
185        EnvPrecedence::AfterFile => loader
186            .from_yaml(path)?
187            .add_providers([DatadogRemapper::new()])
188            .from_environment(ENV_VAR_PREFIX)?,
189        EnvPrecedence::BeforeFile => loader
190            .add_providers([DatadogRemapper::new()])
191            .from_environment(ENV_VAR_PREFIX)?
192            .from_yaml(path)?,
193        EnvPrecedence::Disabled => loader.from_yaml(path)?,
194    };
195    Ok(loader)
196}
197
198#[cfg(test)]
199mod tests {
200    use bytesize::ByteSize;
201    use serde_json::json;
202
203    use super::*;
204
205    // The environment is process-global; serialize the tests that mutate it.
206    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
207
208    #[test]
209    fn build_base_composes_file_and_environment_by_precedence() {
210        let _guard = ENV_MUTEX.lock().unwrap();
211        let path = std::env::temp_dir().join(format!("adp_build_base_{}.yaml", std::process::id()));
212        std::fs::write(
213            &path,
214            "dogstatsd_port: 8125\ndogstatsd_non_local_traffic: false\nempty_key:\n",
215        )
216        .unwrap();
217        std::env::set_var("DD_DOGSTATSD_PORT", "9125");
218
219        // AfterFile: the environment wins over the file, the decoded value is a real number, an
220        // explicitly null key is dropped, and an unrelated file key is preserved.
221        let base = build_base(&path, EnvPrecedence::AfterFile).expect("base builds");
222        assert_eq!(base.get("dogstatsd_port"), Some(&json!(9125)));
223        assert_eq!(base.get("dogstatsd_non_local_traffic"), Some(&json!(false)));
224        assert!(base.get("empty_key").is_none());
225
226        // BeforeFile: the file wins over the environment.
227        let base = build_base(&path, EnvPrecedence::BeforeFile).expect("base builds");
228        assert_eq!(base.get("dogstatsd_port"), Some(&json!(8125)));
229
230        // Disabled: the environment is ignored entirely.
231        let base = build_base(&path, EnvPrecedence::Disabled).expect("base builds");
232        assert_eq!(base.get("dogstatsd_port"), Some(&json!(8125)));
233
234        std::env::remove_var("DD_DOGSTATSD_PORT");
235        std::fs::remove_file(&path).ok();
236    }
237
238    #[tokio::test]
239    async fn local_exposes_translated_configuration() {
240        // Disable environment reads so this test does not need `ENV_MUTEX`.
241        let path = std::env::temp_dir().join(format!("adp_local_{}.yaml", std::process::id()));
242        std::fs::write(&path, "log_level: warn\ndogstatsd_port: 9125\n").unwrap();
243
244        let loaded = LoadedConfiguration::load(&path, EnvPrecedence::Disabled)
245            .await
246            .expect("local sources load");
247        let config = loaded.local();
248
249        assert_eq!(config.control.logging.level, "warn");
250        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
251
252        std::fs::remove_file(&path).ok();
253    }
254
255    #[tokio::test]
256    async fn load_rejects_translation_invalid_local_sources() {
257        let path = std::env::temp_dir().join(format!("adp_local_bad_{}.yaml", std::process::id()));
258        // The compatibility loader accepts this value, but typed translation rejects it.
259        std::fs::write(&path, "dogstatsd_tag_cardinality: bogus\n").unwrap();
260
261        let result = LoadedConfiguration::load(&path, EnvPrecedence::Disabled).await;
262
263        std::fs::remove_file(&path).ok();
264        assert!(matches!(result, Err(Error::Translate { .. })));
265    }
266
267    #[test]
268    fn build_base_rejects_a_malformed_environment_value() {
269        let _guard = ENV_MUTEX.lock().unwrap();
270        let path = std::env::temp_dir().join(format!("adp_build_base_bad_{}.yaml", std::process::id()));
271        std::fs::write(&path, "dogstatsd_port: 8125\n").unwrap();
272        std::env::set_var("DD_DOGSTATSD_PORT", "not-a-number");
273
274        let result = build_base(&path, EnvPrecedence::AfterFile);
275
276        std::env::remove_var("DD_DOGSTATSD_PORT");
277        std::fs::remove_file(&path).ok();
278        assert!(matches!(result, Err(Error::Base { .. })));
279    }
280
281    #[test]
282    fn build_base_accepts_a_human_readable_dogstatsd_interner_size() {
283        let _guard = ENV_MUTEX.lock().unwrap();
284        let path = std::env::temp_dir().join(format!("adp_build_base_interner_{}.yaml", std::process::id()));
285        std::fs::write(&path, "{}\n").unwrap();
286        std::env::set_var("DD_DOGSTATSD_STRING_INTERNER_SIZE_BYTES", "12MiB");
287
288        let base = build_base(&path, EnvPrecedence::AfterFile).expect("human-readable byte size builds");
289        let config = translate_strict(&base, EnvOverlayMode::Fallback).expect("human-readable byte size translates");
290
291        std::env::remove_var("DD_DOGSTATSD_STRING_INTERNER_SIZE_BYTES");
292        std::fs::remove_file(&path).ok();
293        assert_eq!(
294            config.domains.dogstatsd.contexts.string_interner_size_bytes,
295            Some(ByteSize::mib(12).as_u64())
296        );
297    }
298}