Skip to main content

saluki_config/
lib.rs

1//! Primitives for working with typed and untyped configuration data.
2#![deny(warnings)]
3#![deny(missing_docs)]
4
5use std::sync::{Arc, OnceLock, RwLock};
6use std::{borrow::Cow, collections::HashSet};
7
8pub use figment::value;
9use figment::{
10    error::Kind,
11    providers::{Env, Serialized},
12    Figment, Provider,
13};
14use saluki_error::GenericError;
15use serde::Deserialize;
16use snafu::Snafu;
17use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
18use tracing::{debug, error};
19
20pub mod duration_string;
21pub mod dynamic;
22mod provider;
23pub mod space_separated;
24
25pub use self::duration_string::{parse_duration, DurationString, ParseDurationError};
26pub use self::dynamic::FieldUpdateWatcher;
27use self::dynamic::{ConfigChangeEvent, ConfigUpdate};
28use self::provider::ResolvedProvider;
29pub use self::space_separated::{deserialize_opt_space_separated_or_seq, deserialize_space_separated_or_seq};
30
31#[derive(Clone)]
32struct ArcProvider(Arc<dyn Provider + Send + Sync>);
33
34impl Provider for ArcProvider {
35    fn metadata(&self) -> figment::Metadata {
36        self.0.metadata()
37    }
38
39    fn data(&self) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
40        self.0.data()
41    }
42}
43
44enum ProviderSource {
45    Static(ArcProvider),
46    Dynamic(Option<mpsc::Receiver<ConfigUpdate>>),
47}
48
49impl Clone for ProviderSource {
50    fn clone(&self) -> Self {
51        match self {
52            Self::Static(p) => Self::Static(p.clone()),
53            Self::Dynamic(_) => Self::Dynamic(None),
54        }
55    }
56}
57
58/// A configuration error.
59#[derive(Debug, Snafu)]
60#[snafu(context(suffix(false)))]
61pub enum ConfigurationError {
62    /// Environment variable prefix was empty.
63    #[snafu(display("Environment variable prefix must not be empty."))]
64    EmptyPrefix,
65
66    /// Requested field was missing from the configuration.
67    #[snafu(display("Missing field '{}' in configuration. {}", field, help_text))]
68    MissingField {
69        /// Help text describing how to set the missing field.
70        ///
71        /// This is meant to be displayed to the user, and includes environment variable-specific text if environment
72        /// variables had been loaded originally.
73        help_text: String,
74
75        /// Name of the missing field.
76        field: Cow<'static, str>,
77    },
78
79    /// Requested field's data type wasn't the unexpected data type.
80    #[snafu(display(
81        "Expected value for field '{}' to be '{}', got '{}' instead.",
82        field,
83        expected_ty,
84        actual_ty
85    ))]
86    InvalidFieldType {
87        /// Name of the invalid field.
88        ///
89        /// This is a period-separated path to the field.
90        field: String,
91
92        /// Expected data type.
93        expected_ty: String,
94
95        /// Actual data type.
96        actual_ty: String,
97    },
98
99    /// Generic configuration error.
100    #[snafu(transparent)]
101    Generic {
102        /// Error source.
103        source: GenericError,
104    },
105}
106
107impl From<figment::Error> for ConfigurationError {
108    fn from(e: figment::Error) -> Self {
109        match e.kind {
110            Kind::InvalidType(actual_ty, expected_ty) => Self::InvalidFieldType {
111                field: e.path.join("."),
112                expected_ty,
113                actual_ty: actual_ty.to_string(),
114            },
115            _ => Self::Generic { source: e.into() },
116        }
117    }
118}
119
120#[derive(Clone, Debug, Eq, Hash, PartialEq)]
121enum LookupSource {
122    /// The configuration key is looked up in a form suitable for environment variables.
123    Environment { prefix: String },
124}
125
126impl LookupSource {
127    fn transform_key(&self, key: &str) -> String {
128        match self {
129            // The prefix should already be uppercased, with a trailing underscore, which is needed when we actually
130            // configure the provider used for reading from the environment... so we don't need to re-do that here.
131            LookupSource::Environment { prefix } => format!("{}{}", prefix, key.replace('.', "_").to_uppercase()),
132        }
133    }
134}
135
136/// A configuration loader that can pull from various sources.
137///
138/// This loader provides a wrapper around a lower-level library, `figment`, to expose a simpler and focused API for both
139/// loading configuration data from various sources, as well as querying it.
140///
141/// A variety of configuration sources can be configured (see below), with an implicit priority based on the order in
142/// which sources are added: sources added later take precedence over sources prior. Additionally, either a typed value
143/// can be extracted from the configuration ([`into_typed`][Self::into_typed]), or the raw configuration data can be
144/// accessed via a generic API ([`into_generic`][Self::into_generic]).
145///
146/// # Supported sources
147///
148/// - YAML file
149/// - JSON file
150/// - environment variables (must be prefixed; see [`from_environment`][Self::from_environment])
151#[derive(Clone, Default)]
152pub struct ConfigurationLoader {
153    key_aliases: &'static [(&'static str, &'static str)],
154    lookup_sources: HashSet<LookupSource>,
155    provider_sources: Vec<ProviderSource>,
156}
157
158impl ConfigurationLoader {
159    /// Sets key aliases to apply when loading file-based configuration sources.
160    ///
161    /// Each entry is `(nested_path, flat_key)`. When a YAML or JSON file contains a value at `nested_path`
162    /// (dot-separated), that value is also emitted under `flat_key` at the top level—but only if `flat_key`
163    /// isn't already explicitly set at the top level. This ensures that both YAML nested format and flat env var
164    /// format produce the same Figment key, so source precedence (env vars > file) works correctly.
165    ///
166    /// Must be called before any file-loading methods ([`from_yaml`][Self::from_yaml], etc.) to take effect.
167    pub fn with_key_aliases(mut self, aliases: &'static [(&'static str, &'static str)]) -> Self {
168        self.key_aliases = aliases;
169        self
170    }
171
172    /// Appends one or more providers to the configuration chain.
173    ///
174    /// Sources are merged in the order they're added: later sources take precedence over earlier ones. Call
175    /// this method after any file-loading methods and before [`from_environment`][Self::from_environment] to
176    /// place the added providers at the correct intermediate precedence level:
177    ///
178    /// ```text
179    /// file providers  <  add_providers(...)  <  from_environment(...)
180    /// ```
181    pub fn add_providers<P, I>(mut self, providers: I) -> Self
182    where
183        P: Provider + Send + Sync + 'static,
184        I: IntoIterator<Item = P>,
185    {
186        for p in providers {
187            self.provider_sources
188                .push(ProviderSource::Static(ArcProvider(Arc::new(p))));
189        }
190        self
191    }
192
193    /// Loads the given YAML configuration file.
194    ///
195    /// # Errors
196    ///
197    /// If the file couldn't be read, or if the file isn't valid YAML, an error will be returned.
198    pub fn from_yaml<P>(mut self, path: P) -> Result<Self, ConfigurationError>
199    where
200        P: AsRef<std::path::Path>,
201    {
202        let resolved_provider = ResolvedProvider::from_yaml(&path, self.key_aliases)?;
203        self.provider_sources
204            .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
205        Ok(self)
206    }
207
208    /// Attempts to load the given YAML configuration file, ignoring any errors.
209    ///
210    /// Errors include the file not existing, not being readable/accessible, and not being valid YAML.
211    pub fn try_from_yaml<P>(mut self, path: P) -> Self
212    where
213        P: AsRef<std::path::Path>,
214    {
215        match ResolvedProvider::from_yaml(&path, self.key_aliases) {
216            Ok(resolved_provider) => {
217                self.provider_sources
218                    .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
219            }
220            Err(e) => {
221                println!(
222                    "Unable to read YAML configuration file '{}': {}. Ignoring.",
223                    path.as_ref().to_string_lossy(),
224                    e
225                );
226            }
227        }
228        self
229    }
230
231    /// Loads the given JSON configuration file.
232    ///
233    /// # Errors
234    ///
235    /// If the file couldn't be read, or if the file isn't valid JSON, an error will be returned.
236    pub fn from_json<P>(mut self, path: P) -> Result<Self, ConfigurationError>
237    where
238        P: AsRef<std::path::Path>,
239    {
240        let resolved_provider = ResolvedProvider::from_json(&path, self.key_aliases)?;
241        self.provider_sources
242            .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
243        Ok(self)
244    }
245
246    /// Attempts to load the given JSON configuration file, ignoring any errors.
247    ///
248    /// Errors include the file not existing, not being readable/accessible, and not being valid JSON.
249    pub fn try_from_json<P>(mut self, path: P) -> Self
250    where
251        P: AsRef<std::path::Path>,
252    {
253        match ResolvedProvider::from_json(&path, self.key_aliases) {
254            Ok(resolved_provider) => {
255                self.provider_sources
256                    .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
257            }
258            Err(e) => {
259                println!(
260                    "Unable to read JSON configuration file '{}': {}. Ignoring.",
261                    path.as_ref().to_string_lossy(),
262                    e
263                );
264            }
265        }
266        self
267    }
268
269    /// Loads configuration from environment variables.
270    ///
271    /// The prefix given will have an underscore appended to it if it doesn't already end with one. For
272    /// example, with a prefix of `app`, any environment variable starting with `app_` would be matched. The
273    /// prefix is case-insensitive.
274    ///
275    /// Sources are merged in the order they're added, with later sources taking precedence over earlier ones.
276    /// Sources added after this call will have higher precedence than environment variables.
277    ///
278    /// # Errors
279    ///
280    /// If the prefix is empty, an error will be returned.
281    pub fn from_environment(mut self, prefix: &'static str) -> Result<Self, ConfigurationError> {
282        if prefix.is_empty() {
283            return Err(ConfigurationError::EmptyPrefix);
284        }
285
286        let prefix = if prefix.ends_with('_') {
287            prefix.to_string()
288        } else {
289            format!("{}_", prefix)
290        };
291
292        // Convert to use Serialized::defaults since, Env isn't Send + Sync
293        let env = Env::prefixed(&prefix).split("__");
294        let values = env.data().unwrap();
295        if let Some(default_dict) = values.get(&figment::Profile::Default) {
296            self.provider_sources
297                .push(ProviderSource::Static(ArcProvider(Arc::new(Serialized::defaults(
298                    default_dict.clone(),
299                )))));
300            self.lookup_sources.insert(LookupSource::Environment { prefix });
301        }
302        Ok(self)
303    }
304
305    /// Enables dynamic configuration.
306    ///
307    /// The receiver is used in `run_dynamic_config_updater` to handle retrieving the initial snapshot and subsequent updates.
308    pub fn with_dynamic_configuration(mut self, receiver: mpsc::Receiver<ConfigUpdate>) -> Self {
309        self.provider_sources.push(ProviderSource::Dynamic(Some(receiver)));
310        self
311    }
312
313    /// Consumes the configuration loader, deserializing it as `T`.
314    ///
315    /// ## Errors
316    ///
317    /// If the configuration couldn't be deserialized into `T`, an error will be returned.
318    pub fn into_typed<'a, T>(self) -> Result<T, ConfigurationError>
319    where
320        T: Deserialize<'a>,
321    {
322        let figment = build_figment_from_sources(&self.provider_sources);
323        figment.extract().map_err(Into::into)
324    }
325
326    /// Creates a bootstrap `GenericConfiguration` without consuming the loader.
327    ///
328    /// This creates a static snapshot of the configuration loaded so far. As this is intended for bootstrapping
329    /// before dynamic configuration is active, the dynamic provider is ignored.
330    pub fn bootstrap_generic(&self) -> GenericConfiguration {
331        let figment = build_figment_from_sources(&self.provider_sources);
332
333        GenericConfiguration {
334            inner: Arc::new(Inner {
335                figment: RwLock::new(figment),
336                lookup_sources: self.lookup_sources.clone(),
337                event_sender: None,
338                ready_signal: Mutex::new(None),
339            }),
340        }
341    }
342
343    /// Consumes the configuration loader and wraps it in a generic wrapper.
344    pub async fn into_generic(mut self) -> Result<GenericConfiguration, ConfigurationError> {
345        let has_dynamic_provider = self
346            .provider_sources
347            .iter()
348            .any(|s| matches!(s, ProviderSource::Dynamic(_)));
349
350        if has_dynamic_provider {
351            let mut receiver_opt = None;
352            for source in self.provider_sources.iter_mut() {
353                if let ProviderSource::Dynamic(ref mut receiver) = source {
354                    receiver_opt = receiver.take();
355                    break;
356                }
357            }
358            let receiver = receiver_opt.expect("Dynamic receiver should exist but was not found");
359
360            // Build the initial figment object from the static providers. The dynamic provider is empty for now.
361            let figment = build_figment_from_sources(&self.provider_sources);
362
363            let (event_sender, _) = broadcast::channel(100);
364            let (ready_sender, ready_receiver) = oneshot::channel();
365
366            let generic_config = GenericConfiguration {
367                inner: Arc::new(Inner {
368                    figment: RwLock::new(figment),
369                    lookup_sources: self.lookup_sources,
370                    event_sender: Some(event_sender.clone()),
371                    ready_signal: Mutex::new(Some(ready_receiver)),
372                }),
373            };
374
375            // Spawn the background task to handle retrieving the initial snapshot and subsequent updates.
376            tokio::spawn(run_dynamic_config_updater(
377                generic_config.inner.clone(),
378                receiver,
379                self.provider_sources,
380                event_sender,
381                ready_sender,
382            ));
383
384            Ok(generic_config)
385        } else {
386            // Otherwise, just build the static configuration.
387            let figment = build_figment_from_sources(&self.provider_sources);
388
389            Ok(GenericConfiguration {
390                inner: Arc::new(Inner {
391                    figment: RwLock::new(figment),
392                    lookup_sources: self.lookup_sources,
393                    event_sender: None,
394                    ready_signal: Mutex::new(None),
395                }),
396            })
397        }
398    }
399
400    /// Configures a [`GenericConfiguration`] that's suitable for tests.
401    ///
402    /// This configures the loader with the following defaults:
403    ///
404    /// - configuration from a JSON file
405    /// - configuration from environment variables
406    ///
407    /// If `enable_dynamic_configuration` is true, a dynamic configuration sender is returned.
408    ///
409    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
410    pub async fn for_tests(
411        file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
412        enable_dynamic_configuration: bool,
413    ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>) {
414        Self::for_tests_with_provider_factory(file_values, env_vars, enable_dynamic_configuration, &[], || {
415            Serialized::defaults(serde_json::json!({}))
416        })
417        .await
418    }
419
420    /// Like [`for_tests`][Self::for_tests], but applies `key_aliases` during file loading and calls
421    /// `provider_factory` to build an additional provider inserted between the file provider and the
422    /// environment provider.
423    ///
424    /// The factory is called after test environment variables have been set, so any env var reads it performs
425    /// (for example, in `DatadogRemapper`) are consistent with the test's env setup.
426    ///
427    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
428    pub async fn for_tests_with_provider_factory<P, F>(
429        file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
430        enable_dynamic_configuration: bool, key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
431    ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>)
432    where
433        P: Provider + Send + Sync + 'static,
434        F: FnOnce() -> P,
435    {
436        let json_file = tempfile::NamedTempFile::new().expect("should not fail to create temp file.");
437        let path = &json_file.path();
438        let json_to_write = file_values.unwrap_or(serde_json::json!({}));
439        serde_json::to_writer(&json_file, &json_to_write).expect("should not fail to write to temp file.");
440
441        let mut loader = ConfigurationLoader::default()
442            .with_key_aliases(key_aliases)
443            .try_from_json(path);
444        let mut maybe_sender = None;
445        if enable_dynamic_configuration {
446            let (sender, receiver) = tokio::sync::mpsc::channel(1);
447            loader = loader.with_dynamic_configuration(receiver);
448            maybe_sender = Some(sender);
449        }
450
451        static ENV_MUTEX: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
452
453        let guard = ENV_MUTEX.get_or_init(|| std::sync::Mutex::new(())).lock().unwrap();
454
455        if let Some(pairs) = env_vars.as_ref() {
456            for (k, v) in pairs.iter() {
457                // Set under both the raw name and the TEST_ prefix:
458                //   - Raw name: available to any env-reading providers (for example, DatadogRemapper)
459                //   - TEST_ prefix: read by from_environment("TEST") (simulates DD_ prefix)
460                std::env::set_var(k, v);
461                std::env::set_var(format!("TEST_{}", k), v);
462            }
463        }
464
465        // Build and insert the extra provider while env vars are set so it can snapshot them.
466        let loader = loader.add_providers([provider_factory()]);
467
468        // Add environment provider last so it has the highest precedence.
469        let loader = loader
470            .from_environment("TEST")
471            .expect("should not fail to add environment provider");
472
473        // Clean up test-provided env vars now that all providers have been built.
474        if let Some(pairs) = env_vars.as_ref() {
475            for (k, _) in pairs.iter() {
476                std::env::remove_var(k);
477                std::env::remove_var(format!("TEST_{}", k));
478            }
479        }
480
481        drop(guard);
482
483        let cfg = loader
484            .into_generic()
485            .await
486            .expect("should not fail to build generic configuration");
487
488        (cfg, maybe_sender)
489    }
490}
491
492fn build_figment_from_sources(sources: &[ProviderSource]) -> Figment {
493    sources.iter().fold(Figment::new(), |figment, source| match source {
494        ProviderSource::Static(p) => figment.admerge(p.clone()),
495        // No-op. The merging is handled by the updater task.
496        ProviderSource::Dynamic(_) => figment,
497    })
498}
499
500/// Inserts or updates a value for a key.
501///
502/// Intermediate objects are created if they don't exist.
503pub fn upsert(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
504    if !root.is_object() {
505        *root = serde_json::Value::Object(serde_json::Map::new());
506    }
507
508    let mut current = root;
509    // Create a new node for each segment if the key is dotted.
510    let mut segments = key.split('.').peekable();
511
512    while let Some(seg) = segments.next() {
513        let is_leaf = segments.peek().is_none();
514
515        // Ensure current is an object before operating
516        if !current.is_object() {
517            *current = serde_json::Value::Object(serde_json::Map::new());
518        }
519        let node = current.as_object_mut().expect("current node should be an object");
520
521        if is_leaf {
522            node.insert(seg.to_string(), value);
523            break;
524        } else {
525            // Ensure child exists and is an object
526            let should_create_node = match node.get(seg) {
527                Some(v) => !v.is_object(),
528                None => true,
529            };
530            // Check if we need to create an intermediate node if it doesn't exist.
531            if should_create_node {
532                node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
533            }
534
535            // Advance the current node to the next level.
536            current = node.get_mut(seg).expect("should not fail to get nested object");
537        }
538    }
539}
540
541async fn run_dynamic_config_updater(
542    inner: Arc<Inner>, mut receiver: mpsc::Receiver<ConfigUpdate>, provider_sources: Vec<ProviderSource>,
543    sender: broadcast::Sender<ConfigChangeEvent>, ready_sender: oneshot::Sender<()>,
544) {
545    // The first message on the channel will be the initial snapshot.
546    let initial_update = match receiver.recv().await {
547        Some(update) => update,
548        None => {
549            // The channel was closed before we even received the initial snapshot.
550            debug!("Dynamic configuration channel closed before initial snapshot.");
551            return;
552        }
553    };
554
555    let mut dynamic_state = match initial_update {
556        ConfigUpdate::Snapshot(state) => state,
557        ConfigUpdate::Partial { .. } => {
558            // This is theoretically unreachable, as `configstream` should always send a snapshot first.
559            error!("First dynamic config message was not a snapshot. Updater may be in an inconsistent state.");
560            serde_json::Value::Null
561        }
562    };
563
564    // Rebuild the configuration with the initial snapshot.
565    let new_figment = provider_sources
566        .iter()
567        .fold(Figment::new(), |figment, source| match source {
568            ProviderSource::Static(p) => figment.admerge(p.clone()),
569            ProviderSource::Dynamic(_) => {
570                figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
571            }
572        });
573
574    // Update the main figment object and then release the lock.
575    {
576        let mut figment_guard = inner.figment.write().unwrap();
577        *figment_guard = new_figment.clone();
578    }
579
580    // Signal that the initial snapshot has been processed and the configuration is ready.
581    if ready_sender.send(()).is_err() {
582        debug!("Configuration readiness receiver dropped. Updater task shutting down.");
583        return;
584    }
585
586    // Set our "current" state for the main loop.
587    let mut current_config: figment::value::Value = new_figment.extract().unwrap();
588
589    // Enter the main loop to process subsequent updates.
590    loop {
591        let update = match receiver.recv().await {
592            Some(update) => update,
593            None => {
594                // The sender was dropped, which means the config stream has terminated. We can exit.
595                debug!("Dynamic configuration update channel closed. Updater task shutting down.");
596                return;
597            }
598        };
599
600        // Update our local dynamic state based on the received message.
601        match update {
602            ConfigUpdate::Snapshot(new_state) => {
603                debug!("Received configuration snapshot update.");
604                dynamic_state = new_state;
605            }
606            ConfigUpdate::Partial { key, value } => {
607                debug!(%key, "Received partial configuration update.");
608                if dynamic_state.is_null() {
609                    dynamic_state = serde_json::Value::Object(serde_json::Map::new());
610                }
611                if dynamic_state.is_object() {
612                    upsert(&mut dynamic_state, &key, value);
613                } else {
614                    error!(
615                        "Received partial update but current dynamic state is not an object. This should not happen."
616                    );
617                }
618            }
619        }
620
621        // Rebuild the figment object on every update, respecting the original provider order.
622        let new_figment = provider_sources
623            .iter()
624            .fold(Figment::new(), |figment, source| match source {
625                ProviderSource::Static(p) => figment.admerge(p.clone()),
626                ProviderSource::Dynamic(_) => {
627                    figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
628                }
629            });
630
631        let new_config: figment::value::Value = new_figment.clone().extract().unwrap();
632
633        if current_config != new_config {
634            let changes = dynamic::diff_config(&current_config, &new_config);
635
636            {
637                let mut figment_guard = inner.figment.write().unwrap_or_else(|e| {
638                    error!("Failed to acquire write lock for dynamic configuration: {}", e);
639                    e.into_inner()
640                });
641                *figment_guard = new_figment;
642            }
643
644            for change in changes {
645                // Send the change event to any receivers of the dynamic handler.
646                // If there are no receivers, `send` will fail. This is expected and fine,
647                // so we can ignore the error to avoid log spam.
648                let _ = sender.send(change);
649            }
650
651            // Update our "current" state for the next iteration.
652            current_config = new_config;
653        }
654    }
655}
656
657#[derive(Debug)]
658struct Inner {
659    figment: RwLock<Figment>,
660    lookup_sources: HashSet<LookupSource>,
661    event_sender: Option<broadcast::Sender<ConfigChangeEvent>>,
662    ready_signal: Mutex<Option<oneshot::Receiver<()>>>,
663}
664
665/// A generic configuration object.
666///
667/// This represents the merged configuration derived from [`ConfigurationLoader`] in its raw form. Values can be
668/// queried by key, and can be extracted either as typed values or in their raw form.
669///
670/// Keys must be in the form of `a.b.c`, where periods (`.`) as used to indicate a nested value.
671///
672/// Using an example JSON configuration:
673///
674/// ```json
675/// {
676///   "a": {
677///     "b": {
678///       "c": "value"
679///     }
680///   }
681/// }
682/// ```
683///
684/// Querying for the value of `a.b.c` would return `"value"`, and querying for `a.b` would return the nested object `{
685/// "c": "value" }`.
686#[derive(Clone, Debug)]
687pub struct GenericConfiguration {
688    inner: Arc<Inner>,
689}
690
691impl GenericConfiguration {
692    /// Waits for the configuration to be ready, if dynamic configuration is enabled.
693    ///
694    /// If dynamic configuration is in use, this method will asynchronously wait until the first snapshot has been
695    /// received and applied.
696    ///
697    /// If dynamic configuration isn't used, it returns immediately.
698    pub async fn ready(&self) {
699        // We need a lock to both ensure that multiple callers can race against this,
700        // and to allow us mutable access to consume the receiver.
701        let mut maybe_ready_rx = self.inner.ready_signal.lock().await;
702        if let Some(ready_rx) = maybe_ready_rx.take() {
703            // We're the first caller to wait for readiness.
704            //
705            // There is no timeout on this await by design: if the Core Agent never sends the first snapshot, startup
706            // blocks here forever.
707            saluki_antithesis::reachable!("config readiness wait entered");
708
709            let ready_result = ready_rx.await;
710
711            if ready_result.is_err() {
712                saluki_antithesis::unreachable!(
713                    "config readiness sender dropped before signalling — updater task may have panicked"
714                );
715                error!("Failed to receive configuration readiness signal; updater task may have panicked.");
716            } else {
717                saluki_antithesis::sometimes!(true, "config readiness signal received");
718            }
719        }
720    }
721
722    fn get<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
723    where
724        T: Deserialize<'a>,
725    {
726        let figment_guard = self.inner.figment.read().unwrap();
727        match figment_guard.extract_inner(key) {
728            Ok(value) => Ok(value),
729            Err(e) => {
730                if matches!(e.kind, figment::error::Kind::MissingField(_)) {
731                    // We might have been given a key that uses nested notation -- `foo.bar` -- but is only present in the
732                    // environment variables. We specifically don't want to use a different separator in environment
733                    // variables to map to nested key separators, so we simply try again here but with all nested key
734                    // separators (`.`) replaced with `_`, to match environment variables.
735                    let fallback_key = key.replace('.', "_");
736                    figment_guard
737                        .extract_inner(&fallback_key)
738                        .map_err(|fallback_e| from_figment_error(&self.inner.lookup_sources, fallback_e))
739                } else {
740                    Err(e.into())
741                }
742            }
743        }
744    }
745
746    /// Gets a configuration value by key.
747    ///
748    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
749    ///
750    /// ## Errors
751    ///
752    /// If the key doesn't exist in the configuration, or if the value couldn't be deserialized into `T`, an error
753    /// variant will be returned.
754    pub fn get_typed<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
755    where
756        T: Deserialize<'a>,
757    {
758        self.get(key)
759    }
760
761    /// Gets a configuration value by key, or the default value if a key doesn't exist or couldn't be deserialized.
762    ///
763    /// The `Default` implementation of `T` will be used both if the key couldn't be found, as well as for any error
764    /// during deserialization. This effectively swallows any errors and should generally be used sparingly.
765    ///
766    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
767    pub fn get_typed_or_default<'a, T>(&self, key: &str) -> T
768    where
769        T: Default + Deserialize<'a>,
770    {
771        self.get(key).unwrap_or_default()
772    }
773
774    /// Gets a configuration value by key, if it exists.
775    ///
776    /// If the key exists in the configuration, and can be deserialized, `Ok(Some(value))` is returned. Otherwise,
777    /// `Ok(None)` will be returned.
778    ///
779    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
780    ///
781    /// ## Errors
782    ///
783    /// If the value couldn't be deserialized into `T`, an error will be returned.
784    pub fn try_get_typed<'a, T>(&self, key: &str) -> Result<Option<T>, ConfigurationError>
785    where
786        T: Deserialize<'a>,
787    {
788        match self.get(key) {
789            Ok(value) => Ok(Some(value)),
790            Err(ConfigurationError::MissingField { .. }) => Ok(None),
791            Err(e) => Err(e),
792        }
793    }
794
795    /// Attempts to deserialize the entire configuration as `T`.
796    ///
797    /// ## Errors
798    ///
799    /// If the value couldn't be deserialized into `T`, an error will be returned.
800    pub fn as_typed<'a, T>(&self) -> Result<T, ConfigurationError>
801    where
802        T: Deserialize<'a>,
803    {
804        self.inner
805            .figment
806            .read()
807            .unwrap()
808            .extract()
809            .map_err(|e| from_figment_error(&self.inner.lookup_sources, e))
810    }
811
812    /// Subscribes for updates to the configuration.
813    pub fn subscribe_for_updates(&self) -> Option<broadcast::Receiver<dynamic::ConfigChangeEvent>> {
814        self.inner.event_sender.as_ref().map(|s| s.subscribe())
815    }
816
817    /// Extracts the entire configuration as a flat list of dot-separated key paths and their values.
818    ///
819    /// Nested JSON objects are descended into, joining keys with dots. Arrays, strings, numbers,
820    /// bools, and nulls are leaf values and are never descended into. The returned values are
821    /// never `Value::Object`.
822    ///
823    /// ## Errors
824    ///
825    /// If the configuration couldn't be serialized to JSON, an error will be returned.
826    pub fn flattened_keys(&self) -> Result<Vec<(String, serde_json::Value)>, ConfigurationError> {
827        let root: serde_json::Value = self.as_typed()?;
828        let mut out = Vec::new();
829        flatten_value(&root, &mut String::new(), &mut out);
830        Ok(out)
831    }
832
833    /// Creates a watcher that yields only when the given key changes.
834    ///
835    /// If dynamic configuration is disabled, the returned watcher's `changed()`
836    /// will wait indefinitely.
837    pub fn watch_for_updates(&self, key: &str) -> FieldUpdateWatcher {
838        FieldUpdateWatcher {
839            key: key.to_string(),
840            rx: self.subscribe_for_updates(),
841        }
842    }
843}
844
845/// Recursively descend into the tree building a dot-separated path until hitting a non-Object leaf node.
846fn flatten_value(value: &serde_json::Value, prefix: &mut String, out: &mut Vec<(String, serde_json::Value)>) {
847    if let serde_json::Value::Object(map) = value {
848        for (key, child) in map {
849            let prev_len = prefix.len();
850            if !prefix.is_empty() {
851                prefix.push('.');
852            }
853            prefix.push_str(key);
854            flatten_value(child, prefix, out);
855            prefix.truncate(prev_len);
856        }
857    } else {
858        out.push((prefix.clone(), value.clone()));
859    }
860}
861
862fn from_figment_error(lookup_sources: &HashSet<LookupSource>, e: figment::Error) -> ConfigurationError {
863    match e.kind {
864        Kind::MissingField(field) => {
865            let mut valid_keys = lookup_sources
866                .iter()
867                .map(|source| source.transform_key(&field))
868                .collect::<Vec<_>>();
869
870            // Always specify the original key as a valid key to try.
871            valid_keys.insert(0, field.to_string());
872
873            let help_text = format!("Try setting `{}`.", valid_keys.join("` or `"));
874
875            ConfigurationError::MissingField { help_text, field }
876        }
877        Kind::InvalidType(actual_ty, expected_ty) => ConfigurationError::InvalidFieldType {
878            field: e.path.join("."),
879            expected_ty,
880            actual_ty: actual_ty.to_string(),
881        },
882        _ => ConfigurationError::Generic { source: e.into() },
883    }
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    #[tokio::test]
891    async fn test_static_configuration() {
892        let (cfg, _) = ConfigurationLoader::for_tests(
893            Some(serde_json::json!({
894                "foo": "bar",
895                "baz": 5,
896                "foobar": { "a": false, "b": "c" }
897            })),
898            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
899            false,
900        )
901        .await;
902        cfg.ready().await;
903
904        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
905        assert_eq!(cfg.get_typed::<i32>("baz").unwrap(), 5);
906        assert!(!cfg.get_typed::<bool>("foobar.a").unwrap());
907        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
908        assert!(matches!(
909            cfg.get::<String>("nonexistentKey"),
910            Err(ConfigurationError::MissingField { .. })
911        ));
912    }
913
914    #[tokio::test]
915    async fn test_dynamic_configuration() {
916        let (cfg, sender) = ConfigurationLoader::for_tests(
917            Some(serde_json::json!({
918                "foo": "bar",
919                "baz": 5,
920                "foobar": { "a": false, "b": "c" }
921            })),
922            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
923            true,
924        )
925        .await;
926        let sender = sender.expect("sender should exist");
927        sender
928            .send(ConfigUpdate::Snapshot(serde_json::json!({
929                "new": "from_snapshot",
930            })))
931            .await
932            .unwrap();
933
934        cfg.ready().await;
935
936        // Test that existing values still exist.
937        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
938
939        // Test that new values from the snapshot exist.
940        assert_eq!(cfg.get_typed::<String>("new").unwrap(), "from_snapshot");
941
942        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
943
944        sender
945            .send(ConfigUpdate::Partial {
946                key: "new_key".to_string(),
947                value: "from dynamic update".to_string().into(),
948            })
949            .await
950            .unwrap();
951
952        tokio::time::timeout(std::time::Duration::from_secs(2), async {
953            loop {
954                match rx.recv().await {
955                    Ok(ev) if ev.key == "new_key" => break ev,
956                    Err(e) => panic!("updates channel closed: {e}"),
957                    Ok(_) => continue,
958                }
959            }
960        })
961        .await
962        .expect("timed out waiting for new_key update");
963
964        assert_eq!(cfg.get_typed::<String>("new_key").unwrap(), "from dynamic update");
965
966        // Test that an update with a nested key is applied.
967        sender
968            .send(ConfigUpdate::Partial {
969                key: "foobar.a".to_string(),
970                value: serde_json::json!(true),
971            })
972            .await
973            .unwrap();
974
975        tokio::time::timeout(std::time::Duration::from_secs(2), async {
976            loop {
977                match rx.recv().await {
978                    Ok(ev) if ev.key == "foobar.a" => break ev,
979                    Err(e) => panic!("updates channel closed: {e}"),
980                    Ok(_) => continue,
981                }
982            }
983        })
984        .await
985        .expect("timed out waiting for foobar.a update");
986
987        assert!(cfg.get_typed::<bool>("foobar.a").unwrap());
988        assert_eq!(cfg.get_typed::<String>("foobar.b").unwrap(), "c");
989    }
990
991    #[test]
992    fn update_events_are_sent_after_the_figment_map_is_updated() {
993        fn recv_with_timeout(
994            rx: &mut tokio::sync::broadcast::Receiver<ConfigChangeEvent>, timeout: std::time::Duration,
995        ) -> Option<ConfigChangeEvent> {
996            let deadline = std::time::Instant::now() + timeout;
997            loop {
998                match rx.try_recv() {
999                    Ok(event) => return Some(event),
1000                    Err(tokio::sync::broadcast::error::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
1001                        std::thread::sleep(std::time::Duration::from_millis(1));
1002                    }
1003                    Err(tokio::sync::broadcast::error::TryRecvError::Empty) => return None,
1004                    Err(e) => panic!("updates channel failed: {e}"),
1005                }
1006            }
1007        }
1008
1009        let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
1010        let (cfg, sender) = runtime.block_on(async {
1011            let (cfg, sender) = ConfigurationLoader::for_tests(None, None, true).await;
1012            let sender = sender.expect("sender should exist");
1013            sender
1014                .send(ConfigUpdate::Snapshot(serde_json::json!({ "observed": "old" })))
1015                .await
1016                .unwrap();
1017            cfg.ready().await;
1018            (cfg, sender)
1019        });
1020
1021        let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1022        let (started_tx, started_rx) = std::sync::mpsc::channel();
1023        let runtime_thread = std::thread::spawn(move || {
1024            runtime.block_on(async {
1025                started_tx.send(()).unwrap();
1026                let _ = stop_rx.await;
1027            });
1028        });
1029        started_rx.recv().unwrap();
1030
1031        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1032        let figment_guard = cfg.inner.figment.read().unwrap();
1033
1034        sender
1035            .blocking_send(ConfigUpdate::Partial {
1036                key: "observed".to_string(),
1037                value: serde_json::json!("new"),
1038            })
1039            .unwrap();
1040
1041        let early_event = recv_with_timeout(&mut rx, std::time::Duration::from_millis(100));
1042        assert!(
1043            early_event.is_none(),
1044            "update event arrived before the figment map changed"
1045        );
1046
1047        drop(figment_guard);
1048
1049        let event = recv_with_timeout(&mut rx, std::time::Duration::from_secs(2))
1050            .expect("timed out waiting for observed update");
1051        assert_eq!(event.key, "observed");
1052        assert_eq!(cfg.get_typed::<String>("observed").unwrap(), "new");
1053
1054        let _ = stop_tx.send(());
1055        runtime_thread.join().unwrap();
1056    }
1057
1058    #[tokio::test]
1059    async fn test_environment_precedence_over_dynamic() {
1060        let (cfg, sender) = ConfigurationLoader::for_tests(
1061            Some(serde_json::json!({
1062                "foo": "bar",
1063                "baz": 5,
1064                "foobar": { "a": false, "b": "c" }
1065            })),
1066            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
1067            true,
1068        )
1069        .await;
1070        let sender = sender.expect("sender should exist");
1071
1072        sender
1073            .send(ConfigUpdate::Snapshot(serde_json::json!({
1074                "env_var": "from_snapshot_env_var"
1075            })))
1076            .await
1077            .unwrap();
1078
1079        cfg.ready().await;
1080
1081        // Env provider has highest precedence so the snapshot should not override it.
1082        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1083
1084        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1085
1086        // Send a partial update that attempts to override the env-backed key.
1087        sender
1088            .send(ConfigUpdate::Partial {
1089                key: "env_var".to_string(),
1090                value: serde_json::json!("from_partial"),
1091            })
1092            .await
1093            .unwrap();
1094
1095        // Also attempt to override the nested env-backed key via dynamic.
1096        sender
1097            .send(ConfigUpdate::Partial {
1098                key: "foobar.a".to_string(),
1099                value: serde_json::json!(false),
1100            })
1101            .await
1102            .unwrap();
1103
1104        // Send a dummy partial update to ensure the updater has processed prior partials.
1105        sender
1106            .send(ConfigUpdate::Partial {
1107                key: "dummy".to_string(),
1108                value: serde_json::json!(1),
1109            })
1110            .await
1111            .unwrap();
1112
1113        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1114            loop {
1115                match rx.recv().await {
1116                    Ok(ev) if ev.key == "dummy" => break,
1117                    Err(e) => panic!("updates channel closed: {e}"),
1118                    Ok(_) => continue,
1119                }
1120            }
1121        })
1122        .await
1123        .expect("timed out waiting for sync marker");
1124
1125        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1126    }
1127
1128    #[tokio::test]
1129    async fn test_dynamic_configuration_add_new_nested_key() {
1130        let (cfg, sender) = ConfigurationLoader::for_tests(
1131            Some(serde_json::json!({
1132                "foo": "bar",
1133                "baz": 5,
1134                "foobar": { "a": false, "b": "c" }
1135            })),
1136            None,
1137            true,
1138        )
1139        .await;
1140        let sender = sender.expect("sender should exist");
1141
1142        sender
1143            .send(ConfigUpdate::Snapshot(serde_json::json!({})))
1144            .await
1145            .unwrap();
1146        cfg.ready().await;
1147
1148        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1149
1150        sender
1151            .send(ConfigUpdate::Partial {
1152                key: "new_parent.new_child".to_string(),
1153                value: serde_json::json!(42),
1154            })
1155            .await
1156            .unwrap();
1157
1158        // new_parent object did not exist before, so the diff will emit the object "new_parent"
1159        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1160            loop {
1161                match rx.recv().await {
1162                    Ok(ev) if ev.key == "new_parent" => break ev,
1163                    Err(e) => panic!("updates channel closed: {e}"),
1164                    Ok(_) => continue,
1165                }
1166            }
1167        })
1168        .await
1169        .expect("timed out waiting for new_parent.new_child update");
1170
1171        assert_eq!(cfg.get_typed::<i32>("new_parent.new_child").unwrap(), 42);
1172    }
1173
1174    #[tokio::test]
1175    async fn test_underscore_fallback_on_get() {
1176        let (cfg, _) = ConfigurationLoader::for_tests(
1177            Some(serde_json::json!({})),
1178            Some(&[("RANDOM_KEY".to_string(), "from_env_only".to_string())]),
1179            false,
1180        )
1181        .await;
1182        cfg.ready().await;
1183
1184        assert_eq!(cfg.get_typed::<String>("random.key").unwrap(), "from_env_only");
1185    }
1186
1187    #[tokio::test]
1188    async fn test_underscore_fallback_on_get_multi_segment_key() {
1189        // A single-underscore Agent-style env var (e.g. `DD_DATA_PLANE_API_LISTEN_ADDRESS`, which
1190        // `for_tests` simulates with the `TEST_` prefix) produces a flat figment key. A deeply
1191        // nested `get`/`try_get_typed` query must still resolve it via the dot-to-underscore
1192        // fallback, so callers don't need double-underscore env vars for these keys.
1193        let (cfg, _) = ConfigurationLoader::for_tests(
1194            Some(serde_json::json!({})),
1195            Some(&[(
1196                "DATA_PLANE_API_LISTEN_ADDRESS".to_string(),
1197                "tcp://0.0.0.0:55100".to_string(),
1198            )]),
1199            false,
1200        )
1201        .await;
1202        cfg.ready().await;
1203
1204        assert_eq!(
1205            cfg.try_get_typed::<String>("data_plane.api_listen_address").unwrap(),
1206            Some("tcp://0.0.0.0:55100".to_string()),
1207        );
1208    }
1209
1210    #[tokio::test]
1211    async fn test_static_configuration_ready_and_subscribe() {
1212        let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, false).await;
1213        assert!(maybe_sender.is_none());
1214
1215        tokio::time::timeout(std::time::Duration::from_millis(500), cfg.ready())
1216            .await
1217            .expect("ready() should not block when dynamic is disabled");
1218
1219        assert!(cfg.subscribe_for_updates().is_none());
1220    }
1221
1222    #[tokio::test]
1223    async fn test_dynamic_configuration_ready_requires_initial_snapshot() {
1224        // Enable dynamic but do not send the initial snapshot.
1225        let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, true).await;
1226        assert!(maybe_sender.is_some());
1227
1228        // ready() should not resolve until the initial snapshot is processed.
1229        let res = tokio::time::timeout(std::time::Duration::from_millis(1000), cfg.ready()).await;
1230        assert!(res.is_err(), "ready() should time out without an initial snapshot");
1231    }
1232
1233    #[tokio::test]
1234    async fn test_flattened_keys_flat_and_nested() {
1235        let (cfg, _) = ConfigurationLoader::for_tests(
1236            Some(serde_json::json!({
1237                "top": "value",
1238                "nested": { "a": 1, "b": { "c": true } }
1239            })),
1240            None,
1241            false,
1242        )
1243        .await;
1244        cfg.ready().await;
1245
1246        let pairs = cfg.flattened_keys().unwrap();
1247        let map: std::collections::HashMap<&str, &serde_json::Value> =
1248            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1249
1250        assert_eq!(map.get("top"), Some(&&serde_json::json!("value")));
1251        assert_eq!(map.get("nested.a"), Some(&&serde_json::json!(1)));
1252        assert_eq!(map.get("nested.b.c"), Some(&&serde_json::json!(true)));
1253        assert!(!map.contains_key("nested"));
1254        assert!(!map.contains_key("nested.b"));
1255    }
1256
1257    #[tokio::test]
1258    async fn test_flattened_keys_arrays_are_leaves() {
1259        let (cfg, _) = ConfigurationLoader::for_tests(
1260            Some(serde_json::json!({
1261                "tags": ["a", "b"],
1262                "matrix": [[1, 2], [3, 4]]
1263            })),
1264            None,
1265            false,
1266        )
1267        .await;
1268        cfg.ready().await;
1269
1270        let pairs = cfg.flattened_keys().unwrap();
1271        let map: std::collections::HashMap<&str, &serde_json::Value> =
1272            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1273
1274        assert_eq!(map.get("tags"), Some(&&serde_json::json!(["a", "b"])));
1275        assert_eq!(map.get("matrix"), Some(&&serde_json::json!([[1, 2], [3, 4]])));
1276    }
1277
1278    #[tokio::test]
1279    async fn test_flattened_keys_null_values_absent() {
1280        let (cfg, _) = ConfigurationLoader::for_tests(
1281            Some(serde_json::json!({
1282                "present": "yes",
1283                "absent": null
1284            })),
1285            None,
1286            false,
1287        )
1288        .await;
1289        cfg.ready().await;
1290
1291        let pairs = cfg.flattened_keys().unwrap();
1292        let map: std::collections::HashMap<&str, &serde_json::Value> =
1293            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1294
1295        assert_eq!(map.get("present"), Some(&&serde_json::json!("yes")));
1296        // Figment drops null values during deserialization, so they are absent from the output.
1297        assert!(!map.contains_key("absent"));
1298    }
1299}