Skip to main content

vector/sinks/elasticsearch/
config.rs

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
40/// The field name for the timestamp required by data stream mode
41pub const DATA_STREAM_TIMESTAMP_KEY: &str = "@timestamp";
42
43/// The Amazon OpenSearch service type, either managed or serverless; primarily, selects the
44/// correct AWS service to use when calculating the AWS v4 signature + disables features
45/// unsupported by serverless: Elasticsearch API version autodetection, health checks
46#[configurable_component]
47#[derive(Clone, Debug, Eq, PartialEq)]
48#[serde(deny_unknown_fields, rename_all = "lowercase")]
49#[derive(Default)]
50pub enum OpenSearchServiceType {
51    /// Elasticsearch or OpenSearch Managed domain
52    #[default]
53    Managed,
54    /// OpenSearch Serverless collection
55    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/// Configuration for the `elasticsearch` sink.
68#[configurable_component(sink("elasticsearch", "Index observability events in Elasticsearch."))]
69#[derive(Clone, Debug)]
70#[serde(deny_unknown_fields)]
71pub struct ElasticsearchConfig {
72    /// The Elasticsearch endpoint to send logs to.
73    ///
74    /// The endpoint must contain an HTTP scheme, and may specify a
75    /// hostname or IP address and port.
76    #[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    /// A list of Elasticsearch endpoints to send logs to.
83    ///
84    /// The endpoint must contain an HTTP scheme, and may specify a
85    /// hostname or IP address and port.
86    /// The endpoint may include basic authentication credentials,
87    /// e.g., `https://user:password@example.com`. If credentials are provided in the endpoint,
88    /// they will be used to authenticate against Elasticsearch.
89    ///
90    /// If `auth` is specified and the endpoint contains credentials,
91    /// a configuration error will be raised.
92    #[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    /// The [`doc_type`][doc_type] for your index data.
99    ///
100    /// This is only relevant for Elasticsearch <= 6.X. If you are using >= 7.0 you do not need to
101    /// set this option since Elasticsearch has removed it.
102    ///
103    /// [doc_type]: https://www.elastic.co/guide/en/elasticsearch/reference/6.8/actions-index.html
104    #[serde(default = "default_doc_type")]
105    #[configurable(metadata(docs::advanced))]
106    pub doc_type: String,
107
108    /// The API version of Elasticsearch.
109    ///
110    /// Amazon OpenSearch Serverless requires this option to be set to `auto` (the default).
111    #[serde(default)]
112    #[configurable(derived)]
113    pub api_version: ElasticsearchApiVersion,
114
115    /// Whether or not to send the `type` field to Elasticsearch.
116    ///
117    /// The `type` field was deprecated in Elasticsearch 7.x and removed in Elasticsearch 8.x.
118    ///
119    /// If enabled, the `doc_type` option is ignored.
120    #[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    /// Whether or not to retry successful requests containing partial failures.
127    ///
128    /// To avoid duplicates in Elasticsearch, please use option `id_key`.
129    #[serde(default)]
130    #[configurable(metadata(docs::advanced))]
131    pub request_retry_partial: bool,
132
133    /// The name of the event key that should map to Elasticsearch’s [`_id` field][es_id].
134    ///
135    /// By default, the `_id` field is not set, which allows Elasticsearch to set this
136    /// automatically. Setting your own Elasticsearch IDs can [hinder performance][perf_doc].
137    ///
138    /// [es_id]: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-id-field.html
139    /// [perf_doc]: https://www.elastic.co/guide/en/elasticsearch/reference/master/tune-for-indexing-speed.html#_use_auto_generated_ids
140    #[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    /// The name of the pipeline to apply.
147    #[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    /// Custom parameters to add to the query string for each HTTP request sent to Elasticsearch.
177    #[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    /// Amazon OpenSearch service type
189    #[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    // TODO: `bulk` and `data_stream` are each only relevant if the `mode` is set to their
202    // corresponding mode. An improvement to look into would be to extract the `BulkConfig` and
203    // `DataStreamConfig` into the `mode` enum variants. Doing so would remove them from the root
204    // of the config here and thus any post serde config parsing manual error prone logic.
205    #[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(), // the default mode is Bulk
262            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/// Elasticsearch bulk mode configuration.
288#[configurable_component]
289#[derive(Clone, Debug, PartialEq)]
290#[serde(rename_all = "snake_case")]
291pub struct BulkConfig {
292    /// Action to use when making requests to the [Elasticsearch Bulk API][es_bulk].
293    ///
294    /// Only `index`, `create` and `update` actions are supported.
295    ///
296    /// [es_bulk]: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
297    #[serde(default = "default_bulk_action")]
298    #[configurable(metadata(docs::examples = "create"))]
299    #[configurable(metadata(docs::examples = "{{ action }}"))]
300    pub action: Template,
301
302    /// The name of the index to write events to.
303    #[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    /// The default index to write events to if the template in `bulk.index` cannot be resolved
309    #[configurable(metadata(docs::examples = "test-index"))]
310    pub template_fallback_index: Option<String>,
311
312    /// Version field value.
313    #[configurable(metadata(docs::examples = "{{ obj_version }}-%Y-%m-%d"))]
314    #[configurable(metadata(docs::examples = "123"))]
315    pub version: Option<Template>,
316
317    /// Version type.
318    ///
319    /// Possible values are `internal`, `external` or `external_gt` and `external_gte`.
320    ///
321    /// [es_index_versioning]: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#index-versioning
322    #[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/// Elasticsearch data stream mode configuration.
353#[configurable_component]
354#[derive(Clone, Debug)]
355#[serde(rename_all = "snake_case")]
356pub struct DataStreamConfig {
357    /// The data stream type used to construct the data stream at index time.
358    #[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    /// The data stream dataset used to construct the data stream at index time.
365    #[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    /// The data stream namespace used to construct the data stream at index time.
372    #[serde(default = "DataStreamConfig::default_namespace")]
373    #[configurable(metadata(docs::examples = "{{ environment }}"))]
374    pub namespace: Template,
375
376    /// Automatically routes events by deriving the data stream name using specific event fields.
377    ///
378    /// The format of the data stream name is `<type>-<dataset>-<namespace>`, where each value comes
379    /// from the `data_stream` configuration field of the same name.
380    ///
381    /// If enabled, the value of the `data_stream.type`, `data_stream.dataset`, and
382    /// `data_stream.namespace` event fields are used if they are present. Otherwise, the values
383    /// set in this configuration are used.
384    #[serde(default = "DataStreamConfig::default_auto_routing")]
385    pub auto_routing: bool,
386
387    /// Automatically adds and syncs the `data_stream.*` event fields if they are missing from the event.
388    ///
389    /// This ensures that fields match the name of the data stream that is receiving events.
390    #[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    /// If there is a `timestamp` field, rename it to the expected `@timestamp` for Elastic Common Schema.
428    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(&timestamp_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
551/// Auto-routed helper: prefer the event's `data_stream.<key>` field, but run
552/// that raw value through the confinement check attached to the
553/// corresponding template so `auto_routing` can't be used to bypass the
554/// build-time confinement on `data_stream.{type,dataset,namespace}`. Falls
555/// back to `fallback` (normal template rendering) when the event field is
556/// missing.
557fn 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            // Two layers of validation:
571            //
572            // 1. If the operator-authored template has a confinement checker,
573            //    run it. That catches values that violate a `PrefixChecker`
574            //    base or contain `..` segments.
575            //
576            // 2. If the template is *static* (e.g. the default `type = "logs"`),
577            //    no checker is attached and `check_confinement` returns Ok
578            //    for anything. Data-stream names are simple identifiers per
579            //    Elasticsearch's naming rules, so anything containing a path
580            //    separator, `..`, NUL, or over 255 bytes is either an attack
581            //    or would be rejected by Elasticsearch on ingest anyway.
582            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
612/// Baseline sanity check for auto-routed `data_stream.*` values pulled off
613/// events. Rejects anything Elasticsearch itself would reject at ingest, so
614/// that attacker-controlled values can't slip past Vector's drop guard and
615/// blow up later in the request pipeline.
616///
617/// Rules (from the Elasticsearch data-stream / index naming spec):
618///
619/// - No control characters, NUL bytes, or the characters
620///   `\ / * ? " < > | , # : <space>` (forbidden in any index or
621///   data-stream name).
622/// - No exact `.` or `.._` path segments (traversal).
623/// - Cannot start with `- _ + .` (reserved leading characters).
624/// - Combined `type-dataset-namespace` is capped at 100 bytes by
625///   Elasticsearch, so each part must be well under that. We use 100
626///   here as a per-part cap — cheap and conservative.
627/// - `dataset` and `namespace` additionally forbid `-` because `-` is
628///   the separator between the three parts of a data-stream name.
629///
630/// Empty is legitimate — `DataStreamConfig::index` filters empty parts
631/// out of the joined name, so an event field explicitly overriding one
632/// part to `""` just skips it.
633fn 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    // Forbidden anywhere in the string.
641    const FORBIDDEN: &[char] = &[
642        '\0', '/', '\\', '*', '?', '"', '<', '>', '|', ',', '#', ':', ' ',
643    ];
644    if s.contains(FORBIDDEN) {
645        return false;
646    }
647    // Reserved leading characters.
648    if let Some(first) = s.chars().next()
649        && matches!(first, '-' | '_' | '+' | '.')
650    {
651        return false;
652    }
653    // Path-traversal segments — belt-and-braces even though `/` and `\`
654    // are already forbidden.
655    if s == "." || s == ".." {
656        return false;
657    }
658    // Control characters have no place in a routing identifier.
659    if s.chars().any(|c| c.is_control()) {
660        return false;
661    }
662    // `-` is the separator inside the composed data-stream name
663    // (`{type}-{dataset}-{namespace}`), so `dataset` and `namespace`
664    // must not contain it. `type` values (`logs`, `metrics`, …) also
665    // conventionally don't contain `-`, but we accept it there for
666    // forward compatibility with custom types.
667    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        // Confine only the routing fields belonging to the active mode.
679        // `common_mode()` ignores the inactive branch, so confining unused
680        // templates would reject otherwise-valid configs (e.g. a leftover
681        // `bulk.index = "{{ index }}"` in a config that runs in
682        // `data_stream` mode).
683        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        // `-` allowed for `type` (custom types), forbidden for
784        // dataset/namespace where it collides with the separator.
785        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        // Empty is accepted — filtered out of the joined name downstream.
799        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        // Path traversal / separators.
805        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        // Elasticsearch-forbidden characters.
820        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        // Reserved leading characters.
829        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        // Length cap (Elasticsearch caps the joined name at 100 bytes; we
836        // apply per-part to stay safely under).
837        assert!(!is_valid_data_stream_component(
838            &"x".repeat(101),
839            "data_stream.type"
840        ));
841        // NUL + control chars.
842        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        // `-` separates the three parts of a data-stream name, so it
855        // must not appear inside dataset or namespace.
856        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        // Same string is accepted for `type` (custom types may contain `-`).
865        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}