Skip to main content

saluki_env/autodiscovery/
mod.rs

1//! Autodiscovery provider.
2//!
3//! This module provides the `Autodiscovery` trait, which deals with providing information about autodiscovery.
4
5use std::collections::BTreeMap;
6use std::hash::Hasher;
7
8use async_trait::async_trait;
9use datadog_protos::agent::{
10    AdvancedAdIdentifier as ProtoAdvancedAdIdentifier, Config as ProtoConfig, ConfigEventType,
11    KubeNamespacedName as ProtoKubeNamespacedName,
12};
13use fnv::FnvHasher;
14use saluki_error::GenericError;
15use stringtheory::MetaString;
16use tokio::sync::mpsc::Receiver;
17use twox_hash::XxHash64;
18
19pub mod providers;
20
21/// Configuration event type
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum EventType {
24    /// Schedule a configuration
25    Schedule,
26    /// Unschedule a configuration
27    Unschedule,
28}
29
30impl From<ConfigEventType> for EventType {
31    fn from(event_type: ConfigEventType) -> Self {
32        match event_type {
33            ConfigEventType::Schedule => EventType::Schedule,
34            ConfigEventType::Unschedule => EventType::Unschedule,
35        }
36    }
37}
38
39impl From<i32> for EventType {
40    fn from(value: i32) -> Self {
41        if value == ConfigEventType::Unschedule as i32 {
42            EventType::Unschedule
43        } else {
44            // Default to Schedule for unknown values
45            EventType::Schedule
46        }
47    }
48}
49
50/// Kubernetes namespaced name
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct KubeNamespacedName {
53    /// Kubernetes resource name
54    pub name: MetaString,
55    /// Kubernetes namespace
56    pub namespace: MetaString,
57}
58
59impl From<ProtoKubeNamespacedName> for KubeNamespacedName {
60    fn from(value: ProtoKubeNamespacedName) -> Self {
61        Self {
62            name: value.name.into(),
63            namespace: value.namespace.into(),
64        }
65    }
66}
67
68/// Kubernetes endpoints identifier
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct KubeEndpointsIdentifier {
71    /// Kubernetes endpoints resource
72    pub kube_namespaced_name: KubeNamespacedName,
73    /// Endpoints resolution mode
74    pub resolve: MetaString,
75}
76
77/// Advanced autodiscovery identifier
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AdvancedADIdentifier {
80    /// Kubernetes service
81    pub kube_service: Option<KubeNamespacedName>,
82    /// Kubernetes endpoints
83    pub kube_endpoints: Option<KubeEndpointsIdentifier>,
84}
85
86impl From<ProtoAdvancedAdIdentifier> for AdvancedADIdentifier {
87    fn from(value: ProtoAdvancedAdIdentifier) -> Self {
88        Self {
89            kube_service: value.kube_service.map(Into::into),
90            kube_endpoints: value.kube_endpoints.and_then(|endpoints| {
91                endpoints
92                    .kube_namespaced_name
93                    .map(|namespaced_name| KubeEndpointsIdentifier {
94                        kube_namespaced_name: namespaced_name.into(),
95                        resolve: endpoints.resolve.into(),
96                    })
97            }),
98        }
99    }
100}
101
102/// Raw data trait for configuration data
103pub trait RawData {
104    /// Get the value of the data
105    fn get_value(&self) -> &BTreeMap<MetaString, serde_json::Value>;
106
107    /// Get a value from the data
108    fn get(&self, key: &str) -> Option<&serde_json::Value> {
109        self.get_value().get(key)
110    }
111
112    /// Convert the data to bytes
113    fn to_bytes(&self) -> Result<Vec<u8>, GenericError> {
114        let mut buffer = Vec::new();
115        serde_saphyr::to_io_writer(&mut buffer, &self.get_value())?;
116        Ok(buffer)
117    }
118}
119
120/// Generic map of key-value pairs
121#[derive(Debug, Default, Clone, PartialEq, Eq)]
122pub struct Data {
123    value: BTreeMap<MetaString, serde_json::Value>,
124}
125
126impl RawData for Data {
127    fn get_value(&self) -> &BTreeMap<MetaString, serde_json::Value> {
128        &self.value
129    }
130}
131
132/// Configuration for a check instance
133#[derive(Debug, Default, Clone, PartialEq, Eq)]
134pub struct Instance {
135    /// Instance ID
136    id: String,
137    /// Instance value
138    value: BTreeMap<MetaString, serde_json::Value>,
139}
140
141impl Instance {
142    /// Get the instance ID
143    pub fn id(&self) -> &String {
144        &self.id
145    }
146}
147
148impl RawData for Instance {
149    fn get_value(&self) -> &BTreeMap<MetaString, serde_json::Value> {
150        &self.value
151    }
152}
153
154/// Configuration for a check
155#[derive(Debug, Clone)]
156pub struct Config {
157    /// Configuration name/identifier
158    pub name: MetaString,
159    /// Raw configuration data
160    pub init_config: Data,
161    /// Instance configurations
162    pub instances: Vec<Data>,
163    /// Metric configuration
164    pub metric_config: Data,
165    /// Logs configuration
166    pub logs_config: Data,
167    /// Auto-discovery identifiers
168    pub ad_identifiers: Vec<MetaString>,
169    /// Advanced auto-discovery identifiers
170    pub advanced_ad_identifiers: Vec<AdvancedADIdentifier>,
171    /// Provider that discovered this config
172    pub provider: MetaString,
173    /// Service ID
174    pub service_id: MetaString,
175    /// Tagger entity
176    pub tagger_entity: MetaString,
177    /// Whether this is a cluster check
178    pub cluster_check: bool,
179    /// Node name
180    pub node_name: MetaString,
181    /// Source of the configuration
182    pub source: MetaString,
183    /// Whether to ignore autodiscovery tags
184    pub ignore_autodiscovery_tags: bool,
185    /// Whether metrics are excluded
186    pub metrics_excluded: bool,
187    /// Whether logs are excluded
188    pub logs_excluded: bool,
189}
190
191/// Check configuration
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct CheckConfig {
194    /// Check name
195    pub name: MetaString,
196    /// Init configuration data
197    pub init_config: Data,
198    /// Instance configurations
199    pub instances: Vec<Instance>,
200    /// Metric configuration
201    pub metric_config: Data,
202    /// Logs configuration
203    pub logs_config: Data,
204    /// Auto-discovery identifiers
205    pub ad_identifiers: Vec<MetaString>,
206    /// Advanced auto-discovery identifiers
207    pub advanced_ad_identifiers: Vec<AdvancedADIdentifier>,
208    /// Provider that discovered this config
209    pub provider: MetaString,
210    /// Service ID
211    pub service_id: MetaString,
212    /// Tagger entity
213    pub tagger_entity: MetaString,
214    /// Whether this is a cluster check
215    pub cluster_check: bool,
216    /// Node name
217    pub node_name: MetaString,
218    /// Source of the configuration
219    pub source: MetaString,
220    /// Whether to ignore autodiscovery tags
221    pub ignore_autodiscovery_tags: bool,
222    /// Whether metrics are excluded
223    pub metrics_excluded: bool,
224    /// Whether logs are excluded
225    pub logs_excluded: bool,
226}
227
228impl Config {
229    /// Get the digest for the config
230    pub fn digest(&self) -> u64 {
231        let mut h = XxHash64::with_seed(0);
232
233        h.write(self.name.as_bytes());
234        for i in &self.instances {
235            h.write(&i.to_bytes().unwrap_or_default());
236        }
237        h.write(&self.init_config.to_bytes().unwrap_or_default());
238        for i in &self.ad_identifiers {
239            h.write(i.as_bytes());
240        }
241        h.write(self.node_name.as_bytes());
242        h.write(&self.logs_config.to_bytes().unwrap_or_default());
243        h.write(self.service_id.as_bytes());
244        h.write(if self.ignore_autodiscovery_tags {
245            b"true"
246        } else {
247            b"false"
248        });
249
250        h.finish()
251    }
252}
253
254impl From<ProtoConfig> for Config {
255    fn from(proto: ProtoConfig) -> Self {
256        // Convert advanced AD identifiers from proto
257        let advanced_ad_identifiers = proto.advanced_ad_identifiers.into_iter().map(Into::into).collect();
258
259        let init_config = bytes_to_data(proto.init_config).unwrap_or_default();
260        let instances = proto
261            .instances
262            .into_iter()
263            .map(|instance| bytes_to_data(instance).unwrap_or_default())
264            .collect();
265
266        Self {
267            name: proto.name.into(),
268            init_config,
269            instances,
270            metric_config: bytes_to_data(proto.metric_config).unwrap_or_default(),
271            logs_config: bytes_to_data(proto.logs_config).unwrap_or_default(),
272            ad_identifiers: proto.ad_identifiers.into_iter().map(MetaString::from).collect(),
273            advanced_ad_identifiers,
274            provider: proto.provider.into(),
275            service_id: proto.service_id.into(),
276            tagger_entity: proto.tagger_entity.into(),
277            cluster_check: proto.cluster_check,
278            node_name: proto.node_name.into(),
279            source: proto.source.into(),
280            ignore_autodiscovery_tags: proto.ignore_autodiscovery_tags,
281            metrics_excluded: proto.metrics_excluded,
282            logs_excluded: proto.logs_excluded,
283        }
284    }
285}
286
287/// Deserializes a YAML check configuration using YAML 1.1 scalar resolution.
288///
289/// Check configurations are written against YAML 1.1 parsers, so `yes`/`no`/`on`/`off` are booleans, leading-zero
290/// integers are octal, and `<<` merge keys are expanded. Non-finite floats are kept as their string spelling.
291///
292/// The default limits on the number of parsed nodes and events are lifted, as check configurations can legitimately
293/// hold very large lists. Nesting depth, alias expansion, and total scalar size are still bounded.
294///
295/// Errors report only a message and location, never the surrounding source lines, as check configurations can hold
296/// credentials. Comments are not retained, so runs of comments don't count against the comment limits.
297pub(crate) fn yaml_from_str<T>(input: &str) -> Result<T, serde_saphyr::Error>
298where
299    T: serde::de::DeserializeOwned,
300{
301    let mut budget = serde_saphyr::Budget::default();
302    budget.max_nodes = usize::MAX;
303    budget.max_events = usize::MAX;
304
305    let options = serde_saphyr::options! {
306        budget: Some(budget),
307        emit_comments: false,
308        legacy_octal_numbers: true,
309        reject_non_finite_typeless_float: false,
310        with_snippet: false,
311    };
312    serde_saphyr::from_str_with_options(input, options)
313}
314
315fn bytes_to_data(bytes: Vec<u8>) -> Result<Data, GenericError> {
316    let parse_bytes = String::from_utf8(bytes)?;
317
318    let map: BTreeMap<String, serde_json::Value> = yaml_from_str(&parse_bytes)?;
319
320    let mut result = BTreeMap::<MetaString, serde_json::Value>::new();
321
322    for (key, value) in map {
323        result.insert(key.into(), value);
324    }
325
326    Ok(Data { value: result })
327}
328
329fn instance_id(name: &str, instance: &Data, digest: u64, init_config: &Data) -> String {
330    let mut h2 = FnvHasher::default();
331    h2.write_u64(digest);
332    h2.write(&instance.to_bytes().unwrap_or_default());
333    h2.write(&init_config.to_bytes().unwrap_or_default());
334
335    let instance_name = instance_name(instance);
336    let hash2 = h2.finish();
337
338    if !instance_name.is_empty() {
339        format!("{}:{}:{:X}", name, instance_name, hash2)
340    } else {
341        format!("{}:{:X}", name, hash2)
342    }
343}
344
345fn instance_name(instance: &Data) -> String {
346    if let Some(name) = instance.get("name") {
347        if let Some(value) = name.as_str() {
348            return value.to_string();
349        }
350    }
351    if let Some(namespace) = instance.get("namespace") {
352        if let Some(value) = namespace.as_str() {
353            return value.to_string();
354        }
355    }
356    "".to_string()
357}
358
359impl From<Config> for CheckConfig {
360    fn from(config: Config) -> Self {
361        let digest = config.digest();
362        let Config {
363            name,
364            init_config,
365            instances,
366            metric_config,
367            logs_config,
368            ad_identifiers,
369            advanced_ad_identifiers,
370            provider,
371            service_id,
372            tagger_entity,
373            cluster_check,
374            node_name,
375            source,
376            ignore_autodiscovery_tags,
377            metrics_excluded,
378            logs_excluded,
379        } = config;
380        let instances = instances
381            .into_iter()
382            .map(|instance_data| Instance {
383                id: instance_id(&name, &instance_data, digest, &init_config),
384                value: instance_data.value,
385            })
386            .collect();
387
388        Self {
389            name,
390            init_config,
391            instances,
392            metric_config,
393            logs_config,
394            ad_identifiers,
395            advanced_ad_identifiers,
396            provider,
397            service_id,
398            tagger_entity,
399            cluster_check,
400            node_name,
401            source,
402            ignore_autodiscovery_tags,
403            metrics_excluded,
404            logs_excluded,
405        }
406    }
407}
408
409impl From<ProtoConfig> for AutodiscoveryEvent {
410    fn from(proto: ProtoConfig) -> AutodiscoveryEvent {
411        let event_type = EventType::from(proto.event_type);
412
413        let config = Config::from(proto);
414
415        if !config.instances.is_empty() && !config.cluster_check {
416            let check_config = CheckConfig::from(config);
417
418            if event_type == EventType::Schedule {
419                return AutodiscoveryEvent::CheckSchedule { config: check_config };
420            } else {
421                return AutodiscoveryEvent::CheckUnscheduled { config: check_config };
422            }
423        }
424
425        if event_type == EventType::Schedule {
426            AutodiscoveryEvent::Schedule { config }
427        } else {
428            AutodiscoveryEvent::Unscheduled { config }
429        }
430    }
431}
432
433/// An autodiscovery event
434#[derive(Debug, Clone)]
435#[allow(clippy::large_enum_variant)]
436pub enum AutodiscoveryEvent {
437    /// Schedule a check configuration
438    CheckSchedule {
439        /// Configuration
440        config: CheckConfig,
441    },
442    /// Unschedule a check configuration
443    CheckUnscheduled {
444        /// Configuration
445        config: CheckConfig,
446    },
447    /// Schedule a generic configuration
448    Schedule {
449        /// Configuration
450        config: Config,
451    },
452    /// Unscheduled a generic configuration
453    Unscheduled {
454        /// Configuration
455        config: Config,
456    },
457}
458
459/// Provides autodiscovery functionality.
460///
461/// This trait is used to discover and monitor configuration files for checks.
462#[async_trait]
463pub trait AutodiscoveryProvider {
464    /// Subscribe to autodiscovery events.
465    ///
466    /// Returns `None` when no provider is configured.
467    async fn subscribe(&self) -> Option<Receiver<AutodiscoveryEvent>>;
468}
469
470#[async_trait]
471impl<T> AutodiscoveryProvider for Option<T>
472where
473    T: AutodiscoveryProvider + Sync,
474{
475    async fn subscribe(&self) -> Option<Receiver<AutodiscoveryEvent>> {
476        match self.as_ref() {
477            Some(provider) => provider.subscribe().await,
478            None => None,
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use datadog_protos::agent::{
486        AdvancedAdIdentifier, KubeEndpointsIdentifier as ProtoKubeEndpointsIdentifier, KubeNamespacedName,
487    };
488
489    use super::*;
490
491    // Builds a `ProtoConfig` with representative defaults for every field, so tests can override only the fields
492    // they actually exercise (via struct-update syntax) instead of restating the full 17-field literal.
493    fn base_proto_config() -> ProtoConfig {
494        ProtoConfig {
495            name: "test-config".to_string(),
496            event_type: ConfigEventType::Schedule as i32,
497            init_config: b"key: value".to_vec(),
498            instances: vec![],
499            provider: "test-provider".to_string(),
500            ad_identifiers: vec!["id1".to_string(), "id2".to_string()],
501            cluster_check: false,
502            metric_config: b"metric_key: metric_value".to_vec(),
503            logs_config: b"log_key: log_value".to_vec(),
504            advanced_ad_identifiers: vec![],
505            service_id: "service-id".to_string(),
506            tagger_entity: "tagger-entity".to_string(),
507            node_name: "node-name".to_string(),
508            source: "source".to_string(),
509            ignore_autodiscovery_tags: false,
510            metrics_excluded: false,
511            logs_excluded: false,
512        }
513    }
514
515    #[test]
516    fn test_event_type_from_config_event_type() {
517        assert_eq!(EventType::from(ConfigEventType::Schedule), EventType::Schedule);
518        assert_eq!(EventType::from(ConfigEventType::Unschedule), EventType::Unschedule);
519    }
520
521    #[test]
522    fn test_event_type_from_i32() {
523        // Known values
524        assert_eq!(EventType::from(0), EventType::Schedule); // Schedule is 0
525        assert_eq!(EventType::from(1), EventType::Unschedule); // Unschedule is 1
526
527        // Unknown values should default to Schedule
528        assert_eq!(EventType::from(2), EventType::Schedule);
529        assert_eq!(EventType::from(-1), EventType::Schedule);
530    }
531
532    #[test]
533    fn test_check_config_instance_id() {
534        let proto_config = ProtoConfig {
535            name: "test-check".to_string(),
536            instances: vec![b"name: test".to_vec(), b"another_key: another_value".to_vec()],
537            ..base_proto_config()
538        };
539
540        let config = Config::from(proto_config);
541
542        let check_config = CheckConfig::from(config);
543
544        let id1 = &check_config.instances[0].id;
545        let id2 = &check_config.instances[1].id;
546
547        assert_ne!(id1, id2);
548
549        assert_eq!(id1, "test-check:test:369F074E36651C8");
550        assert_eq!(id2, "test-check:8C83712B7A572843");
551    }
552
553    #[test]
554    fn test_config_from_proto_config() {
555        // Create a ProtoConfig with test values
556        let mut proto_config = ProtoConfig {
557            instances: vec![
558                b"instance_key: instance_value".to_vec(),
559                b"another_key: another_value".to_vec(),
560            ],
561            cluster_check: true,
562            ..base_proto_config()
563        };
564
565        let kube_svc = KubeNamespacedName {
566            name: "nginx".to_string(),
567            namespace: "default".to_string(),
568        };
569
570        let adv_id = AdvancedAdIdentifier {
571            kube_service: Some(kube_svc),
572            kube_endpoints: None,
573        };
574
575        proto_config.advanced_ad_identifiers = vec![adv_id];
576
577        let config = Config::from(proto_config);
578
579        assert_eq!(config.name, "test-config");
580        assert_eq!(config.provider, "test-provider");
581        assert_eq!(
582            config.ad_identifiers,
583            vec![MetaString::from_static("id1"), MetaString::from_static("id2")]
584        );
585        assert!(config.cluster_check);
586        assert_eq!(config.service_id, "service-id");
587        assert_eq!(config.tagger_entity, "tagger-entity");
588        assert_eq!(config.node_name, "node-name");
589        assert_eq!(config.source, "source");
590        assert!(!config.ignore_autodiscovery_tags);
591        assert!(!config.metrics_excluded);
592        assert!(!config.logs_excluded);
593        assert_eq!(
594            config.init_config.get("key"),
595            Some(&serde_json::Value::String("value".to_string()))
596        );
597        assert_eq!(config.instances.len(), 2);
598        assert_eq!(
599            config.instances[0].get("instance_key"),
600            Some(&serde_json::Value::String("instance_value".to_string()))
601        );
602        assert_eq!(
603            config.instances[1].get("another_key"),
604            Some(&serde_json::Value::String("another_value".to_string()))
605        );
606        assert_eq!(
607            config.metric_config.get("metric_key"),
608            Some(&serde_json::Value::String("metric_value".to_string()))
609        );
610        assert_eq!(
611            config.logs_config.get("log_key"),
612            Some(&serde_json::Value::String("log_value".to_string()))
613        );
614
615        assert_eq!(config.advanced_ad_identifiers.len(), 1);
616        let adv_id = &config.advanced_ad_identifiers[0];
617        assert!(adv_id.kube_endpoints.is_none());
618        assert!(adv_id.kube_service.is_some());
619        let svc = adv_id.kube_service.as_ref().unwrap();
620        assert_eq!(svc.name, "nginx");
621        assert_eq!(svc.namespace, "default");
622    }
623
624    #[test]
625    fn test_advanced_ad_identifier_from_proto_retains_endpoints_resolve() {
626        fn proto_id(resolve: &str) -> AdvancedAdIdentifier {
627            AdvancedAdIdentifier {
628                kube_service: None,
629                kube_endpoints: Some(ProtoKubeEndpointsIdentifier {
630                    kube_namespaced_name: Some(KubeNamespacedName {
631                        name: "nginx".to_string(),
632                        namespace: "default".to_string(),
633                    }),
634                    resolve: resolve.to_string(),
635                }),
636            }
637        }
638
639        let adv_id = AdvancedADIdentifier::from(proto_id("ip"));
640
641        assert!(adv_id.kube_service.is_none());
642        let endpoints = adv_id.kube_endpoints.as_ref().expect("endpoints should be present");
643        assert_eq!(endpoints.kube_namespaced_name.name, "nginx");
644        assert_eq!(endpoints.kube_namespaced_name.namespace, "default");
645        assert_eq!(endpoints.resolve, "ip");
646
647        // Identifiers that only differ by their resolution mode must remain distinguishable.
648        assert_ne!(adv_id, AdvancedADIdentifier::from(proto_id("auto")));
649    }
650
651    #[test]
652    fn test_advanced_ad_identifier_from_proto_drops_endpoints_without_namespaced_name() {
653        let proto_id = AdvancedAdIdentifier {
654            kube_service: None,
655            kube_endpoints: Some(ProtoKubeEndpointsIdentifier {
656                kube_namespaced_name: None,
657                resolve: "ip".to_string(),
658            }),
659        };
660
661        assert!(AdvancedADIdentifier::from(proto_id).kube_endpoints.is_none());
662    }
663
664    #[test]
665    fn test_autodiscovery_event_from_proto_config() {
666        // Create a ProtoConfig with test values
667        let mut proto_config = ProtoConfig {
668            init_config: b"init-data".to_vec(),
669            cluster_check: true,
670            metric_config: vec![],
671            logs_config: vec![],
672            ..base_proto_config()
673        };
674
675        let kube_svc = KubeNamespacedName {
676            name: "nginx".to_string(),
677            namespace: "default".to_string(),
678        };
679
680        let adv_id = AdvancedAdIdentifier {
681            kube_service: Some(kube_svc),
682            kube_endpoints: None,
683        };
684
685        proto_config.advanced_ad_identifiers = vec![adv_id];
686
687        let event = AutodiscoveryEvent::from(proto_config.clone());
688
689        match event {
690            AutodiscoveryEvent::Schedule { config: _config } => {}
691            _ => panic!("Expected an Schedule event"),
692        }
693
694        proto_config.event_type = ConfigEventType::Unschedule as i32;
695
696        let event = AutodiscoveryEvent::from(proto_config.clone());
697
698        match event {
699            AutodiscoveryEvent::Unscheduled { config } => {
700                assert_eq!(config.name, "test-config");
701            }
702            _ => panic!("Expected an Unscheduled event"),
703        }
704
705        proto_config.instances = vec![b"instance1".to_vec(), b"instance2".to_vec()];
706        proto_config.cluster_check = false;
707        proto_config.event_type = ConfigEventType::Schedule as i32;
708
709        let event = AutodiscoveryEvent::from(proto_config.clone());
710
711        match event {
712            AutodiscoveryEvent::CheckSchedule { config: _config } => {}
713            _ => panic!("Expected an CheckSchedule event"),
714        }
715
716        proto_config.event_type = ConfigEventType::Unschedule as i32;
717
718        let event = AutodiscoveryEvent::from(proto_config);
719
720        match event {
721            AutodiscoveryEvent::CheckUnscheduled { config: _config } => {}
722            _ => panic!("Expected an CheckUnscheduled event"),
723        }
724    }
725
726    #[test]
727    fn test_yaml_errors_do_not_include_source_lines() {
728        let input = "password: hunter2hunter2\nhost: [unclosed\n";
729
730        let error = yaml_from_str::<BTreeMap<String, serde_json::Value>>(input)
731            .unwrap_err()
732            .to_string();
733
734        assert!(!error.contains("hunter2hunter2"), "error leaks source: {error}");
735    }
736
737    #[test]
738    fn test_yaml_parses_long_runs_of_comments() {
739        let mut input = String::from("username: # optional\n");
740        for i in 0..100 {
741            input.push_str(&format!("# documentation line {i}\n"));
742        }
743        input.push_str("host: localhost\n");
744
745        let data = bytes_to_data(input.into_bytes()).unwrap();
746
747        assert_eq!(data.get("username"), Some(&serde_json::Value::Null));
748        assert_eq!(
749            data.get("host"),
750            Some(&serde_json::Value::String("localhost".to_string()))
751        );
752    }
753}