agent_data_plane_config_system/
compatibility.rs

1//! Compatibility checks against the merged configuration sources.
2//!
3//! The typed model is the Agent schema pruned to the keys ADP supports, so the unsupported keys this
4//! check exists to catch are absent from it by construction. It reads the by-key view of the merged
5//! sources instead, which holds every key any input supplied along with the provenance of each.
6
7use std::collections::HashSet;
8
9use agent_data_plane_config::Provenance;
10use datadog_agent_config::classifier::{ConfigClassifier, Pipeline, PipelineAffinity, Severity, SupportLevel};
11use saluki_error::{generic_error, GenericError};
12use tracing::{debug, error, trace, warn};
13
14use crate::ConfigurationSystem;
15
16impl ConfigurationSystem {
17    /// Checks settings that an input set explicitly and that affect active pipelines, logging the
18    /// severity of each.
19    ///
20    /// # Errors
21    ///
22    /// Returns an error if high-severity incompatibilities exist. All keys are checked before
23    /// returning, so the error includes the total count.
24    pub fn check_compatibility(&self, active_pipelines: &HashSet<Pipeline>) -> Result<(), GenericError> {
25        let classifier = ConfigClassifier::new();
26        let mut high_severity_incompatibilities = 0u32;
27        debug!("Analyzing configuration.");
28        for (key, value, provenance) in self.sources().flattened_keys() {
29            let Some(classification) = classifier.classify(&key, value) else {
30                continue;
31            };
32
33            let pipeline_is_active = match &classification.pipeline_affinity {
34                PipelineAffinity::Pipelines(affected) => affected.iter().any(|p| active_pipelines.contains(p)),
35                PipelineAffinity::CrossCutting => true,
36            };
37            if !pipeline_is_active {
38                continue;
39            }
40
41            // A producer publishes every key it knows about, the ones nobody configured included, so
42            // only a key an input set explicitly says anything about what the operator asked for.
43            if provenance == Provenance::Default {
44                trace!(key = %key, "Configuration key is not set by any input.");
45                continue;
46            }
47
48            match classification.support_level {
49                SupportLevel::Incompatible(Severity::Low) => {
50                    debug!("Low-severity incompatible key detected. Proceeding.")
51                }
52                SupportLevel::Partial => {
53                    warn!(key = %key, "Partially supported configuration key. See documentation for details. Proceeding.")
54                }
55                SupportLevel::Incompatible(Severity::Medium) => {
56                    warn!(key = %key, "Unsupported configuration key. Proceeding.")
57                }
58                SupportLevel::Incompatible(Severity::High) => {
59                    error!(key = %key, "Unsupported configuration key with non-default value. ADP cannot run safely with \
60                    this setting.");
61                    high_severity_incompatibilities += 1;
62                }
63                SupportLevel::Ignored | SupportLevel::Unrecognized => {
64                    trace!(key = %key, "Configuration key not-applicable. Silently ignoring.")
65                }
66            }
67        }
68
69        if high_severity_incompatibilities > 0 {
70            return Err(generic_error!(
71                "{high_severity_incompatibilities} incompatible configuration detected. ADP cannot start. Review error \
72                logs for details."
73            ));
74        }
75
76        Ok(())
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::collections::HashSet;
83
84    use datadog_agent_config::classifier::Pipeline;
85    use saluki_config::dynamic::{ConfigSetting, Provenance as StreamProvenance};
86    use serde_json::{json, Value};
87
88    use crate::system::translate_strict;
89    use crate::{source::SourceTree, ConfigurationSystem};
90
91    /// Builds a system whose sources are a local configuration file, so every key present was set
92    /// explicitly.
93    fn system_with(file: Value) -> ConfigurationSystem {
94        system_from(SourceTree::all_explicit(file))
95    }
96
97    /// Builds a system whose sources are the settings a configuration producer published, each
98    /// carrying its own provenance.
99    fn system_from_settings(settings: &[(&str, Value, StreamProvenance)]) -> ConfigurationSystem {
100        let settings: Vec<_> = settings
101            .iter()
102            .map(|(key, value, provenance)| ConfigSetting::new(*key, value.clone(), *provenance))
103            .collect();
104
105        system_from(SourceTree::from_settings(&settings))
106    }
107
108    fn system_from(sources: SourceTree) -> ConfigurationSystem {
109        let config = translate_strict(&sources).expect("sources translate");
110        ConfigurationSystem::standalone(config, sources)
111    }
112
113    fn pipelines(active: &[Pipeline]) -> HashSet<Pipeline> {
114        active.iter().copied().collect()
115    }
116
117    fn otlp_tls_settings(cert_pem: &str, key_pem: &str) -> Value {
118        json!({
119            "otlp_config": {
120                "receiver": {
121                    "protocols": {
122                        "http": {
123                            "tls": { "cert_pem": cert_pem, "key_pem": key_pem }
124                        }
125                    }
126                }
127            }
128        })
129    }
130
131    #[test]
132    fn high_severity_keys_fail_the_check_and_are_all_counted() {
133        let system = system_with(otlp_tls_settings("/etc/adp/cert.pem", "/etc/adp/key.pem"));
134
135        let error = system
136            .check_compatibility(&pipelines(&[Pipeline::Otlp]))
137            .expect_err("a high-severity incompatible key should fail the check");
138
139        assert!(error.to_string().contains("2 incompatible configuration detected"));
140    }
141
142    #[test]
143    fn a_high_severity_key_nobody_set_is_skipped() {
144        // The Agent publishes every key it knows about, so a key it reports at its own default is a
145        // key nobody configured, whatever value it holds.
146        let system = system_from_settings(&[
147            (
148                "otlp_config.receiver.protocols.http.tls.cert_pem",
149                json!("/etc/adp/cert.pem"),
150                StreamProvenance::Default,
151            ),
152            (
153                "otlp_config.receiver.protocols.http.tls.key_pem",
154                json!("/etc/adp/key.pem"),
155                StreamProvenance::Default,
156            ),
157        ]);
158
159        system
160            .check_compatibility(&pipelines(&[Pipeline::Otlp]))
161            .expect("a key nobody set is not an incompatibility");
162    }
163
164    #[test]
165    fn a_high_severity_key_set_to_its_default_value_is_still_checked() {
166        // Writing an unsupported key is a request ADP cannot honor, so it is reported even when the
167        // value written happens to be the one the schema would have supplied.
168        let system = system_with(otlp_tls_settings("", ""));
169
170        let error = system
171            .check_compatibility(&pipelines(&[Pipeline::Otlp]))
172            .expect_err("an explicitly set key should fail the check");
173
174        assert!(error.to_string().contains("2 incompatible configuration detected"));
175    }
176
177    #[test]
178    fn a_high_severity_key_affecting_no_active_pipeline_is_skipped() {
179        let system = system_with(otlp_tls_settings("/etc/adp/cert.pem", "/etc/adp/key.pem"));
180
181        system
182            .check_compatibility(&pipelines(&[Pipeline::DogStatsD]))
183            .expect("an inactive pipeline's keys are not incompatibilities");
184    }
185
186    #[test]
187    fn lower_severity_keys_pass_and_cross_cutting_keys_ignore_active_pipelines() {
188        let system = system_with(json!({ "dogstatsd_queue_size": 2048, "min_tls_version": "tlsv1.3" }));
189        system
190            .check_compatibility(&pipelines(&[Pipeline::DogStatsD]))
191            .expect("only high-severity incompatibilities fail the check");
192
193        let cross_cutting = system_with(json!({ "heroku_dyno": true }));
194        cross_cutting
195            .check_compatibility(&pipelines(&[]))
196            .expect_err("a cross-cutting high-severity key fails the check with no pipeline active");
197    }
198
199    #[test]
200    fn keys_the_registry_does_not_know_are_ignored() {
201        let system = system_with(json!({ "not_a_real_agent_setting": true, "dogstatsd_port": 9125 }));
202
203        system
204            .check_compatibility(&pipelines(&[Pipeline::DogStatsD]))
205            .expect("unclassified keys are not incompatibilities");
206    }
207}