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;
20use serde_json::{Map, Value};
21
22use crate::smoke_test_support::ConfigurationStruct;
23
24#[derive(Debug, Clone, Deserialize)]
29pub struct SchemaOverlay {
30 pub inventory: IndexMap<String, KnownEntry>,
31 pub excluded: IndexMap<String, String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
36#[serde(tag = "support", rename_all = "snake_case")]
37pub enum KnownEntry {
38 Full(FullSupport),
40 Partial(PartialSupport),
42 #[serde(rename = "none")]
44 Unsupported(Unsupported),
45 Unknown(UnknownSupport),
47}
48
49#[derive(Debug, Clone, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub struct FullSupport {
53 pub pipelines: PipelineAffinity,
55 pub description: String,
57 #[serde(default)]
59 pub documentation: Option<String>,
60 #[serde(default)]
62 pub issue: Option<String>,
63 pub test_support: TestSupport,
65}
66
67#[derive(Debug, Clone, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub struct PartialSupport {
71 pub pipelines: PipelineAffinity,
73 pub description: String,
75 pub documentation: String,
77 #[serde(default)]
79 pub warn: bool,
80 #[serde(default)]
82 pub issue: Option<String>,
83 pub test_support: TestSupport,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub struct Unsupported {
91 pub pipelines: PipelineAffinity,
93 pub description: String,
95 #[serde(default)]
97 pub documentation: Option<String>,
98 pub severity: Severity,
100 pub planned: bool,
102 #[serde(default)]
104 pub issue: Option<String>,
105}
106
107#[derive(Debug, Clone, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub struct UnknownSupport {
111 #[serde(default)]
113 pub description: Option<String>,
114 #[serde(default)]
116 pub severity: Option<Severity>,
117 #[serde(default)]
119 pub issue: Option<String>,
120}
121
122#[derive(Debug, Clone, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub struct TestSupport {
129 #[serde(default)]
131 pub env_var_override: Option<Vec<String>>,
132 #[serde(default)]
134 pub additional_yaml_paths: Vec<String>,
135 #[serde(default)]
137 pub value_type_override: Option<ValueType>,
138 pub used_by: IndexSet<ConfigurationStruct>,
140 #[serde(default)]
142 pub test_json: Option<String>,
143 #[serde(default)]
146 pub additional_attributes: IndexMap<String, String>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum Severity {
153 Low,
154 Medium,
155 High,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Pipeline {
162 #[serde(rename = "dogstatsd")]
163 DogStatsD,
164 Checks,
165 Otlp,
166 Traces,
167}
168
169#[derive(Debug, Clone)]
175pub enum PipelineAffinity {
176 CrossCutting,
178 Pipelines(Vec<Pipeline>),
180}
181
182impl<'de> serde::Deserialize<'de> for PipelineAffinity {
183 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
184 #[derive(Deserialize, PartialEq)]
185 #[serde(rename_all = "snake_case")]
186 enum Token {
187 CrossCutting,
188 #[serde(rename = "dogstatsd")]
189 DogStatsD,
190 Checks,
191 Otlp,
192 Traces,
193 }
194
195 let tokens: Vec<Token> = Vec::deserialize(d)?;
196
197 if tokens.is_empty() {
198 return Err(serde::de::Error::custom("pipelines must be non-empty"));
199 }
200
201 let has_cc = tokens.iter().any(|t| t == &Token::CrossCutting);
202
203 if has_cc && tokens.len() > 1 {
204 return Err(serde::de::Error::custom(
205 "cross_cutting must appear alone in pipelines list",
206 ));
207 }
208
209 if has_cc {
210 return Ok(PipelineAffinity::CrossCutting);
211 }
212
213 let pipelines = tokens
214 .into_iter()
215 .map(|t| match t {
216 Token::DogStatsD => Pipeline::DogStatsD,
217 Token::Checks => Pipeline::Checks,
218 Token::Otlp => Pipeline::Otlp,
219 Token::Traces => Pipeline::Traces,
220 Token::CrossCutting => unreachable!(),
221 })
222 .collect();
223
224 Ok(PipelineAffinity::Pipelines(pipelines))
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ValueType {
232 Boolean,
233 Integer,
234 Float,
235 String,
236 StringList,
237}
238
239pub struct Files {
243 pub datadog_schema: PathBuf,
245 pub otel_schema_dir: PathBuf,
247 pub overlay: PathBuf,
249}
250
251impl Default for Files {
252 fn default() -> Self {
253 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
254 .join("..")
255 .join("config")
256 .join("schema");
257 Files {
258 datadog_schema: dir.join("core").join("core_schema.yaml"),
259 otel_schema_dir: dir.join("otel"),
260 overlay: dir.join("schema_overlay.yaml"),
261 }
262 }
263}
264
265#[derive(Deserialize)]
268#[serde(untagged)]
269enum LintSection {
270 Mapping(IndexMap<String, serde::de::IgnoredAny>),
271 Other(#[allow(dead_code)] serde::de::IgnoredAny),
272}
273
274impl SchemaOverlay {
275 pub fn load(files: Files) -> Result<Self, Error> {
276 let loaded = Self::from_file(&files.overlay)?;
277 loaded.validate(&files.datadog_schema, &files.otel_schema_dir)?;
278 Ok(loaded)
279 }
280
281 fn from_yaml(s: &str) -> Result<Self, Error> {
282 let sections: IndexMap<String, LintSection> = serde_saphyr::from_str(s).map_err(Error::Yaml)?;
284 Self::lint_yaml(§ions)?;
285 serde_saphyr::from_str(s).map_err(Error::Yaml)
286 }
287
288 fn from_file(path: &Path) -> Result<Self, Error> {
289 let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
290 Self::from_yaml(&contents)
291 }
292
293 fn validate(&self, datadog_schema: &Path, otel_schema_dir: &Path) -> Result<(), Error> {
294 self.validate_keys_match(datadog_schema, otel_schema_dir)?;
295 self.validate_entries()?;
296 Ok(())
297 }
298
299 fn lint_yaml(sections: &IndexMap<String, LintSection>) -> Result<(), Error> {
302 let section_names: Vec<&str> = sections.keys().map(String::as_str).collect();
303
304 for required in ["inventory", "excluded"] {
305 if !section_names.contains(&required) {
306 return Err(Error::Validation(format!(
307 "overlay missing required section '{}'",
308 required
309 )));
310 }
311 }
312
313 let pos_known = section_names.iter().position(|&k| k == "inventory").unwrap();
314 let pos_ignored = section_names.iter().position(|&k| k == "excluded").unwrap();
315
316 if pos_known >= pos_ignored {
317 return Err(Error::Validation(
318 "sections must appear in order: known, ignored".to_string(),
319 ));
320 }
321
322 for section_name in ["inventory", "excluded"] {
323 if let Some(LintSection::Mapping(section)) = sections.get(section_name) {
324 let mut prev = "";
325 for key in section.keys().map(String::as_str) {
326 if key < prev {
327 return Err(Error::Validation(format!(
328 "{}: key '{}' is out of alphabetical order (after '{}')",
329 section_name, key, prev
330 )));
331 }
332 prev = key;
333 }
334 }
335 }
336
337 Ok(())
338 }
339
340 fn validate_keys_match(&self, datadog_schema: &Path, otel_schema_dir: &Path) -> Result<(), Error> {
342 let schema_keys = Self::schema_keys(datadog_schema, otel_schema_dir)?;
343
344 for key in self.excluded.keys() {
345 if self.inventory.contains_key(key.as_str()) {
346 return Err(Error::Validation(format!(
347 "key '{}' appears in more than one overlay section",
348 key
349 )));
350 }
351 }
352
353 for key in self.inventory.keys().chain(self.excluded.keys()) {
354 if !schema_keys.contains(key.as_str()) {
355 return Err(Error::Validation(format!(
356 "overlay key '{}' is not present in the schema",
357 key
358 )));
359 }
360 }
361
362 let overlay_keys: HashSet<&str> = self
363 .inventory
364 .keys()
365 .chain(self.excluded.keys())
366 .map(|s| s.as_str())
367 .collect();
368 for key in &schema_keys {
369 if !overlay_keys.contains(key.as_str()) {
370 return Err(Error::Validation(format!(
371 "schema key '{}' is not covered by the overlay",
372 key
373 )));
374 }
375 }
376
377 Ok(())
378 }
379
380 fn schema_keys(datadog_schema: &Path, otel_schema_dir: &Path) -> Result<HashSet<String>, Error> {
381 let schema = load_composed_schema(datadog_schema, otel_schema_dir)?;
382 let props = schema
383 .get("properties")
384 .and_then(|v| v.as_object())
385 .ok_or_else(|| Error::Validation("schema missing 'properties' section".to_string()))?;
386 let mut keys = HashSet::new();
387 Self::collect_schema_keys(props, "", &mut keys);
388 Ok(keys)
389 }
390
391 fn collect_schema_keys(props: &Map<String, Value>, prefix: &str, keys: &mut HashSet<String>) {
392 for (name, v) in props {
393 let full_key = if prefix.is_empty() {
394 name.to_string()
395 } else {
396 format!("{}.{}", prefix, name)
397 };
398 if let Some(sub_props) = v.get("properties").and_then(|p| p.as_object()) {
401 Self::collect_schema_keys(sub_props, &full_key, keys);
402 } else {
403 keys.insert(full_key);
404 }
405 }
406 }
407
408 fn validate_entries(&self) -> Result<(), Error> {
411 let canonical_keys: HashSet<&str> = self.inventory.keys().map(String::as_str).collect();
412
413 for (key, entry) in &self.inventory {
414 match entry {
415 KnownEntry::Full(f) => {
416 if f.test_support.used_by.is_empty() {
417 return Err(Error::Validation(format!(
418 "full key '{}': used_by must be non-empty",
419 key
420 )));
421 }
422 if f.description.len() > 50 {
423 return Err(Error::Validation(format!(
424 "full key '{}': description exceeds 50 chars ({} chars)",
425 key,
426 f.description.len()
427 )));
428 }
429 Self::validate_additional_yaml_paths(key, &f.test_support.additional_yaml_paths, &canonical_keys)?;
430 }
431 KnownEntry::Partial(p) => {
432 if p.test_support.used_by.is_empty() {
433 return Err(Error::Validation(format!(
434 "partial key '{}': used_by must be non-empty",
435 key
436 )));
437 }
438 if p.description.len() > 50 {
439 return Err(Error::Validation(format!(
440 "partial key '{}': description exceeds 50 chars ({} chars)",
441 key,
442 p.description.len()
443 )));
444 }
445 Self::validate_additional_yaml_paths(key, &p.test_support.additional_yaml_paths, &canonical_keys)?;
446 }
447 KnownEntry::Unsupported(u) => {
448 if u.description.len() > 50 {
449 return Err(Error::Validation(format!(
450 "unsupported key '{}': description exceeds 50 chars ({} chars)",
451 key,
452 u.description.len()
453 )));
454 }
455 if u.planned && u.issue.is_none() {
456 return Err(Error::Validation(format!(
457 "unsupported key '{}': planned requires an issue",
458 key
459 )));
460 }
461 }
462 KnownEntry::Unknown(u) => {
463 if let Some(desc) = &u.description {
464 if desc.len() > 50 {
465 return Err(Error::Validation(format!(
466 "unknown key '{}': description exceeds 50 chars ({} chars)",
467 key,
468 desc.len()
469 )));
470 }
471 }
472 }
473 }
474 }
475 Ok(())
476 }
477
478 fn validate_additional_yaml_paths(
479 key: &str, paths: &[String], canonical_keys: &HashSet<&str>,
480 ) -> Result<(), Error> {
481 let mut seen: HashSet<&str> = HashSet::new();
482 for path in paths {
483 if !seen.insert(path.as_str()) {
484 return Err(Error::Validation(format!(
485 "key '{}': duplicate additional_yaml_path '{}'",
486 key, path
487 )));
488 }
489 if path.contains('.') {
490 return Err(Error::Validation(format!(
491 "key '{}': additional_yaml_path '{}' contains a dot. \
492 Dotted aliases land at a different nesting depth in the YAML tree, \
493 which cannot be represented as a serde field alias on the generated \
494 struct. Supporting dotted aliases requires a post-deserialization \
495 merge step or custom Deserialize impl.",
496 key, path
497 )));
498 }
499 if canonical_keys.contains(path.as_str()) {
500 return Err(Error::Validation(format!(
501 "key '{}': additional_yaml_path '{}' collides with a canonical \
502 overlay key. Two fields would deserialize from the same YAML key.",
503 key, path
504 )));
505 }
506 }
507 Ok(())
508 }
509}
510
511fn read_yaml(path: &Path) -> Result<Value, Error> {
513 let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
514 serde_saphyr::from_str(&contents).map_err(Error::Yaml)
515}
516
517pub fn load_resolved_schema(schema_path: &Path) -> Result<Value, Error> {
529 let schema_dir = schema_path.parent().unwrap_or_else(|| Path::new("."));
530 let mut doc = read_yaml(schema_path)?;
531 resolve_refs(&mut doc, schema_dir)?;
532 Ok(doc)
533}
534
535fn resolve_refs(value: &mut Value, schema_dir: &Path) -> Result<(), Error> {
538 if let Some(map) = value.as_object_mut() {
539 if let Some(ref_path) = map.get("$ref").and_then(|v| v.as_str()) {
540 let ref_file = schema_dir.join(ref_path);
541 let mut ref_doc = read_yaml(&ref_file)?;
542 resolve_refs(&mut ref_doc, schema_dir)?;
543 *value = ref_doc;
544 return Ok(());
545 }
546 for (_k, v) in map.iter_mut() {
547 resolve_refs(v, schema_dir)?;
548 }
549 }
550 Ok(())
551}
552
553pub fn load_composed_schema(datadog_schema: &Path, otel_schema_dir: &Path) -> Result<Value, Error> {
555 let mut datadog_schema = load_resolved_schema(datadog_schema)?;
556 let otel_receiver = load_otel_receiver(otel_schema_dir)?;
557 patch_receiver(&mut datadog_schema, otel_receiver)?;
558 Ok(datadog_schema)
559}
560
561fn load_otel_receiver(otel_dir: &Path) -> Result<Value, Error> {
563 let schema_path = otel_dir.join("config.schema.yaml");
564 let doc = read_yaml(&schema_path)?;
565
566 let defs = doc
567 .get("$defs")
568 .and_then(|v| v.as_object())
569 .ok_or_else(|| Error::Validation("OTel schema missing $defs".to_string()))?
570 .clone();
571
572 let mut protocols = doc
574 .get("properties")
575 .and_then(|v| v.get("protocols"))
576 .cloned()
577 .ok_or_else(|| Error::Validation("OTel schema missing properties.protocols".to_string()))?;
578
579 resolve_otel_refs(&mut protocols, otel_dir, &defs)?;
581
582 convert_to_datadog_dialect(&mut protocols);
584
585 tag_otel_env_bindings(&mut protocols, &["protocols"]);
587
588 let mut receiver_section = Map::new();
590 receiver_section.insert("node_type".to_string(), Value::String("section".to_string()));
591 receiver_section.insert("type".to_string(), Value::String("object".to_string()));
592 let mut properties = Map::new();
593 properties.insert("protocols".to_string(), protocols);
594 receiver_section.insert("properties".to_string(), Value::Object(properties));
595
596 Ok(Value::Object(receiver_section))
597}
598
599fn patch_receiver(datadog_schema: &mut Value, otel_receiver: Value) -> Result<(), Error> {
602 let Some(otlp_config) = datadog_schema
603 .get_mut("properties")
604 .and_then(|v| v.get_mut("otlp_config"))
605 else {
606 return Ok(());
607 };
608
609 let Some(receiver) = otlp_config.get_mut("properties").and_then(|v| v.get_mut("receiver")) else {
610 return Ok(());
611 };
612
613 let datadog_receiver = std::mem::take(receiver);
614 let mut merged = otel_receiver;
615 merge_receiver_node(&mut merged, &datadog_receiver);
616 *receiver = merged;
617 Ok(())
618}
619
620fn merge_receiver_node(otel: &mut Value, datadog: &Value) {
622 let Some(otel_map) = otel.as_object_mut() else { return };
623 let Some(datadog_map) = datadog.as_object() else {
624 return;
625 };
626
627 for (key, val) in datadog_map.iter() {
630 if key == "properties" {
631 continue;
632 }
633 otel_map.insert(key.clone(), val.clone());
634 }
635
636 if let (Some(otel_props), Some(datadog_props)) = (
638 otel_map.get_mut("properties").and_then(|v| v.as_object_mut()),
639 datadog_map.get("properties").and_then(|v| v.as_object()),
640 ) {
641 let otel_keys: Vec<String> = otel_props.keys().cloned().collect();
643 for key in &otel_keys {
644 if let Some(datadog_val) = datadog_props.get(key) {
645 if let Some(otel_val) = otel_props.get_mut(key) {
646 merge_receiver_node(otel_val, datadog_val);
647 }
648 }
649 }
650 for (key, val) in datadog_props.iter() {
652 if !otel_props.contains_key(key) {
653 otel_props.insert(key.clone(), val.clone());
654 }
655 }
656 }
657}
658
659fn resolve_otel_refs(value: &mut Value, otel_dir: &Path, current_defs: &Map<String, Value>) -> Result<(), Error> {
661 let Some(map) = value.as_object_mut() else {
662 return Ok(());
663 };
664
665 if let Some(ref_str) = map.get("$ref").and_then(|v| v.as_str()) {
667 let siblings: Vec<(String, Value)> = map
669 .iter()
670 .filter(|(k, _)| *k != "$ref")
671 .map(|(k, v)| (k.clone(), v.clone()))
672 .collect();
673
674 let (mut resolved, source_defs) = resolve_otel_ref_target(ref_str, otel_dir, current_defs)?;
676
677 resolve_otel_refs(&mut resolved, otel_dir, &source_defs)?;
679
680 let mut resolved_siblings: Vec<(String, Value)> = Vec::new();
682 for (k, mut v) in siblings {
683 resolve_otel_refs(&mut v, otel_dir, current_defs)?;
684 resolved_siblings.push((k, v));
685 }
686
687 if let Some(resolved_map) = resolved.as_object_mut() {
689 for (k, v) in resolved_siblings {
690 resolved_map.insert(k, v);
691 }
692 }
693
694 *value = resolved;
695 return Ok(());
696 }
697
698 if let Some(allof) = map.remove("allOf") {
700 if let Some(allof_seq) = allof.as_array() {
701 for fragment in allof_seq {
702 let mut resolved_fragment = fragment.clone();
703 resolve_otel_refs(&mut resolved_fragment, otel_dir, current_defs)?;
704
705 if let Some(source_props) = resolved_fragment.get("properties").and_then(|v| v.as_object()) {
707 if map.get("properties").is_none() {
708 map.insert("properties".to_string(), Value::Object(Map::new()));
709 }
710 if let Some(target_props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
711 for (k, v) in source_props {
712 target_props.insert(k.clone(), v.clone());
713 }
714 }
715 }
716 }
717 }
718 }
719
720 if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
722 if let Some(auth) = props.get("auth") {
726 let auth_str = serde_json::to_string(auth).unwrap_or_default();
727 if auth_str.contains("$ref") {
728 props.remove("auth");
729 }
730 }
731 if let Some(middlewares) = props.get("middlewares") {
732 let mw_str = serde_json::to_string(middlewares).unwrap_or_default();
733 if mw_str.contains("$ref") {
734 props.remove("middlewares");
735 }
736 }
737 for (_, v) in props.iter_mut() {
738 resolve_otel_refs(v, otel_dir, current_defs)?;
739 }
740 }
741
742 Ok(())
743}
744
745fn resolve_otel_ref_target(
747 ref_str: &str, otel_dir: &Path, current_defs: &Map<String, Value>,
748) -> Result<(Value, Map<String, Value>), Error> {
749 if let Some(stripped) = ref_str.strip_prefix('/') {
750 let last_dot = stripped
752 .rfind('.')
753 .ok_or_else(|| Error::Validation(format!("invalid package-qualified ref (no dot): {ref_str}")))?;
754 let package_path = &stripped[..last_dot];
755 let def_name = &stripped[last_dot + 1..];
756
757 if package_path == "config/configopaque" {
759 let inline = match def_name {
760 "string" => Value::Object([("type".into(), "string".into())].into_iter().collect()),
761 "map_list" => {
762 let mut m = Map::new();
763 m.insert("type".into(), "object".into());
764 let mut addl = Map::new();
765 addl.insert("type".into(), "string".into());
766 m.insert("additionalProperties".into(), Value::Object(addl));
767 Value::Object(m)
768 }
769 other => return Err(Error::Validation(format!("unknown configopaque ref: {other}"))),
770 };
771 return Ok((inline, Map::new()));
772 }
773
774 if package_path == "config/configauth" || package_path == "config/configmiddleware" {
776 return Err(Error::Validation(format!(
777 "unexpected ref to excluded package: {ref_str}"
778 )));
779 }
780
781 let file_path = otel_dir.join(package_path).join("config.schema.yaml");
783 let file_doc = read_yaml(&file_path)?;
784 let file_defs = file_doc
785 .get("$defs")
786 .and_then(|v| v.as_object())
787 .ok_or_else(|| Error::Validation(format!("OTel schema {file_path:?} missing $defs")))?
788 .clone();
789 let def_value = file_defs
790 .get(def_name)
791 .cloned()
792 .ok_or_else(|| Error::Validation(format!("$defs.{def_name} not found in {file_path:?}")))?;
793
794 Ok((def_value, file_defs))
795 } else {
796 let def_value = current_defs
798 .get(ref_str)
799 .cloned()
800 .ok_or_else(|| Error::Validation(format!("local $def {ref_str} not found")))?;
801 Ok((def_value, current_defs.clone()))
802 }
803}
804
805fn convert_to_datadog_dialect(value: &mut Value) {
807 let Some(map) = value.as_object_mut() else {
808 return;
809 };
810
811 map.remove("x-optional");
813 map.remove("x-customType");
814
815 let is_section = map.get("properties").is_some();
817 let node_type = if is_section { "section" } else { "setting" };
818
819 if map.get("node_type").is_none() {
821 map.insert("node_type".to_string(), Value::String(node_type.to_string()));
822 }
823
824 if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
826 for (_, v) in props.iter_mut() {
827 convert_to_datadog_dialect(v);
828 }
829 }
830}
831
832const ENV_REGISTERED_OTEL_KEYS: &[&str] = &[
834 "otlp_config.receiver.protocols.grpc.endpoint",
835 "otlp_config.receiver.protocols.grpc.transport",
836 "otlp_config.receiver.protocols.grpc.max_recv_msg_size_mib",
837 "otlp_config.receiver.protocols.grpc.max_concurrent_streams",
838 "otlp_config.receiver.protocols.grpc.read_buffer_size",
839 "otlp_config.receiver.protocols.grpc.write_buffer_size",
840 "otlp_config.receiver.protocols.grpc.include_metadata",
841 "otlp_config.receiver.protocols.grpc.keepalive.enforcement_policy.min_time",
842 "otlp_config.receiver.protocols.http.endpoint",
843 "otlp_config.receiver.protocols.http.max_request_body_size",
844 "otlp_config.receiver.protocols.http.include_metadata",
845 "otlp_config.receiver.protocols.http.cors.allowed_headers",
846 "otlp_config.receiver.protocols.http.cors.allowed_origins",
847];
848
849fn tag_otel_env_bindings(value: &mut Value, path_parts: &[&str]) {
851 let Some(map) = value.as_object_mut() else { return };
852
853 if map.get("node_type").and_then(|v| v.as_str()) == Some("setting") {
854 let full_path = format!("otlp_config.receiver.{}", path_parts.join("."));
855 if ENV_REGISTERED_OTEL_KEYS.contains(&full_path.as_str()) {
856 let env_var = format!("DD_{}", full_path.replace('.', "_").to_uppercase());
857 let env_vars = Value::Array(vec![Value::String(env_var)]);
858 map.insert("env_vars".to_string(), env_vars);
859 } else {
860 let mut tags = map.get("tags").and_then(|v| v.as_array().cloned()).unwrap_or_default();
861 if !tags.iter().any(|t| t.as_str() == Some("no-env")) {
862 tags.push(Value::String("no-env".to_string()));
863 }
864 map.insert("tags".to_string(), Value::Array(tags));
865 }
866 return;
867 }
868
869 if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) {
870 for (key, val) in props.iter_mut() {
871 let mut parts = path_parts.to_vec();
872 parts.push(key);
873 tag_otel_env_bindings(val, &parts);
874 }
875 }
876}
877
878const VALIDATION_RULES: &str = "\n\
879 \n\
880 Rules that must hold in schema_overlay.yaml:\n\
881 - Every core_schema.yaml key appears in exactly one section (known / ignored).\n\
882 - No key appears in more than one section.\n\
883 - Sections appear in order: known, ignored.\n\
884 - Keys within each section are sorted alphabetically.\n\
885 - full entries: pipelines non-empty, used_by non-empty, description <= 50 chars.\n\
886 - partial entries: pipelines non-empty, used_by non-empty, description <= 50 chars, documentation required.\n\
887 - unsupported entries: pipelines non-empty, description <= 50 chars, planned+issue consistent.\n\
888 - unknown entries: description <= 50 chars (when present).\n\
889 - additional_yaml_paths: no duplicates within a single entry, no dots, no collisions with canonical keys.\n\
890 Fix: edit lib/datadog-agent/config/schema/schema_overlay.yaml.";
891
892#[derive(Debug)]
894pub enum Error {
895 Io((PathBuf, std::io::Error)),
896 Yaml(serde_saphyr::Error),
897 Validation(String),
898}
899
900impl std::fmt::Display for Error {
901 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902 match self {
903 Error::Io(e) => write!(f, "Error reading {}: {}", e.0.display(), e.1),
904 Error::Yaml(e) => write!(f, "YAML parse error in overlay: {e}"),
905 Error::Validation(s) => write!(f, "schema_overlay.yaml validation failed: {s}{VALIDATION_RULES}"),
906 }
907 }
908}
909
910impl std::error::Error for Error {
911 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
912 match self {
913 Error::Io(e) => Some(&e.1),
914 Error::Yaml(e) => Some(e),
915 Error::Validation(_) => None,
916 }
917 }
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 fn otel_schema_dir_for_tests() -> PathBuf {
926 Path::new(env!("CARGO_MANIFEST_DIR"))
927 .join("..")
928 .join("config")
929 .join("schema")
930 .join("otel")
931 }
932
933 #[test]
934 fn overlay_loads() {
935 let test_files = Files {
936 datadog_schema: Path::new(env!("CARGO_MANIFEST_DIR"))
937 .join("test")
938 .join("fake_schema.yaml"),
939 otel_schema_dir: otel_schema_dir_for_tests(),
940 overlay: Path::new(env!("CARGO_MANIFEST_DIR"))
941 .join("test")
942 .join("fake_overlay.yaml"),
943 };
944 let validated = SchemaOverlay::load(test_files).unwrap();
945 assert_eq!(validated.inventory.len(), 18);
946 }
947
948 #[test]
949 fn pipeline_affinity_cross_cutting() {
950 let yaml = "pipelines: [cross_cutting]";
951 #[derive(Deserialize)]
952 struct W {
953 pipelines: PipelineAffinity,
954 }
955 let w: W = serde_saphyr::from_str(yaml).unwrap();
956 assert!(matches!(w.pipelines, PipelineAffinity::CrossCutting));
957 }
958
959 #[test]
960 fn pipeline_affinity_multi() {
961 let yaml = "pipelines: [dogstatsd, traces]";
962 #[derive(Deserialize)]
963 struct W {
964 pipelines: PipelineAffinity,
965 }
966 let w: W = serde_saphyr::from_str(yaml).unwrap();
967 if let PipelineAffinity::Pipelines(ps) = w.pipelines {
968 assert_eq!(ps.len(), 2);
969 assert!(matches!(ps[0], Pipeline::DogStatsD));
970 assert!(matches!(ps[1], Pipeline::Traces));
971 } else {
972 panic!("expected Pipelines");
973 }
974 }
975
976 #[test]
977 fn pipeline_affinity_cross_cutting_must_be_alone() {
978 let yaml = "pipelines: [cross_cutting, dogstatsd]";
979 #[derive(Deserialize)]
980 #[allow(dead_code)]
981 struct W {
982 pipelines: PipelineAffinity,
983 }
984 assert!(serde_saphyr::from_str::<W>(yaml).is_err());
985 }
986
987 fn load_from_strs(schema: &str, overlay: &str) -> Result<SchemaOverlay, Error> {
988 let dir = tempfile::tempdir().unwrap();
989 let schema_path = dir.path().join("fake_schema.yaml");
990 let overlay_path = dir.path().join("overlay.yaml");
991 std::fs::write(&schema_path, schema).unwrap();
992 std::fs::write(&overlay_path, overlay).unwrap();
993 let otel_schema_dir = otel_schema_dir_for_tests();
994 SchemaOverlay::load(Files {
995 datadog_schema: schema_path,
996 otel_schema_dir,
997 overlay: overlay_path,
998 })
999 }
1000
1001 #[test]
1002 fn validation_rejects_schema_key_missing_from_overlay() {
1003 let schema = "\
1004properties:
1005 key_a:
1006 type: string
1007 key_b:
1008 type: string
1009";
1010 let overlay = "\
1011inventory:
1012 key_a:
1013 support: full
1014 pipelines: [cross_cutting]
1015 description: \"Key A\"
1016 test_support:
1017 used_by: [TypedConfigSystem]
1018excluded: {}
1019";
1020 let err = load_from_strs(schema, overlay).unwrap_err();
1021 assert!(
1022 err.to_string().contains("schema key 'key_b' is not covered"),
1023 "unexpected error: {err}"
1024 );
1025 }
1026
1027 #[test]
1028 fn validation_rejects_overlay_key_absent_from_schema() {
1029 let schema = "\
1030properties:
1031 key_a:
1032 type: string
1033";
1034 let overlay = "\
1035inventory:
1036 key_a:
1037 support: full
1038 pipelines: [cross_cutting]
1039 description: \"Key A\"
1040 test_support:
1041 used_by: [TypedConfigSystem]
1042excluded:
1043 key_b: \"not in schema\"
1044";
1045 let err = load_from_strs(schema, overlay).unwrap_err();
1046 assert!(
1047 err.to_string().contains("overlay key 'key_b' is not present"),
1048 "unexpected error: {err}"
1049 );
1050 }
1051
1052 #[test]
1053 fn validation_rejects_key_in_two_sections() {
1054 let schema = "\
1055properties:
1056 key_a:
1057 type: string
1058 key_b:
1059 type: string
1060";
1061 let overlay = "\
1062inventory:
1063 key_a:
1064 support: full
1065 pipelines: [cross_cutting]
1066 description: \"Key A\"
1067 test_support:
1068 used_by: [TypedConfigSystem]
1069excluded:
1070 key_a: \"duplicate\"
1071 key_b: \"ok\"
1072";
1073 let err = load_from_strs(schema, overlay).unwrap_err();
1074 assert!(
1075 err.to_string()
1076 .contains("key 'key_a' appears in more than one overlay section"),
1077 "unexpected error: {err}"
1078 );
1079 }
1080
1081 #[test]
1083 fn schema_ref_is_resolved_and_keys_namespaced() {
1084 let dir = tempfile::tempdir().unwrap();
1085 std::fs::write(
1086 dir.path().join("sub.yaml"),
1087 "properties:\n enabled:\n type: boolean\n",
1088 )
1089 .unwrap();
1090 let schema_path = dir.path().join("core_schema.yaml");
1091 std::fs::write(&schema_path, "properties:\n feature:\n $ref: sub.yaml\n").unwrap();
1092
1093 let keys = SchemaOverlay::schema_keys(&schema_path, &otel_schema_dir_for_tests()).unwrap();
1094 assert_eq!(
1095 keys,
1096 HashSet::from(["feature.enabled".to_string()]),
1097 "unexpected keys: {keys:?}"
1098 );
1099 }
1100
1101 #[test]
1104 fn missing_schema_ref_reports_io_error() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let schema_path = dir.path().join("core_schema.yaml");
1107 std::fs::write(&schema_path, "properties:\n feature:\n $ref: does_not_exist.yaml\n").unwrap();
1108
1109 let err = SchemaOverlay::schema_keys(&schema_path, &otel_schema_dir_for_tests()).unwrap_err();
1110 assert!(matches!(err, Error::Io(_)), "expected Io error, got: {err}");
1111 assert!(
1112 err.to_string().contains("does_not_exist.yaml"),
1113 "error should name the missing file: {err}"
1114 );
1115 }
1116
1117 #[test]
1118 fn validation_rejects_unsorted_inventory_keys() {
1119 let schema = "\
1120properties:
1121 key_a:
1122 type: string
1123 key_b:
1124 type: string
1125";
1126 let overlay = "\
1127inventory:
1128 key_b:
1129 support: full
1130 pipelines: [cross_cutting]
1131 description: \"Key B\"
1132 test_support:
1133 used_by: [TypedConfigSystem]
1134 key_a:
1135 support: full
1136 pipelines: [cross_cutting]
1137 description: \"Key A\"
1138 test_support:
1139 used_by: [TypedConfigSystem]
1140excluded: {}
1141";
1142 let err = load_from_strs(schema, overlay).unwrap_err();
1143 assert!(
1144 err.to_string().contains("out of alphabetical order"),
1145 "unexpected error: {err}"
1146 );
1147 }
1148
1149 fn validate_entries_of(overlay: &str) -> Result<(), Error> {
1153 SchemaOverlay::from_yaml(overlay)
1154 .expect("overlay should deserialize")
1155 .validate_entries()
1156 }
1157
1158 #[test]
1159 fn per_entry_validation_accepts_a_well_formed_entry_of_every_kind() {
1160 let overlay = "\
1163inventory:
1164 full_key:
1165 support: full
1166 pipelines: [dogstatsd]
1167 description: \"Fully supported key\"
1168 test_support:
1169 used_by: [TypedConfigSystem]
1170 additional_yaml_paths: [full_alias]
1171 partial_key:
1172 support: partial
1173 pipelines: [traces]
1174 description: \"Partially supported key\"
1175 documentation: \"Behaves differently from the core agent.\"
1176 test_support:
1177 used_by: [TypedConfigSystem]
1178 unknown_key:
1179 support: unknown
1180 description: \"Not yet classified\"
1181 unsupported_key:
1182 support: none
1183 pipelines: [checks]
1184 description: \"Unsupported key\"
1185 severity: high
1186 planned: true
1187 issue: \"1234\"
1188excluded: {}
1189";
1190 validate_entries_of(overlay).expect("a well-formed overlay should pass per-entry validation");
1191 }
1192
1193 #[test]
1194 fn per_entry_validation_rejects_over_long_description_for_every_entry_kind() {
1195 let long = "x".repeat(60);
1198 let cases: &[(&str, String)] = &[
1199 (
1200 "full",
1201 format!(
1202 "inventory:\n key_a:\n support: full\n pipelines: [dogstatsd]\n \
1203 description: \"{long}\"\n test_support:\n used_by: [TypedConfigSystem]\nexcluded: {{}}\n"
1204 ),
1205 ),
1206 (
1207 "partial",
1208 format!(
1209 "inventory:\n key_a:\n support: partial\n pipelines: [dogstatsd]\n \
1210 description: \"{long}\"\n documentation: \"diverges\"\n test_support:\n \
1211 used_by: [TypedConfigSystem]\nexcluded: {{}}\n"
1212 ),
1213 ),
1214 (
1215 "unsupported",
1216 format!(
1217 "inventory:\n key_a:\n support: none\n pipelines: [dogstatsd]\n \
1218 description: \"{long}\"\n severity: low\n planned: false\nexcluded: {{}}\n"
1219 ),
1220 ),
1221 (
1222 "unknown",
1223 format!("inventory:\n key_a:\n support: unknown\n description: \"{long}\"\nexcluded: {{}}\n"),
1224 ),
1225 ];
1226
1227 for (kind, overlay) in cases {
1228 let err = validate_entries_of(overlay).expect_err(&format!("{kind} entry should be rejected"));
1229 assert!(
1230 err.to_string().contains("description exceeds 50 chars"),
1231 "{kind}: unexpected error: {err}"
1232 );
1233 }
1234 }
1235
1236 #[test]
1237 fn per_entry_validation_rejects_empty_used_by_for_full_and_partial_entries() {
1238 let full = "\
1239inventory:
1240 key_a:
1241 support: full
1242 pipelines: [dogstatsd]
1243 description: \"Key A\"
1244 test_support:
1245 used_by: []
1246excluded: {}
1247";
1248 let err = validate_entries_of(full).expect_err("full entry with empty used_by should be rejected");
1249 assert!(
1250 err.to_string().contains("full key 'key_a': used_by must be non-empty"),
1251 "unexpected error: {err}"
1252 );
1253
1254 let partial = "\
1255inventory:
1256 key_a:
1257 support: partial
1258 pipelines: [dogstatsd]
1259 description: \"Key A\"
1260 documentation: \"diverges\"
1261 test_support:
1262 used_by: []
1263excluded: {}
1264";
1265 let err = validate_entries_of(partial).expect_err("partial entry with empty used_by should be rejected");
1266 assert!(
1267 err.to_string()
1268 .contains("partial key 'key_a': used_by must be non-empty"),
1269 "unexpected error: {err}"
1270 );
1271 }
1272
1273 #[test]
1274 fn per_entry_validation_rejects_planned_unsupported_entry_without_issue() {
1275 let overlay = "\
1276inventory:
1277 key_a:
1278 support: none
1279 pipelines: [dogstatsd]
1280 description: \"Key A\"
1281 severity: medium
1282 planned: true
1283excluded: {}
1284";
1285 let err = validate_entries_of(overlay).expect_err("planned unsupported entry without issue should be rejected");
1286 assert!(
1287 err.to_string()
1288 .contains("unsupported key 'key_a': planned requires an issue"),
1289 "unexpected error: {err}"
1290 );
1291 }
1292
1293 #[test]
1294 fn per_entry_validation_rejects_duplicate_additional_yaml_path() {
1295 let overlay = "\
1296inventory:
1297 key_a:
1298 support: full
1299 pipelines: [dogstatsd]
1300 description: \"Key A\"
1301 test_support:
1302 used_by: [TypedConfigSystem]
1303 additional_yaml_paths: [dup_alias, dup_alias]
1304excluded: {}
1305";
1306 let err = validate_entries_of(overlay).expect_err("duplicate additional_yaml_path should be rejected");
1307 assert!(
1308 err.to_string()
1309 .contains("key 'key_a': duplicate additional_yaml_path 'dup_alias'"),
1310 "unexpected error: {err}"
1311 );
1312 }
1313
1314 #[test]
1315 fn per_entry_validation_rejects_dotted_additional_yaml_path() {
1316 let overlay = "\
1319inventory:
1320 key_a:
1321 support: full
1322 pipelines: [dogstatsd]
1323 description: \"Key A\"
1324 test_support:
1325 used_by: [TypedConfigSystem]
1326 additional_yaml_paths: [\"nested.alias\"]
1327excluded: {}
1328";
1329 let err = validate_entries_of(overlay).expect_err("dotted additional_yaml_path should be rejected");
1330 assert!(
1331 err.to_string()
1332 .contains("additional_yaml_path 'nested.alias' contains a dot"),
1333 "unexpected error: {err}"
1334 );
1335 }
1336
1337 #[test]
1338 fn per_entry_validation_rejects_additional_yaml_path_colliding_with_canonical_key() {
1339 let overlay = "\
1342inventory:
1343 alpha:
1344 support: full
1345 pipelines: [dogstatsd]
1346 description: \"Alpha\"
1347 test_support:
1348 used_by: [TypedConfigSystem]
1349 additional_yaml_paths: [beta]
1350 beta:
1351 support: full
1352 pipelines: [dogstatsd]
1353 description: \"Beta\"
1354 test_support:
1355 used_by: [TypedConfigSystem]
1356excluded: {}
1357";
1358 let err = validate_entries_of(overlay).expect_err("aliasing a canonical key should be rejected");
1359 assert!(
1360 err.to_string()
1361 .contains("additional_yaml_path 'beta' collides with a canonical"),
1362 "unexpected error: {err}"
1363 );
1364 }
1365
1366 #[test]
1367 fn verify_otel_receiver_env_bindings() {
1368 let schema_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1369 .join("..")
1370 .join("config")
1371 .join("schema")
1372 .join("core")
1373 .join("core_schema.yaml");
1374 let otel_schema_dir = otel_schema_dir_for_tests();
1375 let schema_map = crate::schema_gen::load_schema(&schema_path, &otel_schema_dir);
1376
1377 let registered: HashSet<&str> = super::ENV_REGISTERED_OTEL_KEYS.iter().copied().collect();
1378
1379 for (key, info) in &schema_map {
1380 if !key.starts_with("otlp_config.receiver.protocols.") {
1381 continue;
1382 }
1383 if registered.contains(key.as_str()) {
1384 assert!(
1385 matches!(info.env, crate::schema_gen::EnvBinding::Overridden(_)),
1386 "registered key `{key}` should have explicit env_vars"
1387 );
1388 } else {
1389 assert!(
1390 matches!(info.env, crate::schema_gen::EnvBinding::None),
1391 "unregistered key `{key}` should be no-env"
1392 );
1393 }
1394 }
1395 }
1396}