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 stream::BatcherSettings,
12};
13use vrl::value::Kind;
14
15use crate::{
16 codecs::Transformer,
17 config::{
18 AcknowledgementsConfig, DataType, DynValidatedSink, Input, SinkConfig, SinkContext,
19 ValidatedSink,
20 },
21 event::{EventRef, LogEvent, Value},
22 http::{HttpClient, QueryParameters},
23 internal_events::TemplateRenderingError,
24 sinks::{
25 Healthcheck, VectorSink,
26 elasticsearch::{
27 ElasticsearchApiVersion, ElasticsearchAuthConfig, ElasticsearchCommon,
28 ElasticsearchCommonMode, ElasticsearchMode, ParseError, VersionType,
29 health::ElasticsearchHealthLogic,
30 retry::ElasticsearchRetryLogic,
31 service::{ElasticsearchService, HttpRequestBuilder},
32 sink::ElasticsearchSink,
33 },
34 util::{
35 BatchConfig, Compression, HttpEndpoint, RealtimeSizeBasedDefaultBatchSettings,
36 TowerRequestSettings, http::RequestConfig, service::HealthConfig,
37 },
38 },
39 template::{ConfinedTemplate, ConfinementConfig, Template, UnconfinedTemplate},
40 tls::TlsConfig,
41 transforms::metric_to_log::MetricToLogConfig,
42};
43
44pub const DATA_STREAM_TIMESTAMP_KEY: &str = "@timestamp";
46
47#[configurable_component]
51#[derive(Clone, Debug, Eq, PartialEq)]
52#[serde(deny_unknown_fields, rename_all = "lowercase")]
53#[derive(Default)]
54pub enum OpenSearchServiceType {
55 #[default]
57 Managed,
58 Serverless,
60}
61
62impl OpenSearchServiceType {
63 pub const fn as_str(&self) -> &'static str {
64 match self {
65 OpenSearchServiceType::Managed => "es",
66 OpenSearchServiceType::Serverless => "aoss",
67 }
68 }
69}
70
71#[configurable_component(sink("elasticsearch", "Index observability events in Elasticsearch."))]
73#[derive(Clone, Debug)]
74#[serde(deny_unknown_fields)]
75pub struct ElasticsearchConfig {
76 #[serde(default)]
81 #[configurable(
82 deprecated = "This option has been deprecated, the `endpoints` option should be used instead."
83 )]
84 #[configurable(required_one_of = "endpoint")]
85 pub endpoint: Option<HttpEndpoint>,
86
87 #[serde(default)]
98 #[configurable(metadata(docs::examples = "http://10.24.32.122:9000"))]
99 #[configurable(metadata(docs::examples = "https://example.com"))]
100 #[configurable(metadata(docs::examples = "https://user:password@example.com"))]
101 #[configurable(required_one_of = "endpoint")]
102 pub endpoints: Vec<HttpEndpoint>,
103
104 #[serde(default = "default_doc_type")]
111 pub doc_type: String,
112
113 #[serde(default)]
117 #[configurable(derived)]
118 pub api_version: ElasticsearchApiVersion,
119
120 #[serde(default)]
126 #[configurable(
127 deprecated = "This option has been deprecated, the `api_version` option should be used instead."
128 )]
129 pub suppress_type_name: bool,
130
131 #[serde(default)]
135 pub request_retry_partial: bool,
136
137 #[serde(default)]
145 #[configurable(metadata(docs::examples = "id"))]
146 #[configurable(metadata(docs::examples = "_id"))]
147 pub id_key: Option<ConfigValuePath>,
148
149 #[serde(default)]
151 #[configurable(metadata(docs::examples = "pipeline-name"))]
152 pub pipeline: Option<String>,
153
154 #[serde(default)]
155 #[configurable(derived)]
156 pub mode: ElasticsearchMode,
157
158 #[serde(default)]
159 #[configurable(derived)]
160 pub compression: Compression,
161
162 #[serde(skip_serializing_if = "crate::serde::is_default", default)]
163 #[configurable(derived)]
164 pub encoding: Transformer,
165
166 #[serde(default)]
167 #[configurable(derived)]
168 pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
169
170 #[serde(default)]
171 #[configurable(derived)]
172 pub request: RequestConfig,
173
174 #[configurable(derived)]
175 pub auth: Option<ElasticsearchAuthConfig>,
176
177 #[serde(default)]
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> {
276 match self.mode {
277 ElasticsearchMode::Bulk => Ok(ElasticsearchCommonMode::Bulk {
278 index: self.bulk.index.clone().confine(
282 &self.confinement,
283 Self::NAME,
284 "bulk.index",
285 )?,
286 template_fallback_index: self.bulk.template_fallback_index.clone(),
287 action: self.bulk.action.clone(),
288 version: self.bulk.version.clone(),
289 version_type: self.bulk.version_type,
290 }),
291 ElasticsearchMode::DataStream => Ok(ElasticsearchCommonMode::DataStream(
292 self.data_stream
293 .clone()
294 .unwrap_or_default()
295 .confine(&self.confinement, Self::NAME)?,
296 )),
297 }
298 }
299}
300
301#[configurable_component]
303#[derive(Clone, Debug, PartialEq)]
304#[serde(rename_all = "snake_case")]
305pub struct BulkConfig {
306 #[serde(default = "default_bulk_action")]
312 #[configurable(metadata(docs::examples = "create"))]
313 #[configurable(metadata(docs::examples = "{{ action }}"))]
314 pub action: UnconfinedTemplate,
315
316 #[serde(default = "default_index")]
318 #[configurable(metadata(docs::examples = "application-{{ application_id }}-%Y-%m-%d"))]
319 #[configurable(metadata(docs::examples = "{{ index }}"))]
320 pub index: Template,
321
322 #[configurable(metadata(docs::examples = "test-index"))]
324 pub template_fallback_index: Option<String>,
325
326 #[configurable(metadata(docs::examples = "{{ obj_version }}-%Y-%m-%d"))]
328 #[configurable(metadata(docs::examples = "123"))]
329 pub version: Option<UnconfinedTemplate>,
330
331 #[serde(default = "default_version_type")]
337 #[configurable(metadata(docs::examples = "internal"))]
338 #[configurable(metadata(docs::examples = "external"))]
339 pub version_type: VersionType,
340}
341
342fn default_bulk_action() -> UnconfinedTemplate {
343 UnconfinedTemplate::try_from("index").expect("unable to parse template")
344}
345
346fn default_index() -> Template {
347 Template::try_from("vector-%Y.%m.%d").expect("unable to parse template")
348}
349
350const fn default_version_type() -> VersionType {
351 VersionType::Internal
352}
353
354impl Default for BulkConfig {
355 fn default() -> Self {
356 Self {
357 action: default_bulk_action(),
358 index: default_index(),
359 template_fallback_index: Default::default(),
360 version: Default::default(),
361 version_type: default_version_type(),
362 }
363 }
364}
365
366#[configurable_component]
368#[derive(Clone, Debug)]
369#[serde(rename_all = "snake_case")]
370pub struct DataStreamConfig {
371 #[serde(rename = "type", default = "DataStreamConfig::default_type")]
373 #[configurable(metadata(docs::examples = "metrics"))]
374 #[configurable(metadata(docs::examples = "synthetics"))]
375 #[configurable(metadata(docs::examples = "{{ type }}"))]
376 pub dtype: Template,
377
378 #[serde(default = "DataStreamConfig::default_dataset")]
380 #[configurable(metadata(docs::examples = "generic"))]
381 #[configurable(metadata(docs::examples = "nginx"))]
382 #[configurable(metadata(docs::examples = "{{ service }}"))]
383 pub dataset: Template,
384
385 #[serde(default = "DataStreamConfig::default_namespace")]
387 #[configurable(metadata(docs::examples = "{{ environment }}"))]
388 pub namespace: Template,
389
390 #[serde(default = "DataStreamConfig::default_auto_routing")]
399 pub auto_routing: bool,
400
401 #[serde(default = "DataStreamConfig::default_sync_fields")]
405 pub sync_fields: bool,
406}
407
408impl Default for DataStreamConfig {
409 fn default() -> Self {
410 Self {
411 dtype: Self::default_type(),
412 dataset: Self::default_dataset(),
413 namespace: Self::default_namespace(),
414 auto_routing: Self::default_auto_routing(),
415 sync_fields: Self::default_sync_fields(),
416 }
417 }
418}
419
420impl DataStreamConfig {
421 fn default_type() -> Template {
422 Template::try_from("logs").expect("couldn't build default type template")
423 }
424
425 fn default_dataset() -> Template {
426 Template::try_from("generic").expect("couldn't build default dataset template")
427 }
428
429 fn default_namespace() -> Template {
430 Template::try_from("default").expect("couldn't build default namespace template")
431 }
432
433 const fn default_auto_routing() -> bool {
434 true
435 }
436
437 const fn default_sync_fields() -> bool {
438 true
439 }
440
441 pub fn confine(
446 self,
447 confinement: &ConfinementConfig,
448 component_name: &'static str,
449 ) -> crate::Result<DataStreamMode> {
450 Ok(DataStreamMode {
451 dtype: self
452 .dtype
453 .confine(confinement, component_name, "data_stream.type")?,
454 dataset: self
455 .dataset
456 .confine(confinement, component_name, "data_stream.dataset")?,
457 namespace: self.namespace.confine(
458 confinement,
459 component_name,
460 "data_stream.namespace",
461 )?,
462 auto_routing: self.auto_routing,
463 sync_fields: self.sync_fields,
464 })
465 }
466}
467
468#[derive(Clone, Debug)]
473pub struct DataStreamMode {
474 dtype: ConfinedTemplate,
475 dataset: ConfinedTemplate,
476 namespace: ConfinedTemplate,
477 auto_routing: bool,
478 sync_fields: bool,
479}
480
481impl DataStreamMode {
482 pub fn remap_timestamp(&self, log: &mut LogEvent) {
484 if let Some(timestamp_key) = log.timestamp_path().cloned() {
485 if timestamp_key.to_string() == DATA_STREAM_TIMESTAMP_KEY {
486 return;
487 }
488
489 log.rename_key(×tamp_key, event_path!(DATA_STREAM_TIMESTAMP_KEY));
490 }
491 }
492
493 pub fn dtype<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
494 self.dtype
495 .render_string(event)
496 .map_err(|error| {
497 emit!(TemplateRenderingError {
498 error,
499 field: Some("data_stream.type"),
500 drop_event: true,
501 });
502 })
503 .ok()
504 }
505
506 pub fn dataset<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
507 self.dataset
508 .render_string(event)
509 .map_err(|error| {
510 emit!(TemplateRenderingError {
511 error,
512 field: Some("data_stream.dataset"),
513 drop_event: true,
514 });
515 })
516 .ok()
517 }
518
519 pub fn namespace<'a>(&self, event: impl Into<EventRef<'a>>) -> Option<String> {
520 self.namespace
521 .render_string(event)
522 .map_err(|error| {
523 emit!(TemplateRenderingError {
524 error,
525 field: Some("data_stream.namespace"),
526 drop_event: true,
527 });
528 })
529 .ok()
530 }
531
532 pub fn sync_fields(&self, log: &mut LogEvent) {
533 if !self.sync_fields {
534 return;
535 }
536
537 let dtype = self.dtype(&*log);
538 let dataset = self.dataset(&*log);
539 let namespace = self.namespace(&*log);
540
541 if log.as_map().is_none() {
542 *log.value_mut() = Value::Object(BTreeMap::new());
543 }
544 let existing = log
545 .as_map_mut()
546 .expect("must be a map")
547 .entry("data_stream".into())
548 .or_insert_with(|| Value::Object(BTreeMap::new()))
549 .as_object_mut_unwrap();
550
551 if let Some(dtype) = dtype {
552 existing
553 .entry("type".into())
554 .or_insert_with(|| dtype.into());
555 }
556 if let Some(dataset) = dataset {
557 existing
558 .entry("dataset".into())
559 .or_insert_with(|| dataset.into());
560 }
561 if let Some(namespace) = namespace {
562 existing
563 .entry("namespace".into())
564 .or_insert_with(|| namespace.into());
565 }
566 }
567
568 pub fn index(&self, log: &LogEvent) -> Option<String> {
569 let (dtype, dataset, namespace) = if !self.auto_routing {
570 (self.dtype(log)?, self.dataset(log)?, self.namespace(log)?)
571 } else {
572 let data_stream = log
573 .get(event_path!("data_stream"))
574 .and_then(|ds| ds.as_object());
575 let dtype =
576 auto_routed_value(data_stream, "type", &self.dtype, "data_stream.type", || {
577 self.dtype(log)
578 })?;
579 let dataset = auto_routed_value(
580 data_stream,
581 "dataset",
582 &self.dataset,
583 "data_stream.dataset",
584 || self.dataset(log),
585 )?;
586 let namespace = auto_routed_value(
587 data_stream,
588 "namespace",
589 &self.namespace,
590 "data_stream.namespace",
591 || self.namespace(log),
592 )?;
593 (dtype, dataset, namespace)
594 };
595
596 let name = [dtype, dataset, namespace]
597 .into_iter()
598 .filter(|s| !s.is_empty())
599 .collect::<Vec<_>>()
600 .join("-");
601
602 Some(name)
603 }
604}
605
606fn auto_routed_value<F>(
613 data_stream: Option<&std::collections::BTreeMap<vector_lib::event::KeyString, Value>>,
614 key: &str,
615 template: &ConfinedTemplate,
616 field: &'static str,
617 fallback: F,
618) -> Option<String>
619where
620 F: FnOnce() -> Option<String>,
621{
622 match data_stream.and_then(|ds| ds.get(key)) {
623 Some(value) => {
624 let s = value.to_string_lossy().into_owned();
625 template
638 .check_confinement(&s)
639 .map_err(|error| {
640 emit!(TemplateRenderingError {
641 error,
642 field: Some(field),
643 drop_event: true,
644 });
645 })
646 .ok()?;
647 if !is_valid_data_stream_component(&s, field) {
648 emit!(TemplateRenderingError {
649 error: crate::template::TemplateRenderingError::Confined {
650 rendered_preview: crate::template::confined_preview(&s),
651 rendered_len: s.len(),
652 message: format!(
653 "auto-routed {field} value is not a valid data-stream identifier"
654 ),
655 },
656 field: Some(field),
657 drop_event: true,
658 });
659 return None;
660 }
661 Some(s)
662 }
663 None => fallback(),
664 }
665}
666
667fn is_valid_data_stream_component(s: &str, field: &str) -> bool {
689 if s.is_empty() {
690 return true;
691 }
692 if s.len() > 100 {
693 return false;
694 }
695 const FORBIDDEN: &[char] = &[
697 '\0', '/', '\\', '*', '?', '"', '<', '>', '|', ',', '#', ':', ' ',
698 ];
699 if s.contains(FORBIDDEN) {
700 return false;
701 }
702 if let Some(first) = s.chars().next()
704 && matches!(first, '-' | '_' | '+' | '.')
705 {
706 return false;
707 }
708 if s == "." || s == ".." {
711 return false;
712 }
713 if s.chars().any(|c| c.is_control()) {
715 return false;
716 }
717 if (field == "data_stream.dataset" || field == "data_stream.namespace") && s.contains('-') {
723 return false;
724 }
725 true
726}
727
728#[async_trait::async_trait]
729#[typetag::serde(name = "elasticsearch")]
730impl SinkConfig for ElasticsearchConfig {
731 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
732 Some(&self.confinement)
733 }
734
735 fn input(&self) -> Input {
736 let requirements = Requirement::empty().optional_meaning("timestamp", Kind::timestamp());
737
738 Input::new(DataType::Metric | DataType::Log).with_schema_requirement(requirements)
739 }
740
741 fn acknowledgements(&self) -> &AcknowledgementsConfig {
742 &self.acknowledgements
743 }
744
745 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
746 Some(self)
747 }
748}
749
750#[derive(Clone, Debug)]
751pub struct ValidatedElasticsearch {
752 request_limits: TowerRequestSettings,
753 health_config: HealthConfig,
754 batch_settings: BatcherSettings,
755}
756
757#[async_trait::async_trait]
758impl ValidatedSink for ElasticsearchConfig {
759 type Validated = ValidatedElasticsearch;
760
761 fn validate(&self) -> crate::Result<ValidatedElasticsearch> {
762 match (&self.endpoint, self.endpoints.is_empty()) {
766 (Some(_), false) => return Err(ParseError::EndpointsExclusive.into()),
767 (None, true) => return Err(ParseError::EndpointRequired.into()),
768 _ => {}
769 }
770
771 if self.opensearch_service_type == OpenSearchServiceType::Serverless {
779 match &self.auth {
780 #[cfg(feature = "aws-core")]
781 Some(ElasticsearchAuthConfig::Aws(_)) => (),
782 _ => return Err(ParseError::OpenSearchServerlessRequiresAwsAuth.into()),
783 }
784 }
785 if self.opensearch_service_type == OpenSearchServiceType::Serverless
786 && self.api_version != ElasticsearchApiVersion::Auto
787 {
788 return Err(ParseError::ServerlessElasticsearchApiVersionMustBeAuto.into());
789 }
790
791 #[cfg(feature = "aws-core")]
795 if matches!(self.auth, Some(ElasticsearchAuthConfig::Aws(_)))
796 && self.aws.as_ref().and_then(|aws| aws.region()).is_none()
797 {
798 return Err(ParseError::RegionRequired.into());
799 }
800
801 self.common_mode()?;
806
807 let batch_settings = self.batch.into_batcher_settings()?;
811
812 if self.bulk.version.is_some() && self.bulk.version_type == VersionType::Internal {
813 return Err(ParseError::ExternalVersionIgnoredWithInternalVersioning.into());
814 }
815 if self.bulk.version.is_some()
816 && (self.bulk.version_type == VersionType::External
817 || self.bulk.version_type == VersionType::ExternalGte)
818 && self.id_key.is_none()
819 {
820 return Err(ParseError::ExternalVersioningWithoutDocumentID.into());
821 }
822 if self.bulk.version.is_none()
823 && (self.bulk.version_type == VersionType::External
824 || self.bulk.version_type == VersionType::ExternalGte)
825 {
826 return Err(ParseError::ExternalVersioningWithoutVersion.into());
827 }
828
829 let request_limits = self.request.tower.into_settings();
830 let health_config = self.endpoint_health.clone().unwrap_or_default();
831
832 Ok(ValidatedElasticsearch {
833 request_limits,
834 health_config,
835 batch_settings,
836 })
837 }
838
839 async fn build(
840 &self,
841 validated: &ValidatedElasticsearch,
842 cx: SinkContext,
843 ) -> crate::Result<(VectorSink, Healthcheck)> {
844 let commons = ElasticsearchCommon::parse_many(self, cx.proxy()).await?;
849 let common = commons[0].clone();
850
851 let client = HttpClient::new(common.tls_settings.clone(), cx.proxy())?;
852
853 let health_config = validated.health_config.clone();
854
855 let services = commons
856 .iter()
857 .map(|common| {
858 let endpoint = common.base_url.clone();
859
860 let http_request_builder = HttpRequestBuilder::new(common, self);
861 let service = ElasticsearchService::new(client.clone(), http_request_builder);
862
863 (endpoint, service)
864 })
865 .collect::<Vec<_>>();
866
867 let service = validated.request_limits.clone().distributed_service(
868 ElasticsearchRetryLogic {
869 retry_partial: self.request_retry_partial,
870 },
871 services,
872 health_config,
873 ElasticsearchHealthLogic,
874 1,
875 );
876
877 let sink = ElasticsearchSink::new(&common, self, service, validated.batch_settings)?;
878
879 let stream = VectorSink::from_event_streamsink(sink);
880
881 let healthcheck = futures::future::select_ok(
882 commons
883 .into_iter()
884 .map(move |common| common.healthcheck(client.clone()).boxed()),
885 )
886 .map_ok(|((), _)| ())
887 .boxed();
888 Ok((stream, healthcheck))
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use crate::template::{ConfinementConfig, Template};
896
897 #[test]
898 fn generate_config() {
899 crate::test_util::test_generate_config::<ElasticsearchConfig>();
900 }
901
902 #[test]
903 fn validate_rejects_missing_endpoints() {
904 use crate::config::ValidatedSink;
905 let config: ElasticsearchConfig = serde_yaml::from_str("{}").unwrap();
906 let err = config
907 .validate()
908 .expect_err("an endpoint or endpoints list is required");
909 assert!(
910 err.to_string()
911 .contains("Endpoints option must be specified"),
912 "unexpected error: {err}"
913 );
914 }
915
916 #[test]
917 fn validate_rejects_endpoint_and_endpoints() {
918 use crate::config::ValidatedSink;
919 let config: ElasticsearchConfig = serde_yaml::from_str(
920 r#"
921 endpoint: "http://localhost:9200"
922 endpoints: ["http://localhost:9200"]
923 "#,
924 )
925 .unwrap();
926 let err = config
927 .validate()
928 .expect_err("endpoint and endpoints are mutually exclusive");
929 assert!(
930 err.to_string().contains("mutually exclusive"),
931 "unexpected error: {err}"
932 );
933 }
934
935 #[test]
936 fn validate_accepts_endpoints() {
937 use crate::config::ValidatedSink;
938 let config: ElasticsearchConfig = serde_yaml::from_str(
939 r#"
940 endpoints: ["http://localhost:9200"]
941 "#,
942 )
943 .unwrap();
944 config.validate().expect("endpoints should validate");
945 }
946
947 #[test]
948 fn validate_rejects_endpoint_without_host() {
949 let err = serde_yaml::from_str::<ElasticsearchConfig>(
951 r#"
952 endpoints: ["/path"]
953 "#,
954 )
955 .unwrap_err();
956 assert!(
957 err.to_string().contains("is not a valid URI"),
958 "unexpected error: {err}"
959 );
960 }
961
962 #[test]
963 fn validate_rejects_non_http_endpoint() {
964 let err = serde_yaml::from_str::<ElasticsearchConfig>(
966 r#"
967 endpoints: ["ftp://elasticsearch.example.com"]
968 "#,
969 )
970 .unwrap_err();
971 assert!(
972 err.to_string().contains("must be an absolute http(s) URL"),
973 "unexpected error: {err}"
974 );
975 }
976
977 #[test]
978 fn validate_rejects_invalid_batch_settings() {
979 use crate::config::ValidatedSink;
980 let config: ElasticsearchConfig = serde_yaml::from_str(
981 r#"
982 endpoints: ["http://localhost:9200"]
983 batch:
984 max_events: 0
985 "#,
986 )
987 .unwrap();
988 config
989 .validate()
990 .expect_err("invalid batch settings should fail validation");
991 }
992
993 #[test]
994 fn validate_rejects_external_versioning_without_version() {
995 use crate::config::ValidatedSink;
996 let config: ElasticsearchConfig = serde_yaml::from_str(
997 r#"
998 endpoints: ["http://localhost:9200"]
999 bulk:
1000 version_type: external
1001 "#,
1002 )
1003 .unwrap();
1004 let err = config
1005 .validate()
1006 .expect_err("external versioning without a version should fail validation");
1007 assert!(
1008 err.to_string().contains("version"),
1009 "unexpected error: {err}"
1010 );
1011 }
1012
1013 #[test]
1014 fn validate_rejects_external_versioning_without_id_key() {
1015 use crate::config::ValidatedSink;
1016 let config: ElasticsearchConfig = serde_yaml::from_str(
1017 r#"
1018 endpoints: ["http://localhost:9200"]
1019 bulk:
1020 version: "{{ obj_version }}"
1021 version_type: external
1022 "#,
1023 )
1024 .unwrap();
1025 let err = config
1026 .validate()
1027 .expect_err("external versioning without an id_key should fail validation");
1028 assert!(
1029 err.to_string().contains("document ID"),
1030 "unexpected error: {err}"
1031 );
1032 }
1033
1034 #[test]
1035 fn validate_rejects_serverless_without_aws_auth() {
1036 use crate::config::ValidatedSink;
1037 let config: ElasticsearchConfig = serde_yaml::from_str(
1038 r#"
1039 endpoints: ["http://localhost:9200"]
1040 opensearch_service_type: serverless
1041 api_version: auto
1042 "#,
1043 )
1044 .unwrap();
1045 let err = config.validate().expect_err("serverless requires AWS auth");
1046 assert!(
1047 err.to_string().contains("auth.strategy"),
1048 "unexpected error: {err}"
1049 );
1050 }
1051
1052 #[cfg(feature = "aws-core")]
1053 #[test]
1054 fn validate_rejects_serverless_with_non_auto_api_version() {
1055 use crate::config::ValidatedSink;
1056 let config: ElasticsearchConfig = serde_yaml::from_str(
1057 r#"
1058 endpoints: ["http://localhost:9200"]
1059 opensearch_service_type: serverless
1060 auth:
1061 strategy: aws
1062 api_version: v8
1063 "#,
1064 )
1065 .unwrap();
1066 let err = config
1067 .validate()
1068 .expect_err("serverless requires api_version auto");
1069 assert!(
1070 err.to_string().contains("api_version"),
1071 "unexpected error: {err}"
1072 );
1073 }
1074
1075 #[cfg(feature = "aws-core")]
1076 #[test]
1077 fn validate_rejects_aws_auth_without_region() {
1078 use crate::config::ValidatedSink;
1079 let config: ElasticsearchConfig = serde_yaml::from_str(
1080 r#"
1081 endpoints: ["http://localhost:9200"]
1082 auth:
1083 strategy: aws
1084 "#,
1085 )
1086 .unwrap();
1087 let err = config.validate().expect_err("AWS auth requires aws.region");
1088 assert!(
1089 err.to_string().contains("aws.region"),
1090 "unexpected error: {err}"
1091 );
1092 }
1093
1094 #[cfg(feature = "aws-core")]
1095 #[test]
1096 fn validate_accepts_serverless_with_aws_auth_and_auto_api_version() {
1097 use crate::config::ValidatedSink;
1098 let config: ElasticsearchConfig = serde_yaml::from_str(
1099 r#"
1100 endpoints: ["http://localhost:9200"]
1101 opensearch_service_type: serverless
1102 auth:
1103 strategy: aws
1104 aws:
1105 region: us-east-1
1106 api_version: auto
1107 "#,
1108 )
1109 .unwrap();
1110 config
1111 .validate()
1112 .expect("valid serverless config should validate");
1113 }
1114
1115 #[test]
1116 fn is_valid_data_stream_component_accepts_normal_identifiers() {
1117 assert!(is_valid_data_stream_component("logs", "data_stream.type"));
1120 assert!(is_valid_data_stream_component(
1121 "metrics-prod",
1122 "data_stream.type"
1123 ));
1124 assert!(is_valid_data_stream_component(
1125 "app.errors",
1126 "data_stream.dataset"
1127 ));
1128 assert!(is_valid_data_stream_component(
1129 "tenant_42",
1130 "data_stream.namespace"
1131 ));
1132 assert!(is_valid_data_stream_component("", "data_stream.dataset"));
1134 }
1135
1136 #[test]
1137 fn is_valid_data_stream_component_rejects_traversal_and_injection() {
1138 assert!(!is_valid_data_stream_component("..", "data_stream.dataset"));
1140 assert!(!is_valid_data_stream_component(".", "data_stream.dataset"));
1141 assert!(!is_valid_data_stream_component(
1142 "../evil",
1143 "data_stream.dataset"
1144 ));
1145 assert!(!is_valid_data_stream_component(
1146 "logs/tenant",
1147 "data_stream.type"
1148 ));
1149 assert!(!is_valid_data_stream_component(
1150 "logs\\tenant",
1151 "data_stream.type"
1152 ));
1153 for bad in [
1155 "a*b", "a?b", "a\"b", "a<b", "a>b", "a|b", "a,b", "a#b", "a:b", "a b",
1156 ] {
1157 assert!(
1158 !is_valid_data_stream_component(bad, "data_stream.type"),
1159 "should reject {bad:?}"
1160 );
1161 }
1162 for bad in ["-logs", "_logs", "+logs", ".logs"] {
1164 assert!(
1165 !is_valid_data_stream_component(bad, "data_stream.type"),
1166 "should reject {bad:?}"
1167 );
1168 }
1169 assert!(!is_valid_data_stream_component(
1172 &"x".repeat(101),
1173 "data_stream.type"
1174 ));
1175 assert!(!is_valid_data_stream_component(
1177 "logs\0",
1178 "data_stream.type"
1179 ));
1180 assert!(!is_valid_data_stream_component(
1181 "logs\n",
1182 "data_stream.type"
1183 ));
1184 }
1185
1186 #[test]
1187 fn is_valid_data_stream_component_rejects_hyphen_in_dataset_and_namespace() {
1188 assert!(!is_valid_data_stream_component(
1191 "app-errors",
1192 "data_stream.dataset"
1193 ));
1194 assert!(!is_valid_data_stream_component(
1195 "prod-us",
1196 "data_stream.namespace"
1197 ));
1198 assert!(is_valid_data_stream_component(
1200 "app-errors",
1201 "data_stream.type"
1202 ));
1203 }
1204
1205 #[test]
1206 fn confinement_rejects_unconfined_index() {
1207 let template = Template::try_from("{{ index }}").unwrap();
1208 let config = ConfinementConfig::default();
1209 let result = template.confine(&config, "elasticsearch", "bulk.index");
1210 assert!(result.is_err());
1211 }
1212
1213 #[test]
1214 fn confinement_opt_out_allows_unconfined_index() {
1215 let template = Template::try_from("{{ index }}").unwrap();
1216 let config = ConfinementConfig {
1217 dangerously_allow_unconfined_template_resolution: true,
1218 };
1219 let result = template.confine(&config, "elasticsearch", "bulk.index");
1220 assert!(result.is_ok());
1221 }
1222
1223 #[test]
1224 fn confinement_allows_prefixed_index() {
1225 let template = Template::try_from("events-{{ env }}").unwrap();
1226 let config = ConfinementConfig::default();
1227 let result = template.confine(&config, "elasticsearch", "bulk.index");
1228 assert!(result.is_ok());
1229 }
1230
1231 #[test]
1232 fn validate_produces_request_limits_and_health() {
1233 use crate::config::ValidatedSink;
1234 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1235 endpoints: ["http://localhost:9200"]
1236 "#})
1237 .unwrap();
1238 let validated = config.validate().expect("validation should succeed");
1239 assert_eq!(validated.request_limits.concurrency, None);
1241 assert!(validated.request_limits.timeout.as_secs() > 0);
1242 assert!(validated.health_config.retry_initial_backoff_secs > 0);
1243 }
1244
1245 #[test]
1246 fn parse_aws_auth() {
1247 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1248 endpoints: ["http://localhost:9200"]
1249 auth:
1250 strategy: aws
1251 assume_role: role
1252 "#})
1253 .unwrap();
1254
1255 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1256 endpoints: ["http://localhost:9200"]
1257 auth:
1258 strategy: aws
1259 "#})
1260 .unwrap();
1261 }
1262
1263 #[test]
1264 fn parse_mode() {
1265 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1266 endpoints: ["http://localhost:9200"]
1267 mode: data_stream
1268 data_stream:
1269 type: synthetics
1270 "#})
1271 .unwrap();
1272 assert!(matches!(config.mode, ElasticsearchMode::DataStream));
1273 assert!(config.data_stream.is_some());
1274 }
1275
1276 #[test]
1277 fn parse_distribution() {
1278 serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1279 endpoints: ["http://localhost:9200", "http://localhost:9201"]
1280 distribution:
1281 retry_initial_backoff_secs: 10
1282 "#})
1283 .unwrap();
1284 }
1285
1286 #[test]
1287 fn parse_version() {
1288 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1289 endpoints: ["http://localhost:9200"]
1290 api_version: v7
1291 "#})
1292 .unwrap();
1293 assert_eq!(config.api_version, ElasticsearchApiVersion::V7);
1294 }
1295
1296 #[test]
1297 fn parse_version_auto() {
1298 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1299 endpoints: ["http://localhost:9200"]
1300 api_version: auto
1301 "#})
1302 .unwrap();
1303 assert_eq!(config.api_version, ElasticsearchApiVersion::Auto);
1304 }
1305
1306 #[test]
1307 fn parse_default_bulk() {
1308 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1309 endpoints: ["http://localhost:9200"]
1310 "#})
1311 .unwrap();
1312 assert_eq!(config.mode, ElasticsearchMode::Bulk);
1313 assert_eq!(config.bulk, BulkConfig::default());
1314 }
1315
1316 #[test]
1317 fn parse_opensearch_service_type_managed() {
1318 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1319 endpoints: ["http://localhost:9200"]
1320 opensearch_service_type: managed
1321 "#})
1322 .unwrap();
1323 assert_eq!(
1324 config.opensearch_service_type,
1325 OpenSearchServiceType::Managed
1326 );
1327 }
1328
1329 #[test]
1330 fn parse_opensearch_service_type_serverless() {
1331 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1332 endpoints: ["http://localhost:9200"]
1333 opensearch_service_type: serverless
1334 auth:
1335 strategy: aws
1336 api_version: auto
1337 "#})
1338 .unwrap();
1339 assert_eq!(
1340 config.opensearch_service_type,
1341 OpenSearchServiceType::Serverless
1342 );
1343 }
1344
1345 #[test]
1346 fn parse_opensearch_service_type_default() {
1347 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1348 endpoints: ["http://localhost:9200"]
1349 "#})
1350 .unwrap();
1351 assert_eq!(
1352 config.opensearch_service_type,
1353 OpenSearchServiceType::Managed
1354 );
1355 }
1356
1357 #[cfg(feature = "aws-core")]
1358 #[test]
1359 fn parse_opensearch_serverless_with_aws_auth() {
1360 let config = serde_yaml::from_str::<ElasticsearchConfig>(indoc::indoc! {r#"
1361 endpoints: ["http://localhost:9200"]
1362 opensearch_service_type: serverless
1363 auth:
1364 strategy: aws
1365 api_version: auto
1366 "#})
1367 .unwrap();
1368 assert_eq!(
1369 config.opensearch_service_type,
1370 OpenSearchServiceType::Serverless
1371 );
1372 assert!(matches!(config.auth, Some(ElasticsearchAuthConfig::Aws(_))));
1373 assert_eq!(config.api_version, ElasticsearchApiVersion::Auto);
1374 }
1375}