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    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
44/// The field name for the timestamp required by data stream mode
45pub const DATA_STREAM_TIMESTAMP_KEY: &str = "@timestamp";
46
47/// The Amazon OpenSearch service type, either managed or serverless; primarily, selects the
48/// correct AWS service to use when calculating the AWS v4 signature + disables features
49/// unsupported by serverless: Elasticsearch API version autodetection, health checks
50#[configurable_component]
51#[derive(Clone, Debug, Eq, PartialEq)]
52#[serde(deny_unknown_fields, rename_all = "lowercase")]
53#[derive(Default)]
54pub enum OpenSearchServiceType {
55    /// Elasticsearch or OpenSearch Managed domain
56    #[default]
57    Managed,
58    /// OpenSearch Serverless collection
59    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/// Configuration for the `elasticsearch` sink.
72#[configurable_component(sink("elasticsearch", "Index observability events in Elasticsearch."))]
73#[derive(Clone, Debug)]
74#[serde(deny_unknown_fields)]
75pub struct ElasticsearchConfig {
76    /// The Elasticsearch endpoint to send logs to.
77    ///
78    /// The endpoint must contain an HTTP scheme, and may specify a
79    /// hostname or IP address and port.
80    #[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    /// A list of Elasticsearch endpoints to send logs to.
88    ///
89    /// The endpoint must contain an HTTP scheme, and may specify a
90    /// hostname or IP address and port.
91    /// The endpoint may include basic authentication credentials,
92    /// e.g., `https://user:password@example.com`. If credentials are provided in the endpoint,
93    /// they will be used to authenticate against Elasticsearch.
94    ///
95    /// If `auth` is specified and the endpoint contains credentials,
96    /// a configuration error will be raised.
97    #[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    /// The [`doc_type`][doc_type] for your index data.
105    ///
106    /// This is only relevant for Elasticsearch <= 6.X. If you are using >= 7.0 you do not need to
107    /// set this option since Elasticsearch has removed it.
108    ///
109    /// [doc_type]: https://www.elastic.co/guide/en/elasticsearch/reference/6.8/actions-index.html
110    #[serde(default = "default_doc_type")]
111    pub doc_type: String,
112
113    /// The API version of Elasticsearch.
114    ///
115    /// Amazon OpenSearch Serverless requires this option to be set to `auto` (the default).
116    #[serde(default)]
117    #[configurable(derived)]
118    pub api_version: ElasticsearchApiVersion,
119
120    /// Whether or not to send the `type` field to Elasticsearch.
121    ///
122    /// The `type` field was deprecated in Elasticsearch 7.x and removed in Elasticsearch 8.x.
123    ///
124    /// If enabled, the `doc_type` option is ignored.
125    #[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    /// Whether or not to retry successful requests containing partial failures.
132    ///
133    /// To avoid duplicates in Elasticsearch, please use option `id_key`.
134    #[serde(default)]
135    pub request_retry_partial: bool,
136
137    /// The name of the event key that should map to Elasticsearch’s [`_id` field][es_id].
138    ///
139    /// By default, the `_id` field is not set, which allows Elasticsearch to set this
140    /// automatically. Setting your own Elasticsearch IDs can [hinder performance][perf_doc].
141    ///
142    /// [es_id]: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-id-field.html
143    /// [perf_doc]: https://www.elastic.co/guide/en/elasticsearch/reference/master/tune-for-indexing-speed.html#_use_auto_generated_ids
144    #[serde(default)]
145    #[configurable(metadata(docs::examples = "id"))]
146    #[configurable(metadata(docs::examples = "_id"))]
147    pub id_key: Option<ConfigValuePath>,
148
149    /// The name of the pipeline to apply.
150    #[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    /// Custom parameters to add to the query string for each HTTP request sent to Elasticsearch.
178    #[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    /// 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    /// Build the render-capable [`ElasticsearchCommonMode`], confining the templated fields of the
272    /// active mode. `common_mode()` ignores the inactive branch, so a leftover unused template
273    /// (e.g. a `bulk.index` in a config that runs in `data_stream` mode) is never confined and
274    /// cannot reject an otherwise-valid config.
275    pub fn common_mode(&self) -> crate::Result<ElasticsearchCommonMode> {
276        match self.mode {
277            ElasticsearchMode::Bulk => Ok(ElasticsearchCommonMode::Bulk {
278                // Only `index` is a routing field, so it is the only bulk template that is confined.
279                // `action` and `version` are not routing values and render unconfined, hence their
280                // `UnconfinedTemplate` type.
281                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/// Elasticsearch bulk mode configuration.
302#[configurable_component]
303#[derive(Clone, Debug, PartialEq)]
304#[serde(rename_all = "snake_case")]
305pub struct BulkConfig {
306    /// Action to use when making requests to the [Elasticsearch Bulk API][es_bulk].
307    ///
308    /// Only `index`, `create` and `update` actions are supported.
309    ///
310    /// [es_bulk]: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
311    #[serde(default = "default_bulk_action")]
312    #[configurable(metadata(docs::examples = "create"))]
313    #[configurable(metadata(docs::examples = "{{ action }}"))]
314    pub action: UnconfinedTemplate,
315
316    /// The name of the index to write events to.
317    #[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    /// The default index to write events to if the template in `bulk.index` cannot be resolved
323    #[configurable(metadata(docs::examples = "test-index"))]
324    pub template_fallback_index: Option<String>,
325
326    /// Version field value.
327    #[configurable(metadata(docs::examples = "{{ obj_version }}-%Y-%m-%d"))]
328    #[configurable(metadata(docs::examples = "123"))]
329    pub version: Option<UnconfinedTemplate>,
330
331    /// Version type.
332    ///
333    /// Possible values are `internal`, `external` or `external_gt` and `external_gte`.
334    ///
335    /// [es_index_versioning]: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#index-versioning
336    #[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/// Elasticsearch data stream mode configuration.
367#[configurable_component]
368#[derive(Clone, Debug)]
369#[serde(rename_all = "snake_case")]
370pub struct DataStreamConfig {
371    /// The data stream type used to construct the data stream at index time.
372    #[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    /// The data stream dataset used to construct the data stream at index time.
379    #[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    /// The data stream namespace used to construct the data stream at index time.
386    #[serde(default = "DataStreamConfig::default_namespace")]
387    #[configurable(metadata(docs::examples = "{{ environment }}"))]
388    pub namespace: Template,
389
390    /// Automatically routes events by deriving the data stream name using specific event fields.
391    ///
392    /// The format of the data stream name is `<type>-<dataset>-<namespace>`, where each value comes
393    /// from the `data_stream` configuration field of the same name.
394    ///
395    /// If enabled, the value of the `data_stream.type`, `data_stream.dataset`, and
396    /// `data_stream.namespace` event fields are used if they are present. Otherwise, the values
397    /// set in this configuration are used.
398    #[serde(default = "DataStreamConfig::default_auto_routing")]
399    pub auto_routing: bool,
400
401    /// Automatically adds and syncs the `data_stream.*` event fields if they are missing from the event.
402    ///
403    /// This ensures that fields match the name of the data stream that is receiving events.
404    #[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    /// Confine the templated fields, producing a render-capable [`DataStreamMode`].
442    ///
443    /// This is the only way to obtain a `DataStreamMode`, so the `data_stream.*` templates can
444    /// never be rendered without first passing through confinement.
445    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/// Runtime counterpart of [`DataStreamConfig`], holding confined templates.
469///
470/// Obtained via [`DataStreamConfig::confine`]. The render methods here operate on
471/// [`ConfinedTemplate`], so the confinement checker is enforced on every rendered value.
472#[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    /// If there is a `timestamp` field, rename it to the expected `@timestamp` for Elastic Common Schema.
483    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(&timestamp_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
606/// Auto-routed helper: prefer the event's `data_stream.<key>` field, but run
607/// that raw value through the confinement check attached to the
608/// corresponding template so `auto_routing` can't be used to bypass the
609/// build-time confinement on `data_stream.{type,dataset,namespace}`. Falls
610/// back to `fallback` (normal template rendering) when the event field is
611/// missing.
612fn 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            // Two layers of validation:
626            //
627            // 1. If the operator-authored template has a confinement checker,
628            //    run it. That catches values that violate a `PrefixChecker`
629            //    base or contain `..` segments.
630            //
631            // 2. If the template is *static* (e.g. the default `type = "logs"`),
632            //    no checker is attached and `check_confinement` returns Ok
633            //    for anything. Data-stream names are simple identifiers per
634            //    Elasticsearch's naming rules, so anything containing a path
635            //    separator, `..`, NUL, or over 255 bytes is either an attack
636            //    or would be rejected by Elasticsearch on ingest anyway.
637            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
667/// Baseline sanity check for auto-routed `data_stream.*` values pulled off
668/// events. Rejects anything Elasticsearch itself would reject at ingest, so
669/// that attacker-controlled values can't slip past Vector's drop guard and
670/// blow up later in the request pipeline.
671///
672/// Rules (from the Elasticsearch data-stream / index naming spec):
673///
674/// - No control characters, NUL bytes, or the characters
675///   `\ / * ? " < > | , # : <space>` (forbidden in any index or
676///   data-stream name).
677/// - No exact `.` or `.._` path segments (traversal).
678/// - Cannot start with `- _ + .` (reserved leading characters).
679/// - Combined `type-dataset-namespace` is capped at 100 bytes by
680///   Elasticsearch, so each part must be well under that. We use 100
681///   here as a per-part cap — cheap and conservative.
682/// - `dataset` and `namespace` additionally forbid `-` because `-` is
683///   the separator between the three parts of a data-stream name.
684///
685/// Empty is legitimate — `DataStreamConfig::index` filters empty parts
686/// out of the joined name, so an event field explicitly overriding one
687/// part to `""` just skips it.
688fn 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    // Forbidden anywhere in the string.
696    const FORBIDDEN: &[char] = &[
697        '\0', '/', '\\', '*', '?', '"', '<', '>', '|', ',', '#', ':', ' ',
698    ];
699    if s.contains(FORBIDDEN) {
700        return false;
701    }
702    // Reserved leading characters.
703    if let Some(first) = s.chars().next()
704        && matches!(first, '-' | '_' | '+' | '.')
705    {
706        return false;
707    }
708    // Path-traversal segments — belt-and-braces even though `/` and `\`
709    // are already forbidden.
710    if s == "." || s == ".." {
711        return false;
712    }
713    // Control characters have no place in a routing identifier.
714    if s.chars().any(|c| c.is_control()) {
715        return false;
716    }
717    // `-` is the separator inside the composed data-stream name
718    // (`{type}-{dataset}-{namespace}`), so `dataset` and `namespace`
719    // must not contain it. `type` values (`logs`, `metrics`, …) also
720    // conventionally don't contain `-`, but we accept it there for
721    // forward compatibility with custom types.
722    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        // Mirror the pure endpoint-required/exclusive checks from
763        // `ElasticsearchCommon::parse_many` so configs that deterministically
764        // fail at build time are rejected here instead.
765        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        // `HttpEndpoint` validates the scheme (http/https, defaulting a
772        // missing scheme to https) and rejects host-less endpoints at load
773        // time, so the manual checks are no longer needed here.
774
775        // Mirror the pure Serverless checks from `ElasticsearchCommon::parse_config`
776        // (auth strategy, region, and API-version) so configs that deterministically
777        // fail at build time are rejected here instead.
778        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        // Mirror the pure region check from `ElasticsearchCommon::extract_auth` so
792        // AWS auth configs without a region are rejected during validation instead
793        // of failing at build time.
794        #[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        // Run the pure routing-template confinement check for the active mode
802        // so unconfined `bulk.index` / `data_stream.*` templates are rejected
803        // here. The confined mode itself is reconstructed during build (inside
804        // `ElasticsearchCommon::parse_many`).
805        self.common_mode()?;
806
807        // Mirror the pure batch-settings and versioning checks from
808        // `ElasticsearchCommon::parse_config` so configs that deterministically
809        // fail at build time are rejected here instead.
810        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        // Confinement of the active mode's routing templates happens in
845        // [`ElasticsearchConfig::common_mode`], which each `ElasticsearchCommon` calls while
846        // parsing (which performs I/O: AWS credential resolution and API-version autodetection).
847        // The pure request/health settings were resolved during `validate`.
848        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        // `HttpEndpoint` rejects host-less endpoints at deserialization time.
950        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        // `HttpEndpoint` rejects non-http(s) schemes at deserialization time.
965        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        // `-` allowed for `type` (custom types), forbidden for
1118        // dataset/namespace where it collides with the separator.
1119        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        // Empty is accepted — filtered out of the joined name downstream.
1133        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        // Path traversal / separators.
1139        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        // Elasticsearch-forbidden characters.
1154        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        // Reserved leading characters.
1163        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        // Length cap (Elasticsearch caps the joined name at 100 bytes; we
1170        // apply per-part to stay safely under).
1171        assert!(!is_valid_data_stream_component(
1172            &"x".repeat(101),
1173            "data_stream.type"
1174        ));
1175        // NUL + control chars.
1176        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        // `-` separates the three parts of a data-stream name, so it
1189        // must not appear inside dataset or namespace.
1190        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        // Same string is accepted for `type` (custom types may contain `-`).
1199        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        // Default request limits and health settings are resolved during validation.
1240        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}