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