1use std::{
2 collections::{BTreeMap, HashMap},
3 convert::TryFrom,
4};
5
6use futures::{FutureExt, TryFutureExt};
7use vector_lib::{
8 configurable::configurable_component,
9 lookup::{event_path, lookup_v2::ConfigValuePath},
10 schema::Requirement,
11};
12use vrl::value::Kind;
13
14use crate::{
15 codecs::Transformer,
16 config::{AcknowledgementsConfig, DataType, Input, SinkConfig, SinkContext},
17 event::{EventRef, LogEvent, Value},
18 http::{HttpClient, QueryParameters},
19 internal_events::TemplateRenderingError,
20 sinks::{
21 Healthcheck, VectorSink,
22 elasticsearch::{
23 ElasticsearchApiVersion, ElasticsearchAuthConfig, ElasticsearchCommon,
24 ElasticsearchCommonMode, ElasticsearchMode, VersionType,
25 health::ElasticsearchHealthLogic,
26 retry::ElasticsearchRetryLogic,
27 service::{ElasticsearchService, HttpRequestBuilder},
28 sink::ElasticsearchSink,
29 },
30 util::{
31 BatchConfig, Compression, RealtimeSizeBasedDefaultBatchSettings, http::RequestConfig,
32 service::HealthConfig,
33 },
34 },
35 template::{ConfinementConfig, Template},
36 tls::TlsConfig,
37 transforms::metric_to_log::MetricToLogConfig,
38};
39
40pub const DATA_STREAM_TIMESTAMP_KEY: &str = "@timestamp";
42
43#[configurable_component]
47#[derive(Clone, Debug, Eq, PartialEq)]
48#[serde(deny_unknown_fields, rename_all = "lowercase")]
49#[derive(Default)]
50pub enum OpenSearchServiceType {
51 #[default]
53 Managed,
54 Serverless,
56}
57
58impl OpenSearchServiceType {
59 pub const fn as_str(&self) -> &'static str {
60 match self {
61 OpenSearchServiceType::Managed => "es",
62 OpenSearchServiceType::Serverless => "aoss",
63 }
64 }
65}
66
67#[configurable_component(sink("elasticsearch", "Index observability events in Elasticsearch."))]
69#[derive(Clone, Debug)]
70#[serde(deny_unknown_fields)]
71pub struct ElasticsearchConfig {
72 #[serde(default)]
77 #[configurable(
78 deprecated = "This option has been deprecated, the `endpoints` option should be used instead."
79 )]
80 pub endpoint: Option<String>,
81
82 #[serde(default)]
93 #[configurable(metadata(docs::examples = "http://10.24.32.122:9000"))]
94 #[configurable(metadata(docs::examples = "https://example.com"))]
95 #[configurable(metadata(docs::examples = "https://user:password@example.com"))]
96 pub endpoints: Vec<String>,
97
98 #[serde(default = "default_doc_type")]
105 #[configurable(metadata(docs::advanced))]
106 pub doc_type: String,
107
108 #[serde(default)]
112 #[configurable(derived)]
113 pub api_version: ElasticsearchApiVersion,
114
115 #[serde(default)]
121 #[configurable(
122 deprecated = "This option has been deprecated, the `api_version` option should be used instead."
123 )]
124 pub suppress_type_name: bool,
125
126 #[serde(default)]
130 #[configurable(metadata(docs::advanced))]
131 pub request_retry_partial: bool,
132
133 #[serde(default)]
141 #[configurable(metadata(docs::advanced))]
142 #[configurable(metadata(docs::examples = "id"))]
143 #[configurable(metadata(docs::examples = "_id"))]
144 pub id_key: Option<ConfigValuePath>,
145
146 #[serde(default)]
148 #[configurable(metadata(docs::advanced))]
149 #[configurable(metadata(docs::examples = "pipeline-name"))]
150 pub pipeline: Option<String>,
151
152 #[serde(default)]
153 #[configurable(derived)]
154 pub mode: ElasticsearchMode,
155
156 #[serde(default)]
157 #[configurable(derived)]
158 pub compression: Compression,
159
160 #[serde(skip_serializing_if = "crate::serde::is_default", default)]
161 #[configurable(derived)]
162 #[configurable(metadata(docs::advanced))]
163 pub encoding: Transformer,
164
165 #[serde(default)]
166 #[configurable(derived)]
167 pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
168
169 #[serde(default)]
170 #[configurable(derived)]
171 pub request: RequestConfig,
172
173 #[configurable(derived)]
174 pub auth: Option<ElasticsearchAuthConfig>,
175
176 #[serde(default)]
178 #[configurable(metadata(docs::advanced))]
179 #[configurable(metadata(docs::additional_props_description = "A query string parameter."))]
180 #[configurable(metadata(docs::examples = "query_examples()"))]
181 pub query: Option<QueryParameters>,
182
183 #[serde(default)]
184 #[configurable(derived)]
185 #[cfg(feature = "aws-core")]
186 pub aws: Option<crate::aws::RegionOrEndpoint>,
187
188 #[serde(default)]
190 pub opensearch_service_type: OpenSearchServiceType,
191
192 #[serde(default)]
193 #[configurable(derived)]
194 pub tls: Option<TlsConfig>,
195
196 #[serde(default)]
197 #[configurable(derived)]
198 #[serde(rename = "distribution")]
199 pub endpoint_health: Option<HealthConfig>,
200
201 #[serde(alias = "normal", default)]
206 #[configurable(derived)]
207 pub bulk: BulkConfig,
208
209 #[serde(default)]
210 #[configurable(derived)]
211 pub data_stream: Option<DataStreamConfig>,
212
213 #[serde(default)]
214 #[configurable(derived)]
215 pub metrics: Option<MetricToLogConfig>,
216
217 #[serde(
218 default,
219 deserialize_with = "crate::serde::bool_or_struct",
220 skip_serializing_if = "crate::serde::is_default"
221 )]
222 #[configurable(derived)]
223 pub acknowledgements: AcknowledgementsConfig,
224
225 #[configurable(derived)]
226 #[serde(flatten)]
227 pub confinement: ConfinementConfig,
228}
229
230fn default_doc_type() -> String {
231 "_doc".to_owned()
232}
233
234fn query_examples() -> HashMap<String, String> {
235 HashMap::<_, _>::from_iter([("X-Powered-By".to_owned(), "Vector".to_owned())])
236}
237
238impl Default for ElasticsearchConfig {
239 fn default() -> Self {
240 Self {
241 endpoint: None,
242 endpoints: vec![],
243 doc_type: default_doc_type(),
244 api_version: Default::default(),
245 suppress_type_name: false,
246 request_retry_partial: false,
247 id_key: None,
248 pipeline: None,
249 mode: Default::default(),
250 compression: Default::default(),
251 encoding: Default::default(),
252 batch: Default::default(),
253 request: Default::default(),
254 auth: None,
255 query: None,
256 #[cfg(feature = "aws-core")]
257 aws: None,
258 opensearch_service_type: Default::default(),
259 tls: None,
260 endpoint_health: None,
261 bulk: BulkConfig::default(), data_stream: None,
263 metrics: None,
264 acknowledgements: Default::default(),
265 confinement: ConfinementConfig::default(),
266 }
267 }
268}
269
270impl ElasticsearchConfig {
271 pub fn common_mode(&self) -> crate::Result<ElasticsearchCommonMode> {
272 match self.mode {
273 ElasticsearchMode::Bulk => Ok(ElasticsearchCommonMode::Bulk {
274 index: self.bulk.index.clone(),
275 template_fallback_index: self.bulk.template_fallback_index.clone(),
276 action: self.bulk.action.clone(),
277 version: self.bulk.version.clone(),
278 version_type: self.bulk.version_type,
279 }),
280 ElasticsearchMode::DataStream => Ok(ElasticsearchCommonMode::DataStream(
281 self.data_stream.clone().unwrap_or_default(),
282 )),
283 }
284 }
285}
286
287#[configurable_component]
289#[derive(Clone, Debug, PartialEq)]
290#[serde(rename_all = "snake_case")]
291pub struct BulkConfig {
292 #[serde(default = "default_bulk_action")]
298 #[configurable(metadata(docs::examples = "create"))]
299 #[configurable(metadata(docs::examples = "{{ action }}"))]
300 pub action: Template,
301
302 #[serde(default = "default_index")]
304 #[configurable(metadata(docs::examples = "application-{{ application_id }}-%Y-%m-%d"))]
305 #[configurable(metadata(docs::examples = "{{ index }}"))]
306 pub index: Template,
307
308 #[configurable(metadata(docs::examples = "test-index"))]
310 pub template_fallback_index: Option<String>,
311
312 #[configurable(metadata(docs::examples = "{{ obj_version }}-%Y-%m-%d"))]
314 #[configurable(metadata(docs::examples = "123"))]
315 pub version: Option<Template>,
316
317 #[serde(default = "default_version_type")]
323 #[configurable(metadata(docs::examples = "internal"))]
324 #[configurable(metadata(docs::examples = "external"))]
325 pub version_type: VersionType,
326}
327
328fn default_bulk_action() -> Template {
329 Template::try_from("index").expect("unable to parse template")
330}
331
332fn default_index() -> Template {
333 Template::try_from("vector-%Y.%m.%d").expect("unable to parse template")
334}
335
336const fn default_version_type() -> VersionType {
337 VersionType::Internal
338}
339
340impl Default for BulkConfig {
341 fn default() -> Self {
342 Self {
343 action: default_bulk_action(),
344 index: default_index(),
345 template_fallback_index: Default::default(),
346 version: Default::default(),
347 version_type: default_version_type(),
348 }
349 }
350}
351
352#[configurable_component]
354#[derive(Clone, Debug)]
355#[serde(rename_all = "snake_case")]
356pub struct DataStreamConfig {
357 #[serde(rename = "type", default = "DataStreamConfig::default_type")]
359 #[configurable(metadata(docs::examples = "metrics"))]
360 #[configurable(metadata(docs::examples = "synthetics"))]
361 #[configurable(metadata(docs::examples = "{{ type }}"))]
362 pub dtype: Template,
363
364 #[serde(default = "DataStreamConfig::default_dataset")]
366 #[configurable(metadata(docs::examples = "generic"))]
367 #[configurable(metadata(docs::examples = "nginx"))]
368 #[configurable(metadata(docs::examples = "{{ service }}"))]
369 pub dataset: Template,
370
371 #[serde(default = "DataStreamConfig::default_namespace")]
373 #[configurable(metadata(docs::examples = "{{ environment }}"))]
374 pub namespace: Template,
375
376 #[serde(default = "DataStreamConfig::default_auto_routing")]
385 pub auto_routing: bool,
386
387 #[serde(default = "DataStreamConfig::default_sync_fields")]
391 pub sync_fields: bool,
392}
393
394impl Default for DataStreamConfig {
395 fn default() -> Self {
396 Self {
397 dtype: Self::default_type(),
398 dataset: Self::default_dataset(),
399 namespace: Self::default_namespace(),
400 auto_routing: Self::default_auto_routing(),
401 sync_fields: Self::default_sync_fields(),
402 }
403 }
404}
405
406impl DataStreamConfig {
407 fn default_type() -> Template {
408 Template::try_from("logs").expect("couldn't build default type template")
409 }
410
411 fn default_dataset() -> Template {
412 Template::try_from("generic").expect("couldn't build default dataset template")
413 }
414
415 fn default_namespace() -> Template {
416 Template::try_from("default").expect("couldn't build default namespace template")
417 }
418
419 const fn default_auto_routing() -> bool {
420 true
421 }
422
423 const fn default_sync_fields() -> bool {
424 true
425 }
426
427 pub fn remap_timestamp(&self, log: &mut LogEvent) {
429 if let Some(timestamp_key) = log.timestamp_path().cloned() {
430 if timestamp_key.to_string() == DATA_STREAM_TIMESTAMP_KEY {
431 return;
432 }
433
434 log.rename_key(×tamp_key, event_path!(DATA_STREAM_TIMESTAMP_KEY));
435 }
436 }
437
438 pub fn dtype<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
439 self.dtype
440 .render_string(event)
441 .map_err(|error| {
442 emit!(TemplateRenderingError {
443 error,
444 field: Some("data_stream.type"),
445 drop_event: true,
446 });
447 })
448 .ok()
449 }
450
451 pub fn dataset<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
452 self.dataset
453 .render_string(event)
454 .map_err(|error| {
455 emit!(TemplateRenderingError {
456 error,
457 field: Some("data_stream.dataset"),
458 drop_event: true,
459 });
460 })
461 .ok()
462 }
463
464 pub fn namespace<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
465 self.namespace
466 .render_string(event)
467 .map_err(|error| {
468 emit!(TemplateRenderingError {
469 error,
470 field: Some("data_stream.namespace"),
471 drop_event: true,
472 });
473 })
474 .ok()
475 }
476
477 pub fn sync_fields(&self, log: &mut LogEvent) {
478 if !self.sync_fields {
479 return;
480 }
481
482 let dtype = self.dtype(&*log);
483 let dataset = self.dataset(&*log);
484 let namespace = self.namespace(&*log);
485
486 if log.as_map().is_none() {
487 *log.value_mut() = Value::Object(BTreeMap::new());
488 }
489 let existing = log
490 .as_map_mut()
491 .expect("must be a map")
492 .entry("data_stream".into())
493 .or_insert_with(|| Value::Object(BTreeMap::new()))
494 .as_object_mut_unwrap();
495
496 if let Some(dtype) = dtype {
497 existing
498 .entry("type".into())
499 .or_insert_with(|| dtype.into());
500 }
501 if let Some(dataset) = dataset {
502 existing
503 .entry("dataset".into())
504 .or_insert_with(|| dataset.into());
505 }
506 if let Some(namespace) = namespace {
507 existing
508 .entry("namespace".into())
509 .or_insert_with(|| namespace.into());
510 }
511 }
512
513 pub fn index(&self, log: &LogEvent) -> Option<String> {
514 let (dtype, dataset, namespace) = if !self.auto_routing {
515 (self.dtype(log)?, self.dataset(log)?, self.namespace(log)?)
516 } else {
517 let data_stream = log
518 .get(event_path!("data_stream"))
519 .and_then(|ds| ds.as_object());
520 let dtype =
521 auto_routed_value(data_stream, "type", &self.dtype, "data_stream.type", || {
522 self.dtype(log)
523 })?;
524 let dataset = auto_routed_value(
525 data_stream,
526 "dataset",
527 &self.dataset,
528 "data_stream.dataset",
529 || self.dataset(log),
530 )?;
531 let namespace = auto_routed_value(
532 data_stream,
533 "namespace",
534 &self.namespace,
535 "data_stream.namespace",
536 || self.namespace(log),
537 )?;
538 (dtype, dataset, namespace)
539 };
540
541 let name = [dtype, dataset, namespace]
542 .into_iter()
543 .filter(|s| !s.is_empty())
544 .collect::<Vec<_>>()
545 .join("-");
546
547 Some(name)
548 }
549}
550
551fn auto_routed_value<F>(
558 data_stream: Option<&std::collections::BTreeMap<vector_lib::event::KeyString, Value>>,
559 key: &str,
560 template: &Template,
561 field: &'static str,
562 fallback: F,
563) -> Option<String>
564where
565 F: FnOnce() -> Option<String>,
566{
567 match data_stream.and_then(|ds| ds.get(key)) {
568 Some(value) => {
569 let s = value.to_string_lossy().into_owned();
570 template
583 .check_confinement(&s)
584 .map_err(|error| {
585 emit!(TemplateRenderingError {
586 error,
587 field: Some(field),
588 drop_event: true,
589 });
590 })
591 .ok()?;
592 if !is_valid_data_stream_component(&s, field) {
593 emit!(TemplateRenderingError {
594 error: crate::template::TemplateRenderingError::Confined {
595 rendered_preview: crate::template::confined_preview(&s),
596 rendered_len: s.len(),
597 message: format!(
598 "auto-routed {field} value is not a valid data-stream identifier"
599 ),
600 },
601 field: Some(field),
602 drop_event: true,
603 });
604 return None;
605 }
606 Some(s)
607 }
608 None => fallback(),
609 }
610}
611
612fn is_valid_data_stream_component(s: &str, field: &str) -> bool {
634 if s.is_empty() {
635 return true;
636 }
637 if s.len() > 100 {
638 return false;
639 }
640 const FORBIDDEN: &[char] = &[
642 '\0', '/', '\\', '*', '?', '"', '<', '>', '|', ',', '#', ':', ' ',
643 ];
644 if s.contains(FORBIDDEN) {
645 return false;
646 }
647 if let Some(first) = s.chars().next()
649 && matches!(first, '-' | '_' | '+' | '.')
650 {
651 return false;
652 }
653 if s == "." || s == ".." {
656 return false;
657 }
658 if s.chars().any(|c| c.is_control()) {
660 return false;
661 }
662 if (field == "data_stream.dataset" || field == "data_stream.namespace") && s.contains('-') {
668 return false;
669 }
670 true
671}
672
673#[async_trait::async_trait]
674#[typetag::serde(name = "elasticsearch")]
675impl SinkConfig for ElasticsearchConfig {
676 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
677 let mut confined_config = self.clone();
678 match self.mode {
684 ElasticsearchMode::Bulk => {
685 confined_config.bulk.index = confined_config.bulk.index.confine(
686 &self.confinement,
687 Self::NAME,
688 "bulk.index",
689 )?;
690 }
691 ElasticsearchMode::DataStream => {
692 confined_config.data_stream = confined_config
693 .data_stream
694 .map(|mut ds| -> crate::Result<DataStreamConfig> {
695 ds.dtype =
696 ds.dtype
697 .confine(&self.confinement, Self::NAME, "data_stream.type")?;
698 ds.dataset = ds.dataset.confine(
699 &self.confinement,
700 Self::NAME,
701 "data_stream.dataset",
702 )?;
703 ds.namespace = ds.namespace.confine(
704 &self.confinement,
705 Self::NAME,
706 "data_stream.namespace",
707 )?;
708 Ok(ds)
709 })
710 .transpose()?;
711 }
712 }
713 let this = &confined_config;
714 let commons = ElasticsearchCommon::parse_many(this, cx.proxy()).await?;
715 let common = commons[0].clone();
716
717 let client = HttpClient::new(common.tls_settings.clone(), cx.proxy())?;
718
719 let request_limits = this.request.tower.into_settings();
720
721 let health_config = this.endpoint_health.clone().unwrap_or_default();
722
723 let services = commons
724 .iter()
725 .map(|common| {
726 let endpoint = common.base_url.clone();
727
728 let http_request_builder = HttpRequestBuilder::new(common, this);
729 let service = ElasticsearchService::new(client.clone(), http_request_builder);
730
731 (endpoint, service)
732 })
733 .collect::<Vec<_>>();
734
735 let service = request_limits.distributed_service(
736 ElasticsearchRetryLogic {
737 retry_partial: this.request_retry_partial,
738 },
739 services,
740 health_config,
741 ElasticsearchHealthLogic,
742 1,
743 );
744
745 let sink = ElasticsearchSink::new(&common, this, service)?;
746
747 let stream = VectorSink::from_event_streamsink(sink);
748
749 let healthcheck = futures::future::select_ok(
750 commons
751 .into_iter()
752 .map(move |common| common.healthcheck(client.clone()).boxed()),
753 )
754 .map_ok(|((), _)| ())
755 .boxed();
756 self.confinement.set_confinement_gauge("sink", Self::NAME);
757 Ok((stream, healthcheck))
758 }
759
760 fn input(&self) -> Input {
761 let requirements = Requirement::empty().optional_meaning("timestamp", Kind::timestamp());
762
763 Input::new(DataType::Metric | DataType::Log).with_schema_requirement(requirements)
764 }
765
766 fn acknowledgements(&self) -> &AcknowledgementsConfig {
767 &self.acknowledgements
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use crate::template::{ConfinementConfig, Template};
775
776 #[test]
777 fn generate_config() {
778 crate::test_util::test_generate_config::<ElasticsearchConfig>();
779 }
780
781 #[test]
782 fn is_valid_data_stream_component_accepts_normal_identifiers() {
783 assert!(is_valid_data_stream_component("logs", "data_stream.type"));
786 assert!(is_valid_data_stream_component(
787 "metrics-prod",
788 "data_stream.type"
789 ));
790 assert!(is_valid_data_stream_component(
791 "app.errors",
792 "data_stream.dataset"
793 ));
794 assert!(is_valid_data_stream_component(
795 "tenant_42",
796 "data_stream.namespace"
797 ));
798 assert!(is_valid_data_stream_component("", "data_stream.dataset"));
800 }
801
802 #[test]
803 fn is_valid_data_stream_component_rejects_traversal_and_injection() {
804 assert!(!is_valid_data_stream_component("..", "data_stream.dataset"));
806 assert!(!is_valid_data_stream_component(".", "data_stream.dataset"));
807 assert!(!is_valid_data_stream_component(
808 "../evil",
809 "data_stream.dataset"
810 ));
811 assert!(!is_valid_data_stream_component(
812 "logs/tenant",
813 "data_stream.type"
814 ));
815 assert!(!is_valid_data_stream_component(
816 "logs\\tenant",
817 "data_stream.type"
818 ));
819 for bad in [
821 "a*b", "a?b", "a\"b", "a<b", "a>b", "a|b", "a,b", "a#b", "a:b", "a b",
822 ] {
823 assert!(
824 !is_valid_data_stream_component(bad, "data_stream.type"),
825 "should reject {bad:?}"
826 );
827 }
828 for bad in ["-logs", "_logs", "+logs", ".logs"] {
830 assert!(
831 !is_valid_data_stream_component(bad, "data_stream.type"),
832 "should reject {bad:?}"
833 );
834 }
835 assert!(!is_valid_data_stream_component(
838 &"x".repeat(101),
839 "data_stream.type"
840 ));
841 assert!(!is_valid_data_stream_component(
843 "logs\0",
844 "data_stream.type"
845 ));
846 assert!(!is_valid_data_stream_component(
847 "logs\n",
848 "data_stream.type"
849 ));
850 }
851
852 #[test]
853 fn is_valid_data_stream_component_rejects_hyphen_in_dataset_and_namespace() {
854 assert!(!is_valid_data_stream_component(
857 "app-errors",
858 "data_stream.dataset"
859 ));
860 assert!(!is_valid_data_stream_component(
861 "prod-us",
862 "data_stream.namespace"
863 ));
864 assert!(is_valid_data_stream_component(
866 "app-errors",
867 "data_stream.type"
868 ));
869 }
870
871 #[test]
872 fn confinement_rejects_unconfined_index() {
873 let template = Template::try_from("{{ index }}").unwrap();
874 let config = ConfinementConfig::default();
875 let result = template.confine(&config, "elasticsearch", "bulk.index");
876 assert!(result.is_err());
877 }
878
879 #[test]
880 fn confinement_opt_out_allows_unconfined_index() {
881 let template = Template::try_from("{{ index }}").unwrap();
882 let config = ConfinementConfig {
883 dangerously_allow_unconfined_template_resolution: true,
884 };
885 let result = template.confine(&config, "elasticsearch", "bulk.index");
886 assert!(result.is_ok());
887 }
888
889 #[test]
890 fn confinement_allows_prefixed_index() {
891 let template = Template::try_from("events-{{ env }}").unwrap();
892 let config = ConfinementConfig::default();
893 let result = template.confine(&config, "elasticsearch", "bulk.index");
894 assert!(result.is_ok());
895 }
896
897 #[test]
898 fn parse_aws_auth() {
899 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
900 endpoints: [""]
901 auth:
902 strategy: aws
903 assume_role: role
904 "#})
905 .unwrap();
906
907 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
908 endpoints: [""]
909 auth:
910 strategy: aws
911 "#})
912 .unwrap();
913 }
914
915 #[test]
916 fn parse_mode() {
917 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
918 endpoints: [""]
919 mode: data_stream
920 data_stream:
921 type: synthetics
922 "#})
923 .unwrap();
924 assert!(matches!(config.mode, ElasticsearchMode::DataStream));
925 assert!(config.data_stream.is_some());
926 }
927
928 #[test]
929 fn parse_distribution() {
930 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
931 endpoints: ["", ""]
932 distribution:
933 retry_initial_backoff_secs: 10
934 "#})
935 .unwrap();
936 }
937
938 #[test]
939 fn parse_version() {
940 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
941 endpoints: [""]
942 api_version: v7
943 "#})
944 .unwrap();
945 assert_eq!(config.api_version, ElasticsearchApiVersion::V7);
946 }
947
948 #[test]
949 fn parse_version_auto() {
950 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
951 endpoints: [""]
952 api_version: auto
953 "#})
954 .unwrap();
955 assert_eq!(config.api_version, ElasticsearchApiVersion::Auto);
956 }
957
958 #[test]
959 fn parse_default_bulk() {
960 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
961 endpoints: [""]
962 "#})
963 .unwrap();
964 assert_eq!(config.mode, ElasticsearchMode::Bulk);
965 assert_eq!(config.bulk, BulkConfig::default());
966 }
967
968 #[test]
969 fn parse_opensearch_service_type_managed() {
970 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
971 endpoints: [""]
972 opensearch_service_type: managed
973 "#})
974 .unwrap();
975 assert_eq!(
976 config.opensearch_service_type,
977 OpenSearchServiceType::Managed
978 );
979 }
980
981 #[test]
982 fn parse_opensearch_service_type_serverless() {
983 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
984 endpoints: [""]
985 opensearch_service_type: serverless
986 auth:
987 strategy: aws
988 api_version: auto
989 "#})
990 .unwrap();
991 assert_eq!(
992 config.opensearch_service_type,
993 OpenSearchServiceType::Serverless
994 );
995 }
996
997 #[test]
998 fn parse_opensearch_service_type_default() {
999 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1000 endpoints: [""]
1001 "#})
1002 .unwrap();
1003 assert_eq!(
1004 config.opensearch_service_type,
1005 OpenSearchServiceType::Managed
1006 );
1007 }
1008
1009 #[cfg(feature = "aws-core")]
1010 #[test]
1011 fn parse_opensearch_serverless_with_aws_auth() {
1012 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1013 endpoints: [""]
1014 opensearch_service_type: serverless
1015 auth:
1016 strategy: aws
1017 api_version: auto
1018 "#})
1019 .unwrap();
1020 assert_eq!(
1021 config.opensearch_service_type,
1022 OpenSearchServiceType::Serverless
1023 );
1024 assert!(matches!(config.auth, Some(ElasticsearchAuthConfig::Aws(_))));
1025 assert_eq!(config.api_version, ElasticsearchApiVersion::Auto);
1026 }
1027}