1pub 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#[derive(Debug, Clone, Deserialize)]
28pub struct SchemaOverlay {
29 pub inventory: IndexMap<String, KnownEntry>,
30 pub excluded: IndexMap<String, String>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35#[serde(tag = "support", rename_all = "snake_case")]
36pub enum KnownEntry {
37 Full(FullSupport),
39 Partial(PartialSupport),
41 #[serde(rename = "none")]
43 Unsupported(Unsupported),
44 Unknown(UnknownSupport),
46}
47
48#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub struct FullSupport {
52 pub pipelines: PipelineAffinity,
54 pub description: String,
56 #[serde(default)]
58 pub documentation: Option<String>,
59 #[serde(default)]
61 pub issue: Option<String>,
62 pub test_support: TestSupport,
64}
65
66#[derive(Debug, Clone, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub struct PartialSupport {
70 pub pipelines: PipelineAffinity,
72 pub description: String,
74 pub documentation: String,
76 #[serde(default)]
78 pub warn: bool,
79 #[serde(default)]
81 pub issue: Option<String>,
82 pub test_support: TestSupport,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub struct Unsupported {
90 pub pipelines: PipelineAffinity,
92 pub description: String,
94 #[serde(default)]
96 pub documentation: Option<String>,
97 pub severity: Severity,
99 pub planned: bool,
101 #[serde(default)]
103 pub issue: Option<String>,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub struct UnknownSupport {
110 #[serde(default)]
112 pub description: Option<String>,
113 #[serde(default)]
115 pub severity: Option<Severity>,
116 #[serde(default)]
118 pub issue: Option<String>,
119}
120
121#[derive(Debug, Clone, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub struct TestSupport {
128 #[serde(default)]
130 pub env_var_override: Option<Vec<String>>,
131 #[serde(default)]
133 pub additional_yaml_paths: Vec<String>,
134 #[serde(default)]
136 pub value_type_override: Option<ValueType>,
137 pub used_by: IndexSet<ConfigurationStruct>,
139 #[serde(default)]
141 pub test_json: Option<String>,
142 #[serde(default)]
145 pub additional_attributes: IndexMap<String, String>,
146}
147
148#[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#[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#[derive(Debug, Clone)]
174pub enum PipelineAffinity {
175 CrossCutting,
177 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#[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
238pub struct Files {
242 pub datadog_schema: PathBuf,
244 pub otel_schema_dir: PathBuf,
246 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 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 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 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 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
506fn 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
512pub 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
530fn 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
548pub 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
556fn 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 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_otel_refs(&mut protocols, otel_dir, &defs)?;
576
577 convert_to_datadog_dialect(&mut protocols);
579
580 tag_otel_env_bindings(&mut protocols, &["protocols"]);
582
583 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
603fn 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
624fn 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 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 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 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 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
663fn 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 if let Some(ref_str) = map.get("$ref").and_then(|v| v.as_str()) {
673 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 let (mut resolved, source_defs) = resolve_otel_ref_target(ref_str, otel_dir, current_defs)?;
682
683 resolve_otel_refs(&mut resolved, otel_dir, &source_defs)?;
685
686 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 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 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 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 if let Some(props) = map.get_mut("properties").and_then(|v| v.as_mapping_mut()) {
731 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
754fn 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 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 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 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 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 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
814fn convert_to_datadog_dialect(value: &mut serde_yaml::Value) {
816 let Some(map) = value.as_mapping_mut() else {
817 return;
818 };
819
820 map.remove(serde_yaml::Value::String("x-optional".to_string()));
822 map.remove(serde_yaml::Value::String("x-customType".to_string()));
823
824 let is_section = map.get("properties").is_some();
826 let node_type = if is_section { "section" } else { "setting" };
827
828 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 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
844const 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
861fn 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#[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 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 #[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 #[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 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 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 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 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 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}