Skip to main content

datadog_agent_config_overlay_model/
lib.rs

1//! Deserialization types for `schema_overlay.yaml`.
2//!
3//! This crate provides the model for management of our inventory and metadata around Datadog Agent
4//! configuration. It is designed to be used during build processes by `build.rs` and, for
5//! simplicity, should not depend on any other crates from our workspace.
6//!
7//! The overlay is validated in two passes. First, standard serde deserialization enforces its
8//! type integrity, then custom validation logic runs. This file is the source of truth on what
9//! can and can-not be present in the overlay.
10
11pub mod saluki_keys;
12pub mod schema_gen;
13pub mod smoke_test_support;
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18use indexmap::{IndexMap, IndexSet};
19use serde::Deserialize;
20use serde_json::{Map, Value};
21
22use crate::smoke_test_support::ConfigurationStruct;
23
24/// Top-level overlay structure.
25///
26/// `known` covers every key the team has reviewed and classified. `ignored` covers keys irrelevant
27/// to ADP's domain. Together they must account for every key in `core_schema.yaml`.
28#[derive(Debug, Clone, Deserialize)]
29pub struct SchemaOverlay {
30    pub inventory: IndexMap<String, KnownEntry>,
31    pub excluded: IndexMap<String, String>,
32}
33
34/// Classification of a known (non-ignored) config key.
35#[derive(Debug, Clone, Deserialize)]
36#[serde(tag = "support", rename_all = "snake_case")]
37pub enum KnownEntry {
38    /// ADP reads and fully supports this key; behavior matches the core Agent.
39    Full(FullSupport),
40    /// ADP reads this key but behavior diverges from the core Agent in some cases.
41    Partial(PartialSupport),
42    /// ADP does not support this key.
43    #[serde(rename = "none")]
44    Unsupported(Unsupported),
45    /// ADP's compatibility with this key has not yet been determined.
46    Unknown(UnknownSupport),
47}
48
49/// Metadata for a fully supported configuration key.
50#[derive(Debug, Clone, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub struct FullSupport {
53    /// Which pipelines depend on this key (non-empty).
54    pub pipelines: PipelineAffinity,
55    /// Short description for documentation tables (<= 50 chars).
56    pub description: String,
57    /// Extended documentation (appears in generated docs).
58    #[serde(default)]
59    pub documentation: Option<String>,
60    /// GitHub issue tracking number.
61    #[serde(default)]
62    pub issue: Option<String>,
63    /// Fields to support the `config_registry` and configuration smoke tests.
64    pub test_support: TestSupport,
65}
66
67/// Metadata for a partially supported configuration key.
68#[derive(Debug, Clone, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub struct PartialSupport {
71    /// Which pipelines depend on this key (non-empty).
72    pub pipelines: PipelineAffinity,
73    /// Short description for documentation tables (<= 50 chars).
74    pub description: String,
75    /// Extended documentation explaining the behavioral divergence. Required for partial keys.
76    pub documentation: String,
77    /// When true, the runtime classifier emits a warning for non-default values of this key.
78    #[serde(default)]
79    pub warn: bool,
80    /// GitHub issue tracking number.
81    #[serde(default)]
82    pub issue: Option<String>,
83    /// Fields to support the `config_registry` and configuration smoke tests.
84    pub test_support: TestSupport,
85}
86
87/// Metadata for an unsupported configuration key.
88#[derive(Debug, Clone, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub struct Unsupported {
91    /// Pipelines affected by the lack of support.
92    pub pipelines: PipelineAffinity,
93    /// Short description for documentation tables (<= 50 chars).
94    pub description: String,
95    /// Longer explanation of why it is unsupported and future plans.
96    #[serde(default)]
97    pub documentation: Option<String>,
98    /// How severe the lack of support is.
99    pub severity: Severity,
100    /// Whether support is planned. When true, `issue` must be present.
101    pub planned: bool,
102    /// GitHub issue tracking number.
103    #[serde(default)]
104    pub issue: Option<String>,
105}
106
107/// Metadata for a key whose support level has not yet been determined.
108#[derive(Debug, Clone, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub struct UnknownSupport {
111    /// Short description for documentation tables (<= 50 chars), if known.
112    #[serde(default)]
113    pub description: Option<String>,
114    /// Severity estimate, if there is intuition about the impact.
115    #[serde(default)]
116    pub severity: Option<Severity>,
117    /// GitHub issue tracking the investigation.
118    #[serde(default)]
119    pub issue: Option<String>,
120}
121
122/// Metadata to support config smoke tests.
123///
124/// These fields support logic in the configuration smoke tests and are tightly bound to the
125/// behavior of the test logic. They may change if the test methodology changes.
126#[derive(Debug, Clone, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub struct TestSupport {
129    /// Environment variable overrides for this key. Checked by configuration smoke tests.
130    #[serde(default)]
131    pub env_var_override: Option<Vec<String>>,
132    /// Alias YAML paths that map to the same config key. Checked by configuration smoke tests.
133    #[serde(default)]
134    pub additional_yaml_paths: Vec<String>,
135    /// Override the type inferred from the schema.
136    #[serde(default)]
137    pub value_type_override: Option<ValueType>,
138    /// Configuration consumers that incorporate this key (non-empty).
139    pub used_by: IndexSet<ConfigurationStruct>,
140    /// Literal JSON value for smoke test injection.
141    #[serde(default)]
142    pub test_json: Option<String>,
143    /// TRANSITIONAL BANDAID. Carries metadata needed only to reproduce the hand-written
144    /// registry (filename partitioning, Saluki-only schema source/default). Delete with it.
145    #[serde(default)]
146    pub additional_attributes: IndexMap<String, String>,
147}
148
149/// Impact severity of an unsupported or unknown key.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum Severity {
153    Low,
154    Medium,
155    High,
156}
157
158/// A single pipeline in the ADP vocabulary.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Pipeline {
162    #[serde(rename = "dogstatsd")]
163    DogStatsD,
164    Checks,
165    Otlp,
166    Traces,
167}
168
169/// Which pipelines a config key is associated with.
170///
171/// Deserialized from a flat YAML list of pipeline tokens. An empty list is rejected. A list
172/// containing only `cross_cutting` folds to [`PipelineAffinity::CrossCutting`]; `cross_cutting`
173/// may not appear alongside other tokens.
174#[derive(Debug, Clone)]
175pub enum PipelineAffinity {
176    /// The key affects all pipelines / ADP behaviour as a whole.
177    CrossCutting,
178    /// The key affects the listed pipelines (non-empty, in declaration order).
179    Pipelines(Vec<Pipeline>),
180}
181
182impl<'de> serde::Deserialize<'de> for PipelineAffinity {
183    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
184        #[derive(Deserialize, PartialEq)]
185        #[serde(rename_all = "snake_case")]
186        enum Token {
187            CrossCutting,
188            #[serde(rename = "dogstatsd")]
189            DogStatsD,
190            Checks,
191            Otlp,
192            Traces,
193        }
194
195        let tokens: Vec<Token> = Vec::deserialize(d)?;
196
197        if tokens.is_empty() {
198            return Err(serde::de::Error::custom("pipelines must be non-empty"));
199        }
200
201        let has_cc = tokens.iter().any(|t| t == &Token::CrossCutting);
202
203        if has_cc && tokens.len() > 1 {
204            return Err(serde::de::Error::custom(
205                "cross_cutting must appear alone in pipelines list",
206            ));
207        }
208
209        if has_cc {
210            return Ok(PipelineAffinity::CrossCutting);
211        }
212
213        let pipelines = tokens
214            .into_iter()
215            .map(|t| match t {
216                Token::DogStatsD => Pipeline::DogStatsD,
217                Token::Checks => Pipeline::Checks,
218                Token::Otlp => Pipeline::Otlp,
219                Token::Traces => Pipeline::Traces,
220                Token::CrossCutting => unreachable!(),
221            })
222            .collect();
223
224        Ok(PipelineAffinity::Pipelines(pipelines))
225    }
226}
227
228/// Override type for when the schema under-specifies a key's value type.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ValueType {
232    Boolean,
233    Integer,
234    Float,
235    String,
236    StringList,
237}
238
239/// File paths to the two YAML files required as input by this library.
240///
241/// Defaults to the canonical location of the required schema files in this library.
242pub struct Files {
243    /// The Datadog Agent core schema file (`schema/core/core_schema.yaml`).
244    pub datadog_schema: PathBuf,
245    /// Directory containing the vendored OTel receiver schema (`schema/otel/`).
246    pub otel_schema_dir: PathBuf,
247    /// The schema overlay (`schema/schema_overlay.yaml`).
248    pub overlay: PathBuf,
249}
250
251impl Default for Files {
252    fn default() -> Self {
253        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
254            .join("..")
255            .join("config")
256            .join("schema");
257        Files {
258            datadog_schema: dir.join("core").join("core_schema.yaml"),
259            otel_schema_dir: dir.join("otel"),
260            overlay: dir.join("schema_overlay.yaml"),
261        }
262    }
263}
264
265/// A top-level overlay section as seen by [`SchemaOverlay::lint_yaml`]: only the key order of
266/// mapping sections matters, so values are skipped.
267#[derive(Deserialize)]
268#[serde(untagged)]
269enum LintSection {
270    Mapping(IndexMap<String, serde::de::IgnoredAny>),
271    Other(#[allow(dead_code)] serde::de::IgnoredAny),
272}
273
274impl SchemaOverlay {
275    pub fn load(files: Files) -> Result<Self, Error> {
276        let loaded = Self::from_file(&files.overlay)?;
277        loaded.validate(&files.datadog_schema, &files.otel_schema_dir)?;
278        Ok(loaded)
279    }
280
281    fn from_yaml(s: &str) -> Result<Self, Error> {
282        // Linting needs the keys in file order, so parse into insertion-ordered maps first.
283        let sections: IndexMap<String, LintSection> = serde_saphyr::from_str(s).map_err(Error::Yaml)?;
284        Self::lint_yaml(&sections)?;
285        serde_saphyr::from_str(s).map_err(Error::Yaml)
286    }
287
288    fn from_file(path: &Path) -> Result<Self, Error> {
289        let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
290        Self::from_yaml(&contents)
291    }
292
293    fn validate(&self, datadog_schema: &Path, otel_schema_dir: &Path) -> Result<(), Error> {
294        self.validate_keys_match(datadog_schema, otel_schema_dir)?;
295        self.validate_entries()?;
296        Ok(())
297    }
298
299    /// Ensure that sections appear in the required order and that keys within each section are
300    /// sorted alphabetically.
301    fn lint_yaml(sections: &IndexMap<String, LintSection>) -> Result<(), Error> {
302        let section_names: Vec<&str> = sections.keys().map(String::as_str).collect();
303
304        for required in ["inventory", "excluded"] {
305            if !section_names.contains(&required) {
306                return Err(Error::Validation(format!(
307                    "overlay missing required section '{}'",
308                    required
309                )));
310            }
311        }
312
313        let pos_known = section_names.iter().position(|&k| k == "inventory").unwrap();
314        let pos_ignored = section_names.iter().position(|&k| k == "excluded").unwrap();
315
316        if pos_known >= pos_ignored {
317            return Err(Error::Validation(
318                "sections must appear in order: known, ignored".to_string(),
319            ));
320        }
321
322        for section_name in ["inventory", "excluded"] {
323            if let Some(LintSection::Mapping(section)) = sections.get(section_name) {
324                let mut prev = "";
325                for key in section.keys().map(String::as_str) {
326                    if key < prev {
327                        return Err(Error::Validation(format!(
328                            "{}: key '{}' is out of alphabetical order (after '{}')",
329                            section_name, key, prev
330                        )));
331                    }
332                    prev = key;
333                }
334            }
335        }
336
337        Ok(())
338    }
339
340    /// Ensure that each core schema key appears exactly once across the overlay sections.
341    fn validate_keys_match(&self, datadog_schema: &Path, otel_schema_dir: &Path) -> Result<(), Error> {
342        let schema_keys = Self::schema_keys(datadog_schema, otel_schema_dir)?;
343
344        for key in self.excluded.keys() {
345            if self.inventory.contains_key(key.as_str()) {
346                return Err(Error::Validation(format!(
347                    "key '{}' appears in more than one overlay section",
348                    key
349                )));
350            }
351        }
352
353        for key in self.inventory.keys().chain(self.excluded.keys()) {
354            if !schema_keys.contains(key.as_str()) {
355                return Err(Error::Validation(format!(
356                    "overlay key '{}' is not present in the schema",
357                    key
358                )));
359            }
360        }
361
362        let overlay_keys: HashSet<&str> = self
363            .inventory
364            .keys()
365            .chain(self.excluded.keys())
366            .map(|s| s.as_str())
367            .collect();
368        for key in &schema_keys {
369            if !overlay_keys.contains(key.as_str()) {
370                return Err(Error::Validation(format!(
371                    "schema key '{}' is not covered by the overlay",
372                    key
373                )));
374            }
375        }
376
377        Ok(())
378    }
379
380    fn schema_keys(datadog_schema: &Path, otel_schema_dir: &Path) -> Result<HashSet<String>, Error> {
381        let schema = load_composed_schema(datadog_schema, otel_schema_dir)?;
382        let props = schema
383            .get("properties")
384            .and_then(|v| v.as_object())
385            .ok_or_else(|| Error::Validation("schema missing 'properties' section".to_string()))?;
386        let mut keys = HashSet::new();
387        Self::collect_schema_keys(props, "", &mut keys);
388        Ok(keys)
389    }
390
391    fn collect_schema_keys(props: &Map<String, Value>, prefix: &str, keys: &mut HashSet<String>) {
392        for (name, v) in props {
393            let full_key = if prefix.is_empty() {
394                name.to_string()
395            } else {
396                format!("{}.{}", prefix, name)
397            };
398            // `$ref`s have already been inlined by `load_resolved_schema`, so a node either
399            // carries `properties` (recurse) or is a leaf key.
400            if let Some(sub_props) = v.get("properties").and_then(|p| p.as_object()) {
401                Self::collect_schema_keys(sub_props, &full_key, keys);
402            } else {
403                keys.insert(full_key);
404            }
405        }
406    }
407
408    /// Validate per-entry constraints: description length, `used_by` non-empty, no duplicate
409    /// `additional_yaml_paths`, and planned+issue consistency for unsupported entries.
410    fn validate_entries(&self) -> Result<(), Error> {
411        let canonical_keys: HashSet<&str> = self.inventory.keys().map(String::as_str).collect();
412
413        for (key, entry) in &self.inventory {
414            match entry {
415                KnownEntry::Full(f) => {
416                    if f.test_support.used_by.is_empty() {
417                        return Err(Error::Validation(format!(
418                            "full key '{}': used_by must be non-empty",
419                            key
420                        )));
421                    }
422                    if f.description.len() > 50 {
423                        return Err(Error::Validation(format!(
424                            "full key '{}': description exceeds 50 chars ({} chars)",
425                            key,
426                            f.description.len()
427                        )));
428                    }
429                    Self::validate_additional_yaml_paths(key, &f.test_support.additional_yaml_paths, &canonical_keys)?;
430                }
431                KnownEntry::Partial(p) => {
432                    if p.test_support.used_by.is_empty() {
433                        return Err(Error::Validation(format!(
434                            "partial key '{}': used_by must be non-empty",
435                            key
436                        )));
437                    }
438                    if p.description.len() > 50 {
439                        return Err(Error::Validation(format!(
440                            "partial key '{}': description exceeds 50 chars ({} chars)",
441                            key,
442                            p.description.len()
443                        )));
444                    }
445                    Self::validate_additional_yaml_paths(key, &p.test_support.additional_yaml_paths, &canonical_keys)?;
446                }
447                KnownEntry::Unsupported(u) => {
448                    if u.description.len() > 50 {
449                        return Err(Error::Validation(format!(
450                            "unsupported key '{}': description exceeds 50 chars ({} chars)",
451                            key,
452                            u.description.len()
453                        )));
454                    }
455                    if u.planned && u.issue.is_none() {
456                        return Err(Error::Validation(format!(
457                            "unsupported key '{}': planned requires an issue",
458                            key
459                        )));
460                    }
461                }
462                KnownEntry::Unknown(u) => {
463                    if let Some(desc) = &u.description {
464                        if desc.len() > 50 {
465                            return Err(Error::Validation(format!(
466                                "unknown key '{}': description exceeds 50 chars ({} chars)",
467                                key,
468                                desc.len()
469                            )));
470                        }
471                    }
472                }
473            }
474        }
475        Ok(())
476    }
477
478    fn validate_additional_yaml_paths(
479        key: &str, paths: &[String], canonical_keys: &HashSet<&str>,
480    ) -> Result<(), Error> {
481        let mut seen: HashSet<&str> = HashSet::new();
482        for path in paths {
483            if !seen.insert(path.as_str()) {
484                return Err(Error::Validation(format!(
485                    "key '{}': duplicate additional_yaml_path '{}'",
486                    key, path
487                )));
488            }
489            if path.contains('.') {
490                return Err(Error::Validation(format!(
491                    "key '{}': additional_yaml_path '{}' contains a dot. \
492                     Dotted aliases land at a different nesting depth in the YAML tree, \
493                     which cannot be represented as a serde field alias on the generated \
494                     struct. Supporting dotted aliases requires a post-deserialization \
495                     merge step or custom Deserialize impl.",
496                    key, path
497                )));
498            }
499            if canonical_keys.contains(path.as_str()) {
500                return Err(Error::Validation(format!(
501                    "key '{}': additional_yaml_path '{}' collides with a canonical \
502                     overlay key. Two fields would deserialize from the same YAML key.",
503                    key, path
504                )));
505            }
506        }
507        Ok(())
508    }
509}
510
511/// Read a YAML file into a [`Value`], mapping failures onto [`Error`].
512fn read_yaml(path: &Path) -> Result<Value, Error> {
513    let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
514    serde_saphyr::from_str(&contents).map_err(Error::Yaml)
515}
516
517/// Load the core schema and recursively inline every `$ref: <file>` reference into a single
518/// resolved document with no remaining `$ref` nodes.
519///
520/// Referenced files are resolved relative to the directory containing `schema_path`. This is the
521/// one place that reads subsystem schema files; downstream consumers traverse the returned tree
522/// and never handle `$ref` themselves. Build-time only.
523///
524/// # Errors
525///
526/// Returns [`Error::Io`] if `schema_path` or any referenced file cannot be read, and
527/// [`Error::Yaml`] if any file fails to parse. The offending path is carried in the error.
528pub fn load_resolved_schema(schema_path: &Path) -> Result<Value, Error> {
529    let schema_dir = schema_path.parent().unwrap_or_else(|| Path::new("."));
530    let mut doc = read_yaml(schema_path)?;
531    resolve_refs(&mut doc, schema_dir)?;
532    Ok(doc)
533}
534
535/// Recursively replace any mapping node containing a `$ref: <file>` entry with the (also resolved)
536/// contents of the referenced file, found relative to `schema_dir`.
537fn resolve_refs(value: &mut Value, schema_dir: &Path) -> Result<(), Error> {
538    if let Some(map) = value.as_object_mut() {
539        if let Some(ref_path) = map.get("$ref").and_then(|v| v.as_str()) {
540            let ref_file = schema_dir.join(ref_path);
541            let mut ref_doc = read_yaml(&ref_file)?;
542            resolve_refs(&mut ref_doc, schema_dir)?;
543            *value = ref_doc;
544            return Ok(());
545        }
546        for (_k, v) in map.iter_mut() {
547            resolve_refs(v, schema_dir)?;
548        }
549    }
550    Ok(())
551}
552
553/// Load the Datadog schema with the OTel receiver schema patched in.
554pub fn load_composed_schema(datadog_schema: &Path, otel_schema_dir: &Path) -> Result<Value, Error> {
555    let mut datadog_schema = load_resolved_schema(datadog_schema)?;
556    let otel_receiver = load_otel_receiver(otel_schema_dir)?;
557    patch_receiver(&mut datadog_schema, otel_receiver)?;
558    Ok(datadog_schema)
559}
560
561/// Load and completely resolve the OTel receiver schema into a Datadog compatible subtree.
562fn load_otel_receiver(otel_dir: &Path) -> Result<Value, Error> {
563    let schema_path = otel_dir.join("config.schema.yaml");
564    let doc = read_yaml(&schema_path)?;
565
566    let defs = doc
567        .get("$defs")
568        .and_then(|v| v.as_object())
569        .ok_or_else(|| Error::Validation("OTel schema missing $defs".to_string()))?
570        .clone();
571
572    // Start from properties.protocols (the root of the receiver config).
573    let mut protocols = doc
574        .get("properties")
575        .and_then(|v| v.get("protocols"))
576        .cloned()
577        .ok_or_else(|| Error::Validation("OTel schema missing properties.protocols".to_string()))?;
578
579    // Resolve all $ref, $defs, and allOf.
580    resolve_otel_refs(&mut protocols, otel_dir, &defs)?;
581
582    // Convert to Datadog schema dialect.
583    convert_to_datadog_dialect(&mut protocols);
584
585    // Tag env bindings. Only a subset is is registered and gets an explicit `env_vars` designation; the rest get `no-env`.
586    tag_otel_env_bindings(&mut protocols, &["protocols"]);
587
588    // Wrap in a "receiver" section to match the Datadog schema structure.
589    let mut receiver_section = Map::new();
590    receiver_section.insert("node_type".to_string(), Value::String("section".to_string()));
591    receiver_section.insert("type".to_string(), Value::String("object".to_string()));
592    let mut properties = Map::new();
593    properties.insert("protocols".to_string(), protocols);
594    receiver_section.insert("properties".to_string(), Value::Object(properties));
595
596    Ok(Value::Object(receiver_section))
597}
598
599/// Merge the resolved OTel receiver subtree into the Datadog schema's `otlp_config.receiver` while
600/// honoring default values not native to OTel.
601fn patch_receiver(datadog_schema: &mut Value, otel_receiver: Value) -> Result<(), Error> {
602    let Some(otlp_config) = datadog_schema
603        .get_mut("properties")
604        .and_then(|v| v.get_mut("otlp_config"))
605    else {
606        return Ok(());
607    };
608
609    let Some(receiver) = otlp_config.get_mut("properties").and_then(|v| v.get_mut("receiver")) else {
610        return Ok(());
611    };
612
613    let datadog_receiver = std::mem::take(receiver);
614    let mut merged = otel_receiver;
615    merge_receiver_node(&mut merged, &datadog_receiver);
616    *receiver = merged;
617    Ok(())
618}
619
620/// Recursively overlay Datadog-specific metadata from the core schema onto the resolved OTel node.
621fn merge_receiver_node(otel: &mut Value, datadog: &Value) {
622    let Some(otel_map) = otel.as_object_mut() else { return };
623    let Some(datadog_map) = datadog.as_object() else {
624        return;
625    };
626
627    // Overlay all Datadog metadata fields onto the OTel node, except `properties`
628    // which is merged recursively to preserve OTel's additional keys.
629    for (key, val) in datadog_map.iter() {
630        if key == "properties" {
631            continue;
632        }
633        otel_map.insert(key.clone(), val.clone());
634    }
635
636    // Recursively merge `properties` containers.
637    if let (Some(otel_props), Some(datadog_props)) = (
638        otel_map.get_mut("properties").and_then(|v| v.as_object_mut()),
639        datadog_map.get("properties").and_then(|v| v.as_object()),
640    ) {
641        // Merge overlapping OTel properties with corresponding Datadog properties.
642        let otel_keys: Vec<String> = otel_props.keys().cloned().collect();
643        for key in &otel_keys {
644            if let Some(datadog_val) = datadog_props.get(key) {
645                if let Some(otel_val) = otel_props.get_mut(key) {
646                    merge_receiver_node(otel_val, datadog_val);
647                }
648            }
649        }
650        // Add Datadog-only properties that don't exist in OTel.
651        for (key, val) in datadog_props.iter() {
652            if !otel_props.contains_key(key) {
653                otel_props.insert(key.clone(), val.clone());
654            }
655        }
656    }
657}
658
659/// Resolve OTel schema references into a flat tree.
660fn resolve_otel_refs(value: &mut Value, otel_dir: &Path, current_defs: &Map<String, Value>) -> Result<(), Error> {
661    let Some(map) = value.as_object_mut() else {
662        return Ok(());
663    };
664
665    // 1. Handle $ref: replace this node with the resolved definition.
666    if let Some(ref_str) = map.get("$ref").and_then(|v| v.as_str()) {
667        // Save sibling keys (everything except $ref): for example x-optional, description.
668        let siblings: Vec<(String, Value)> = map
669            .iter()
670            .filter(|(k, _)| *k != "$ref")
671            .map(|(k, v)| (k.clone(), v.clone()))
672            .collect();
673
674        // Resolve the ref target, getting the definition value and its source $defs.
675        let (mut resolved, source_defs) = resolve_otel_ref_target(ref_str, otel_dir, current_defs)?;
676
677        // Recursively resolve refs inside the definition using the source file's $defs.
678        resolve_otel_refs(&mut resolved, otel_dir, &source_defs)?;
679
680        // Resolve siblings using current_defs (they belong to the current file).
681        let mut resolved_siblings: Vec<(String, Value)> = Vec::new();
682        for (k, mut v) in siblings {
683            resolve_otel_refs(&mut v, otel_dir, current_defs)?;
684            resolved_siblings.push((k, v));
685        }
686
687        // Merge siblings into the resolved definition (siblings override def keys).
688        if let Some(resolved_map) = resolved.as_object_mut() {
689            for (k, v) in resolved_siblings {
690                resolved_map.insert(k, v);
691            }
692        }
693
694        *value = resolved;
695        return Ok(());
696    }
697
698    // 2. Handle allOf: merge each fragment's properties into this node.
699    if let Some(allof) = map.remove("allOf") {
700        if let Some(allof_seq) = allof.as_array() {
701            for fragment in allof_seq {
702                let mut resolved_fragment = fragment.clone();
703                resolve_otel_refs(&mut resolved_fragment, otel_dir, current_defs)?;
704
705                // Merge fragment's properties into value's properties.
706                if let Some(source_props) = resolved_fragment.get("properties").and_then(|v| v.as_object()) {
707                    if map.get("properties").is_none() {
708                        map.insert("properties".to_string(), Value::Object(Map::new()));
709                    }
710                    if let Some(target_props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
711                        for (k, v) in source_props {
712                            target_props.insert(k.clone(), v.clone());
713                        }
714                    }
715                }
716            }
717        }
718    }
719
720    // 3. Recurse into properties, stripping auth and middlewares that reference excluded packages.
721    if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
722        // Strip auth/middlewares if they have a $ref (direct or via allOf/items) that
723        // transitively references configauth/configmiddleware. A plain `auth` field
724        // (like tpm_config.auth: type: string) has no $ref and must be kept.
725        if let Some(auth) = props.get("auth") {
726            let auth_str = serde_json::to_string(auth).unwrap_or_default();
727            if auth_str.contains("$ref") {
728                props.remove("auth");
729            }
730        }
731        if let Some(middlewares) = props.get("middlewares") {
732            let mw_str = serde_json::to_string(middlewares).unwrap_or_default();
733            if mw_str.contains("$ref") {
734                props.remove("middlewares");
735            }
736        }
737        for (_, v) in props.iter_mut() {
738            resolve_otel_refs(v, otel_dir, current_defs)?;
739        }
740    }
741
742    Ok(())
743}
744
745/// Resolve a single `$ref` target, returning the definition value and its source `$defs`.
746fn resolve_otel_ref_target(
747    ref_str: &str, otel_dir: &Path, current_defs: &Map<String, Value>,
748) -> Result<(Value, Map<String, Value>), Error> {
749    if let Some(stripped) = ref_str.strip_prefix('/') {
750        // Package-qualified ref: /config/`configgrpc`.server_config
751        let last_dot = stripped
752            .rfind('.')
753            .ok_or_else(|| Error::Validation(format!("invalid package-qualified ref (no dot): {ref_str}")))?;
754        let package_path = &stripped[..last_dot];
755        let def_name = &stripped[last_dot + 1..];
756
757        // Handle configopaque refs inline (not vendored).
758        if package_path == "config/configopaque" {
759            let inline = match def_name {
760                "string" => Value::Object([("type".into(), "string".into())].into_iter().collect()),
761                "map_list" => {
762                    let mut m = Map::new();
763                    m.insert("type".into(), "object".into());
764                    let mut addl = Map::new();
765                    addl.insert("type".into(), "string".into());
766                    m.insert("additionalProperties".into(), Value::Object(addl));
767                    Value::Object(m)
768                }
769                other => return Err(Error::Validation(format!("unknown configopaque ref: {other}"))),
770            };
771            return Ok((inline, Map::new()));
772        }
773
774        // Reject refs to excluded packages (should not be reached if auth/middlewares are stripped).
775        if package_path == "config/configauth" || package_path == "config/configmiddleware" {
776            return Err(Error::Validation(format!(
777                "unexpected ref to excluded package: {ref_str}"
778            )));
779        }
780
781        // Load the package's schema file.
782        let file_path = otel_dir.join(package_path).join("config.schema.yaml");
783        let file_doc = read_yaml(&file_path)?;
784        let file_defs = file_doc
785            .get("$defs")
786            .and_then(|v| v.as_object())
787            .ok_or_else(|| Error::Validation(format!("OTel schema {file_path:?} missing $defs")))?
788            .clone();
789        let def_value = file_defs
790            .get(def_name)
791            .cloned()
792            .ok_or_else(|| Error::Validation(format!("$defs.{def_name} not found in {file_path:?}")))?;
793
794        Ok((def_value, file_defs))
795    } else {
796        // Local ref: protocols, http_config, sanitized_url_path, etc.
797        let def_value = current_defs
798            .get(ref_str)
799            .cloned()
800            .ok_or_else(|| Error::Validation(format!("local $def {ref_str} not found")))?;
801        Ok((def_value, current_defs.clone()))
802    }
803}
804
805/// Convert an OTel schema tree to the Datadog compatible schema dialect.
806fn convert_to_datadog_dialect(value: &mut Value) {
807    let Some(map) = value.as_object_mut() else {
808        return;
809    };
810
811    // Remove OTel-specific extensions.
812    map.remove("x-optional");
813    map.remove("x-customType");
814
815    // Determine if this is a section (has properties) or a setting (leaf).
816    let is_section = map.get("properties").is_some();
817    let node_type = if is_section { "section" } else { "setting" };
818
819    // Add node_type if not present.
820    if map.get("node_type").is_none() {
821        map.insert("node_type".to_string(), Value::String(node_type.to_string()));
822    }
823
824    // Recurse into properties.
825    if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
826        for (_, v) in props.iter_mut() {
827            convert_to_datadog_dialect(v);
828        }
829    }
830}
831
832/// List of env-reachable `otlp_config.receiver` keys
833const ENV_REGISTERED_OTEL_KEYS: &[&str] = &[
834    "otlp_config.receiver.protocols.grpc.endpoint",
835    "otlp_config.receiver.protocols.grpc.transport",
836    "otlp_config.receiver.protocols.grpc.max_recv_msg_size_mib",
837    "otlp_config.receiver.protocols.grpc.max_concurrent_streams",
838    "otlp_config.receiver.protocols.grpc.read_buffer_size",
839    "otlp_config.receiver.protocols.grpc.write_buffer_size",
840    "otlp_config.receiver.protocols.grpc.include_metadata",
841    "otlp_config.receiver.protocols.grpc.keepalive.enforcement_policy.min_time",
842    "otlp_config.receiver.protocols.http.endpoint",
843    "otlp_config.receiver.protocols.http.max_request_body_size",
844    "otlp_config.receiver.protocols.http.include_metadata",
845    "otlp_config.receiver.protocols.http.cors.allowed_headers",
846    "otlp_config.receiver.protocols.http.cors.allowed_origins",
847];
848
849/// Walks the OTel receiver subtree and tags each setting with `env_vars` or `no-env`.
850fn tag_otel_env_bindings(value: &mut Value, path_parts: &[&str]) {
851    let Some(map) = value.as_object_mut() else { return };
852
853    if map.get("node_type").and_then(|v| v.as_str()) == Some("setting") {
854        let full_path = format!("otlp_config.receiver.{}", path_parts.join("."));
855        if ENV_REGISTERED_OTEL_KEYS.contains(&full_path.as_str()) {
856            let env_var = format!("DD_{}", full_path.replace('.', "_").to_uppercase());
857            let env_vars = Value::Array(vec![Value::String(env_var)]);
858            map.insert("env_vars".to_string(), env_vars);
859        } else {
860            let mut tags = map.get("tags").and_then(|v| v.as_array().cloned()).unwrap_or_default();
861            if !tags.iter().any(|t| t.as_str() == Some("no-env")) {
862                tags.push(Value::String("no-env".to_string()));
863            }
864            map.insert("tags".to_string(), Value::Array(tags));
865        }
866        return;
867    }
868
869    if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
870        for (key, val) in props.iter_mut() {
871            let mut parts = path_parts.to_vec();
872            parts.push(key);
873            tag_otel_env_bindings(val, &parts);
874        }
875    }
876}
877
878const VALIDATION_RULES: &str = "\n\
879    \n\
880    Rules that must hold in schema_overlay.yaml:\n\
881    - Every core_schema.yaml key appears in exactly one section (known / ignored).\n\
882    - No key appears in more than one section.\n\
883    - Sections appear in order: known, ignored.\n\
884    - Keys within each section are sorted alphabetically.\n\
885    - full entries: pipelines non-empty, used_by non-empty, description <= 50 chars.\n\
886    - partial entries: pipelines non-empty, used_by non-empty, description <= 50 chars, documentation required.\n\
887    - unsupported entries: pipelines non-empty, description <= 50 chars, planned+issue consistent.\n\
888    - unknown entries: description <= 50 chars (when present).\n\
889    - additional_yaml_paths: no duplicates within a single entry, no dots, no collisions with canonical keys.\n\
890    Fix: edit lib/datadog-agent/config/schema/schema_overlay.yaml.";
891
892/// Errors that can occur when loading a schema overlay.
893#[derive(Debug)]
894pub enum Error {
895    Io((PathBuf, std::io::Error)),
896    Yaml(serde_saphyr::Error),
897    Validation(String),
898}
899
900impl std::fmt::Display for Error {
901    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902        match self {
903            Error::Io(e) => write!(f, "Error reading {}: {}", e.0.display(), e.1),
904            Error::Yaml(e) => write!(f, "YAML parse error in overlay: {e}"),
905            Error::Validation(s) => write!(f, "schema_overlay.yaml validation failed: {s}{VALIDATION_RULES}"),
906        }
907    }
908}
909
910impl std::error::Error for Error {
911    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
912        match self {
913            Error::Io(e) => Some(&e.1),
914            Error::Yaml(e) => Some(e),
915            Error::Validation(_) => None,
916        }
917    }
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    /// Returns the OTel schema directory for tests that need the composed schema.
925    fn otel_schema_dir_for_tests() -> PathBuf {
926        Path::new(env!("CARGO_MANIFEST_DIR"))
927            .join("..")
928            .join("config")
929            .join("schema")
930            .join("otel")
931    }
932
933    #[test]
934    fn overlay_loads() {
935        let test_files = Files {
936            datadog_schema: Path::new(env!("CARGO_MANIFEST_DIR"))
937                .join("test")
938                .join("fake_schema.yaml"),
939            otel_schema_dir: otel_schema_dir_for_tests(),
940            overlay: Path::new(env!("CARGO_MANIFEST_DIR"))
941                .join("test")
942                .join("fake_overlay.yaml"),
943        };
944        let validated = SchemaOverlay::load(test_files).unwrap();
945        assert_eq!(validated.inventory.len(), 18);
946    }
947
948    #[test]
949    fn pipeline_affinity_cross_cutting() {
950        let yaml = "pipelines: [cross_cutting]";
951        #[derive(Deserialize)]
952        struct W {
953            pipelines: PipelineAffinity,
954        }
955        let w: W = serde_saphyr::from_str(yaml).unwrap();
956        assert!(matches!(w.pipelines, PipelineAffinity::CrossCutting));
957    }
958
959    #[test]
960    fn pipeline_affinity_multi() {
961        let yaml = "pipelines: [dogstatsd, traces]";
962        #[derive(Deserialize)]
963        struct W {
964            pipelines: PipelineAffinity,
965        }
966        let w: W = serde_saphyr::from_str(yaml).unwrap();
967        if let PipelineAffinity::Pipelines(ps) = w.pipelines {
968            assert_eq!(ps.len(), 2);
969            assert!(matches!(ps[0], Pipeline::DogStatsD));
970            assert!(matches!(ps[1], Pipeline::Traces));
971        } else {
972            panic!("expected Pipelines");
973        }
974    }
975
976    #[test]
977    fn pipeline_affinity_cross_cutting_must_be_alone() {
978        let yaml = "pipelines: [cross_cutting, dogstatsd]";
979        #[derive(Deserialize)]
980        #[allow(dead_code)]
981        struct W {
982            pipelines: PipelineAffinity,
983        }
984        assert!(serde_saphyr::from_str::<W>(yaml).is_err());
985    }
986
987    fn load_from_strs(schema: &str, overlay: &str) -> Result<SchemaOverlay, Error> {
988        let dir = tempfile::tempdir().unwrap();
989        let schema_path = dir.path().join("fake_schema.yaml");
990        let overlay_path = dir.path().join("overlay.yaml");
991        std::fs::write(&schema_path, schema).unwrap();
992        std::fs::write(&overlay_path, overlay).unwrap();
993        let otel_schema_dir = otel_schema_dir_for_tests();
994        SchemaOverlay::load(Files {
995            datadog_schema: schema_path,
996            otel_schema_dir,
997            overlay: overlay_path,
998        })
999    }
1000
1001    #[test]
1002    fn validation_rejects_schema_key_missing_from_overlay() {
1003        let schema = "\
1004properties:
1005  key_a:
1006    type: string
1007  key_b:
1008    type: string
1009";
1010        let overlay = "\
1011inventory:
1012  key_a:
1013    support: full
1014    pipelines: [cross_cutting]
1015    description: \"Key A\"
1016    test_support:
1017      used_by: [TypedConfigSystem]
1018excluded: {}
1019";
1020        let err = load_from_strs(schema, overlay).unwrap_err();
1021        assert!(
1022            err.to_string().contains("schema key 'key_b' is not covered"),
1023            "unexpected error: {err}"
1024        );
1025    }
1026
1027    #[test]
1028    fn validation_rejects_overlay_key_absent_from_schema() {
1029        let schema = "\
1030properties:
1031  key_a:
1032    type: string
1033";
1034        let overlay = "\
1035inventory:
1036  key_a:
1037    support: full
1038    pipelines: [cross_cutting]
1039    description: \"Key A\"
1040    test_support:
1041      used_by: [TypedConfigSystem]
1042excluded:
1043  key_b: \"not in schema\"
1044";
1045        let err = load_from_strs(schema, overlay).unwrap_err();
1046        assert!(
1047            err.to_string().contains("overlay key 'key_b' is not present"),
1048            "unexpected error: {err}"
1049        );
1050    }
1051
1052    #[test]
1053    fn validation_rejects_key_in_two_sections() {
1054        let schema = "\
1055properties:
1056  key_a:
1057    type: string
1058  key_b:
1059    type: string
1060";
1061        let overlay = "\
1062inventory:
1063  key_a:
1064    support: full
1065    pipelines: [cross_cutting]
1066    description: \"Key A\"
1067    test_support:
1068      used_by: [TypedConfigSystem]
1069excluded:
1070  key_a: \"duplicate\"
1071  key_b: \"ok\"
1072";
1073        let err = load_from_strs(schema, overlay).unwrap_err();
1074        assert!(
1075            err.to_string()
1076                .contains("key 'key_a' appears in more than one overlay section"),
1077            "unexpected error: {err}"
1078        );
1079    }
1080
1081    /// A `$ref` is inlined and its leaf keys are namespaced under the parent key.
1082    #[test]
1083    fn schema_ref_is_resolved_and_keys_namespaced() {
1084        let dir = tempfile::tempdir().unwrap();
1085        std::fs::write(
1086            dir.path().join("sub.yaml"),
1087            "properties:\n  enabled:\n    type: boolean\n",
1088        )
1089        .unwrap();
1090        let schema_path = dir.path().join("core_schema.yaml");
1091        std::fs::write(&schema_path, "properties:\n  feature:\n    $ref: sub.yaml\n").unwrap();
1092
1093        let keys = SchemaOverlay::schema_keys(&schema_path, &otel_schema_dir_for_tests()).unwrap();
1094        assert_eq!(
1095            keys,
1096            HashSet::from(["feature.enabled".to_string()]),
1097            "unexpected keys: {keys:?}"
1098        );
1099    }
1100
1101    /// A missing `$ref` target surfaces a clear I/O error naming the file, not a misleading
1102    /// "key not covered" validation error.
1103    #[test]
1104    fn missing_schema_ref_reports_io_error() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let schema_path = dir.path().join("core_schema.yaml");
1107        std::fs::write(&schema_path, "properties:\n  feature:\n    $ref: does_not_exist.yaml\n").unwrap();
1108
1109        let err = SchemaOverlay::schema_keys(&schema_path, &otel_schema_dir_for_tests()).unwrap_err();
1110        assert!(matches!(err, Error::Io(_)), "expected Io error, got: {err}");
1111        assert!(
1112            err.to_string().contains("does_not_exist.yaml"),
1113            "error should name the missing file: {err}"
1114        );
1115    }
1116
1117    #[test]
1118    fn validation_rejects_unsorted_inventory_keys() {
1119        let schema = "\
1120properties:
1121  key_a:
1122    type: string
1123  key_b:
1124    type: string
1125";
1126        let overlay = "\
1127inventory:
1128  key_b:
1129    support: full
1130    pipelines: [cross_cutting]
1131    description: \"Key B\"
1132    test_support:
1133      used_by: [TypedConfigSystem]
1134  key_a:
1135    support: full
1136    pipelines: [cross_cutting]
1137    description: \"Key A\"
1138    test_support:
1139      used_by: [TypedConfigSystem]
1140excluded: {}
1141";
1142        let err = load_from_strs(schema, overlay).unwrap_err();
1143        assert!(
1144            err.to_string().contains("out of alphabetical order"),
1145            "unexpected error: {err}"
1146        );
1147    }
1148
1149    // The per-entry validation rules (`validate_entries`) are documented in `VALIDATION_RULES` and
1150    // enforced independently of the schema cross-check, so these tests deserialize an overlay in
1151    // isolation (via `from_yaml`) and run only that pass: no matching core schema is needed.
1152    fn validate_entries_of(overlay: &str) -> Result<(), Error> {
1153        SchemaOverlay::from_yaml(overlay)
1154            .expect("overlay should deserialize")
1155            .validate_entries()
1156    }
1157
1158    #[test]
1159    fn per_entry_validation_accepts_a_well_formed_entry_of_every_kind() {
1160        // Keys are alphabetically ordered (full < partial < unknown < unsupported) so the YAML lint
1161        // pass is satisfied and only per-entry validation is under test.
1162        let overlay = "\
1163inventory:
1164  full_key:
1165    support: full
1166    pipelines: [dogstatsd]
1167    description: \"Fully supported key\"
1168    test_support:
1169      used_by: [TypedConfigSystem]
1170      additional_yaml_paths: [full_alias]
1171  partial_key:
1172    support: partial
1173    pipelines: [traces]
1174    description: \"Partially supported key\"
1175    documentation: \"Behaves differently from the core agent.\"
1176    test_support:
1177      used_by: [TypedConfigSystem]
1178  unknown_key:
1179    support: unknown
1180    description: \"Not yet classified\"
1181  unsupported_key:
1182    support: none
1183    pipelines: [checks]
1184    description: \"Unsupported key\"
1185    severity: high
1186    planned: true
1187    issue: \"1234\"
1188excluded: {}
1189";
1190        validate_entries_of(overlay).expect("a well-formed overlay should pass per-entry validation");
1191    }
1192
1193    #[test]
1194    fn per_entry_validation_rejects_over_long_description_for_every_entry_kind() {
1195        // A 60-character description exceeds the documented 50-char cap. Each entry kind that carries a
1196        // description enforces the same limit, so this walks all four.
1197        let long = "x".repeat(60);
1198        let cases: &[(&str, String)] = &[
1199            (
1200                "full",
1201                format!(
1202                    "inventory:\n  key_a:\n    support: full\n    pipelines: [dogstatsd]\n    \
1203                     description: \"{long}\"\n    test_support:\n      used_by: [TypedConfigSystem]\nexcluded: {{}}\n"
1204                ),
1205            ),
1206            (
1207                "partial",
1208                format!(
1209                    "inventory:\n  key_a:\n    support: partial\n    pipelines: [dogstatsd]\n    \
1210                     description: \"{long}\"\n    documentation: \"diverges\"\n    test_support:\n      \
1211                     used_by: [TypedConfigSystem]\nexcluded: {{}}\n"
1212                ),
1213            ),
1214            (
1215                "unsupported",
1216                format!(
1217                    "inventory:\n  key_a:\n    support: none\n    pipelines: [dogstatsd]\n    \
1218                     description: \"{long}\"\n    severity: low\n    planned: false\nexcluded: {{}}\n"
1219                ),
1220            ),
1221            (
1222                "unknown",
1223                format!("inventory:\n  key_a:\n    support: unknown\n    description: \"{long}\"\nexcluded: {{}}\n"),
1224            ),
1225        ];
1226
1227        for (kind, overlay) in cases {
1228            let err = validate_entries_of(overlay).expect_err(&format!("{kind} entry should be rejected"));
1229            assert!(
1230                err.to_string().contains("description exceeds 50 chars"),
1231                "{kind}: unexpected error: {err}"
1232            );
1233        }
1234    }
1235
1236    #[test]
1237    fn per_entry_validation_rejects_empty_used_by_for_full_and_partial_entries() {
1238        let full = "\
1239inventory:
1240  key_a:
1241    support: full
1242    pipelines: [dogstatsd]
1243    description: \"Key A\"
1244    test_support:
1245      used_by: []
1246excluded: {}
1247";
1248        let err = validate_entries_of(full).expect_err("full entry with empty used_by should be rejected");
1249        assert!(
1250            err.to_string().contains("full key 'key_a': used_by must be non-empty"),
1251            "unexpected error: {err}"
1252        );
1253
1254        let partial = "\
1255inventory:
1256  key_a:
1257    support: partial
1258    pipelines: [dogstatsd]
1259    description: \"Key A\"
1260    documentation: \"diverges\"
1261    test_support:
1262      used_by: []
1263excluded: {}
1264";
1265        let err = validate_entries_of(partial).expect_err("partial entry with empty used_by should be rejected");
1266        assert!(
1267            err.to_string()
1268                .contains("partial key 'key_a': used_by must be non-empty"),
1269            "unexpected error: {err}"
1270        );
1271    }
1272
1273    #[test]
1274    fn per_entry_validation_rejects_planned_unsupported_entry_without_issue() {
1275        let overlay = "\
1276inventory:
1277  key_a:
1278    support: none
1279    pipelines: [dogstatsd]
1280    description: \"Key A\"
1281    severity: medium
1282    planned: true
1283excluded: {}
1284";
1285        let err = validate_entries_of(overlay).expect_err("planned unsupported entry without issue should be rejected");
1286        assert!(
1287            err.to_string()
1288                .contains("unsupported key 'key_a': planned requires an issue"),
1289            "unexpected error: {err}"
1290        );
1291    }
1292
1293    #[test]
1294    fn per_entry_validation_rejects_duplicate_additional_yaml_path() {
1295        let overlay = "\
1296inventory:
1297  key_a:
1298    support: full
1299    pipelines: [dogstatsd]
1300    description: \"Key A\"
1301    test_support:
1302      used_by: [TypedConfigSystem]
1303      additional_yaml_paths: [dup_alias, dup_alias]
1304excluded: {}
1305";
1306        let err = validate_entries_of(overlay).expect_err("duplicate additional_yaml_path should be rejected");
1307        assert!(
1308            err.to_string()
1309                .contains("key 'key_a': duplicate additional_yaml_path 'dup_alias'"),
1310            "unexpected error: {err}"
1311        );
1312    }
1313
1314    #[test]
1315    fn per_entry_validation_rejects_dotted_additional_yaml_path() {
1316        // Dotted aliases can't be represented as a serde field alias on the generated struct, so they're
1317        // rejected outright.
1318        let overlay = "\
1319inventory:
1320  key_a:
1321    support: full
1322    pipelines: [dogstatsd]
1323    description: \"Key A\"
1324    test_support:
1325      used_by: [TypedConfigSystem]
1326      additional_yaml_paths: [\"nested.alias\"]
1327excluded: {}
1328";
1329        let err = validate_entries_of(overlay).expect_err("dotted additional_yaml_path should be rejected");
1330        assert!(
1331            err.to_string()
1332                .contains("additional_yaml_path 'nested.alias' contains a dot"),
1333            "unexpected error: {err}"
1334        );
1335    }
1336
1337    #[test]
1338    fn per_entry_validation_rejects_additional_yaml_path_colliding_with_canonical_key() {
1339        // `alpha`'s alias `beta` collides with the canonical key `beta`; two fields would deserialize
1340        // from the same YAML key. Keys are alphabetically ordered so the YAML lint pass is satisfied.
1341        let overlay = "\
1342inventory:
1343  alpha:
1344    support: full
1345    pipelines: [dogstatsd]
1346    description: \"Alpha\"
1347    test_support:
1348      used_by: [TypedConfigSystem]
1349      additional_yaml_paths: [beta]
1350  beta:
1351    support: full
1352    pipelines: [dogstatsd]
1353    description: \"Beta\"
1354    test_support:
1355      used_by: [TypedConfigSystem]
1356excluded: {}
1357";
1358        let err = validate_entries_of(overlay).expect_err("aliasing a canonical key should be rejected");
1359        assert!(
1360            err.to_string()
1361                .contains("additional_yaml_path 'beta' collides with a canonical"),
1362            "unexpected error: {err}"
1363        );
1364    }
1365
1366    #[test]
1367    fn verify_otel_receiver_env_bindings() {
1368        let schema_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1369            .join("..")
1370            .join("config")
1371            .join("schema")
1372            .join("core")
1373            .join("core_schema.yaml");
1374        let otel_schema_dir = otel_schema_dir_for_tests();
1375        let schema_map = crate::schema_gen::load_schema(&schema_path, &otel_schema_dir);
1376
1377        let registered: HashSet<&str> = super::ENV_REGISTERED_OTEL_KEYS.iter().copied().collect();
1378
1379        for (key, info) in &schema_map {
1380            if !key.starts_with("otlp_config.receiver.protocols.") {
1381                continue;
1382            }
1383            if registered.contains(key.as_str()) {
1384                assert!(
1385                    matches!(info.env, crate::schema_gen::EnvBinding::Overridden(_)),
1386                    "registered key `{key}` should have explicit env_vars"
1387                );
1388            } else {
1389                assert!(
1390                    matches!(info.env, crate::schema_gen::EnvBinding::None),
1391                    "unregistered key `{key}` should be no-env"
1392                );
1393            }
1394        }
1395    }
1396}