1use aws_sdk_s3::Client as S3Client;
2use tower::ServiceBuilder;
3#[cfg(feature = "codecs-parquet")]
4use vector_lib::codecs::BatchEncoder;
5#[cfg(feature = "codecs-parquet")]
6use vector_lib::codecs::encoding::{BatchSerializerConfig, format::ParquetSerializerConfig};
7use vector_lib::{
8 TimeZone,
9 codecs::{
10 EncoderKind, TextSerializerConfig,
11 encoding::{Framer, FramingConfig},
12 },
13 configurable::configurable_component,
14 sink::VectorSink,
15};
16
17use super::sink::S3RequestOptions;
18use crate::{
19 aws::{AwsAuthentication, RegionOrEndpoint},
20 codecs::{Encoder, EncodingConfigWithFraming, SinkType},
21 config::{AcknowledgementsConfig, GenerateConfig, Input, ProxyConfig, SinkConfig, SinkContext},
22 sinks::{
23 Healthcheck,
24 s3_common::{
25 self,
26 config::{RetryStrategy, S3Options},
27 partitioner::S3KeyPartitioner,
28 service::S3Service,
29 sink::S3Sink,
30 },
31 util::{
32 BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
33 TowerRequestConfig, timezone_to_offset,
34 },
35 },
36 template::{ConfinementConfig, Template},
37 tls::TlsConfig,
38};
39
40#[cfg(feature = "codecs-parquet")]
42#[configurable_component]
43#[derive(Clone, Debug)]
44#[serde(tag = "codec", rename_all = "snake_case")]
45#[configurable(metadata(
46 docs::enum_tag_description = "The codec to use for batch encoding events."
47))]
48pub enum S3BatchEncoding {
49 Parquet(ParquetSerializerConfig),
53}
54
55#[configurable_component(sink(
57 "aws_s3",
58 "Store observability events in the AWS S3 object storage system."
59))]
60#[derive(Clone, Debug)]
61#[serde(deny_unknown_fields)]
62pub struct S3SinkConfig {
63 #[configurable(metadata(docs::examples = "my-bucket"))]
67 pub bucket: String,
68
69 #[serde(default = "default_key_prefix")]
75 #[configurable(metadata(docs::templateable))]
76 #[configurable(metadata(docs::examples = "date=%F/hour=%H"))]
77 #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d"))]
78 #[configurable(metadata(docs::examples = "application_id={{ application_id }}/date=%F"))]
79 pub key_prefix: String,
80
81 #[serde(default = "default_filename_time_format")]
98 pub filename_time_format: String,
99
100 #[serde(default = "crate::serde::default_true")]
109 #[configurable(metadata(docs::human_name = "Append UUID to Filename"))]
110 pub filename_append_uuid: bool,
111
112 #[configurable(metadata(docs::examples = "json"))]
116 pub filename_extension: Option<String>,
117
118 #[serde(flatten)]
119 pub options: S3Options,
120
121 #[serde(flatten)]
122 pub region: RegionOrEndpoint,
123
124 #[serde(flatten)]
125 pub encoding: EncodingConfigWithFraming,
126
127 #[cfg(feature = "codecs-parquet")]
133 #[configurable(derived)]
134 #[serde(default)]
135 pub batch_encoding: Option<S3BatchEncoding>,
136
137 #[configurable(derived)]
144 #[serde(default = "Compression::gzip_default")]
145 pub compression: Compression,
146
147 #[configurable(derived)]
148 #[serde(default)]
149 pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
150
151 #[configurable(derived)]
152 #[serde(default)]
153 pub request: TowerRequestConfig,
154
155 #[configurable(derived)]
156 pub tls: Option<TlsConfig>,
157
158 #[configurable(derived)]
159 #[serde(default)]
160 pub auth: AwsAuthentication,
161
162 #[configurable(derived)]
163 #[serde(
164 default,
165 deserialize_with = "crate::serde::bool_or_struct",
166 skip_serializing_if = "crate::serde::is_default"
167 )]
168 pub acknowledgements: AcknowledgementsConfig,
169
170 #[configurable(derived)]
171 #[serde(default)]
172 pub timezone: Option<TimeZone>,
173
174 #[serde(default = "crate::serde::default_true")]
178 pub force_path_style: bool,
179
180 #[configurable(derived)]
185 #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")]
186 pub retry_strategy: RetryStrategy,
187
188 #[serde(flatten)]
189 pub confinement: ConfinementConfig,
190}
191
192pub(super) fn default_key_prefix() -> String {
193 "date=%F".to_string()
194}
195
196pub(super) fn default_filename_time_format() -> String {
197 "%s".to_string()
198}
199
200impl GenerateConfig for S3SinkConfig {
201 fn generate_config() -> toml::Value {
202 toml::Value::try_from(Self {
203 bucket: "".to_owned(),
204 key_prefix: default_key_prefix(),
205 filename_time_format: default_filename_time_format(),
206 filename_append_uuid: true,
207 filename_extension: None,
208 options: S3Options::default(),
209 region: RegionOrEndpoint::default(),
210 encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
211 #[cfg(feature = "codecs-parquet")]
212 batch_encoding: None,
213 compression: Compression::gzip_default(),
214 batch: BatchConfig::default(),
215 request: TowerRequestConfig::default(),
216 tls: Some(TlsConfig::default()),
217 auth: AwsAuthentication::default(),
218 acknowledgements: Default::default(),
219 timezone: Default::default(),
220 force_path_style: Default::default(),
221 retry_strategy: Default::default(),
222 confinement: ConfinementConfig::default(),
223 })
224 .unwrap()
225 }
226}
227
228#[async_trait::async_trait]
229#[typetag::serde(name = "aws_s3")]
230impl SinkConfig for S3SinkConfig {
231 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
232 let service = self.create_service(&cx.proxy).await?;
233 let healthcheck = self.build_healthcheck(service.client())?;
234 let sink = self.build_processor(service, cx)?;
235 self.confinement.set_confinement_gauge("sink", Self::NAME);
236 Ok((sink, healthcheck))
237 }
238
239 fn input(&self) -> Input {
240 #[cfg(feature = "codecs-parquet")]
241 if let Some(batch_encoding) = &self.batch_encoding {
242 let S3BatchEncoding::Parquet(parquet_config) = batch_encoding;
243 let resolved = BatchSerializerConfig::Parquet(parquet_config.clone());
244 return Input::new(resolved.input_type());
245 }
246 Input::new(self.encoding.config().1.input_type())
247 }
248
249 fn acknowledgements(&self) -> &AcknowledgementsConfig {
250 &self.acknowledgements
251 }
252}
253
254impl S3SinkConfig {
255 pub fn build_processor(
256 &self,
257 service: S3Service,
258 cx: SinkContext,
259 ) -> crate::Result<VectorSink> {
260 let request_limits = self.request.into_settings();
265 let retry_strategy = self.retry_strategy.clone();
266 let service = ServiceBuilder::new()
267 .settings(request_limits, retry_strategy)
268 .service(service);
269
270 let offset = self
271 .timezone
272 .or(cx.globals.timezone)
273 .and_then(timezone_to_offset);
274
275 let batch_settings = self.batch.into_batcher_settings()?;
277
278 let key_prefix = Template::try_from(self.key_prefix.clone())?.with_tz_offset(offset);
279 let key_prefix = key_prefix.confine(&self.confinement, Self::NAME, "key_prefix")?;
280
281 let ssekms_key_id = self
282 .options
283 .ssekms_key_id
284 .as_ref()
285 .cloned()
286 .map(|ssekms_key_id| Template::try_from(ssekms_key_id.as_str()))
287 .transpose()?
288 .map(|t| t.confine(&self.confinement, Self::NAME, "ssekms_key_id"))
289 .transpose()?;
290
291 let partitioner = S3KeyPartitioner::new(key_prefix, ssekms_key_id, None);
292
293 let transformer = self.encoding.transformer();
294
295 #[cfg(feature = "codecs-parquet")]
298 if let Some(batch_encoding) = &self.batch_encoding {
299 let S3BatchEncoding::Parquet(parquet_config) = batch_encoding;
300 let resolved_batch_config = BatchSerializerConfig::Parquet(parquet_config.clone());
301
302 let batch_serializer = resolved_batch_config.build_batch_serializer()?;
303 let batch_encoder = BatchEncoder::new(batch_serializer);
304
305 let mut api_options = self.options.clone();
308 if api_options.content_type.is_none() {
309 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
310 }
311
312 let encoder = EncoderKind::Batch(batch_encoder);
313
314 let filename_extension = self.filename_extension.clone().or_else(|| {
315 Some(
316 match batch_encoding {
317 S3BatchEncoding::Parquet(_) => "parquet",
318 }
319 .to_string(),
320 )
321 });
322
323 if self.compression != Compression::None {
324 warn!("Top level compression setting ignored when batch_encoding set to parquet.")
325 }
326
327 let request_options = S3RequestOptions {
328 bucket: self.bucket.clone(),
329 api_options,
330 filename_extension,
331 filename_time_format: self.filename_time_format.clone(),
332 filename_append_uuid: self.filename_append_uuid,
333 encoder: (transformer, encoder),
334 compression: Compression::None,
336 filename_tz_offset: offset,
337 };
338
339 let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
340 return Ok(VectorSink::from_event_streamsink(sink));
341 }
342
343 let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
344 let encoder = EncoderKind::Framed(Box::new(Encoder::<Framer>::new(framer, serializer)));
345
346 let request_options = S3RequestOptions {
347 bucket: self.bucket.clone(),
348 api_options: self.options.clone(),
349 filename_extension: self.filename_extension.clone(),
350 filename_time_format: self.filename_time_format.clone(),
351 filename_append_uuid: self.filename_append_uuid,
352 encoder: (transformer, encoder),
353 compression: self.compression,
354 filename_tz_offset: offset,
355 };
356
357 let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
358
359 Ok(VectorSink::from_event_streamsink(sink))
360 }
361
362 pub fn build_healthcheck(&self, client: S3Client) -> crate::Result<Healthcheck> {
363 s3_common::config::build_healthcheck(self.bucket.clone(), client)
364 }
365
366 pub async fn create_service(&self, proxy: &ProxyConfig) -> crate::Result<S3Service> {
367 s3_common::config::create_service(
368 &self.region,
369 &self.auth,
370 proxy,
371 self.tls.as_ref(),
372 self.force_path_style,
373 )
374 .await
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::S3SinkConfig;
381 use crate::template::{ConfinementConfig, Template};
382
383 #[test]
384 fn generate_config() {
385 crate::test_util::test_generate_config::<S3SinkConfig>();
386 }
387
388 #[cfg(feature = "codecs-parquet")]
390 #[test]
391 fn parquet_batch_encoding_correct_toml_shape() {
392 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
393 bucket: test-bucket
394 compression: none
395 encoding:
396 codec: text
397 batch_encoding:
398 schema_mode: auto_infer
399 codec: parquet
400 compression:
401 algorithm: snappy
402 "#})
403 .expect("correct batch_encoding shape should parse");
404
405 let batch_enc = config
406 .batch_encoding
407 .expect("batch_encoding should be Some");
408 let super::S3BatchEncoding::Parquet(ref p) = batch_enc;
409 use vector_lib::codecs::encoding::format::{ParquetCompression, ParquetSchemaMode};
410 assert_eq!(p.schema_mode, ParquetSchemaMode::AutoInfer);
411 assert_eq!(p.compression, ParquetCompression::Snappy);
412 }
413
414 #[cfg(feature = "codecs-parquet")]
417 #[test]
418 fn parquet_content_type_auto_detected() {
419 use vector_lib::codecs::encoding::format::{
420 ParquetCompression, ParquetSchemaMode, ParquetSerializerConfig,
421 };
422
423 use crate::sinks::s3_common::config::S3Options;
424 use crate::sinks::util::{BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression};
425 use vector_lib::codecs::TextSerializerConfig;
426 use vector_lib::codecs::encoding::{BatchSerializerConfig, FramingConfig};
427
428 let parquet_config = ParquetSerializerConfig {
429 schema_mode: ParquetSchemaMode::AutoInfer,
430 compression: ParquetCompression::Snappy,
431 ..Default::default()
432 };
433
434 let config = S3SinkConfig {
435 bucket: "test".to_string(),
436 key_prefix: super::default_key_prefix(),
437 filename_time_format: super::default_filename_time_format(),
438 filename_append_uuid: true,
439 filename_extension: None,
440 options: S3Options::default(),
441 region: crate::aws::RegionOrEndpoint::with_both("us-east-1", "http://localhost:4566"),
442 encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
443 batch_encoding: Some(super::S3BatchEncoding::Parquet(parquet_config)),
444 compression: Compression::None,
445 batch: BatchConfig::<BulkSizeBasedDefaultBatchSettings>::default(),
446 request: Default::default(),
447 tls: Default::default(),
448 auth: Default::default(),
449 acknowledgements: Default::default(),
450 timezone: Default::default(),
451 force_path_style: true,
452 retry_strategy: Default::default(),
453 confinement: ConfinementConfig::default(),
454 };
455
456 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
457 let batch_config = BatchSerializerConfig::Parquet(p.clone());
458 let batch_serializer = batch_config.build_batch_serializer().unwrap();
459 let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
460
461 let mut api_options = config.options.clone();
462 if api_options.content_type.is_none() {
463 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
464 }
465
466 assert_eq!(
467 api_options.content_type.as_deref(),
468 Some("application/vnd.apache.parquet"),
469 "Content-Type must be auto-detected for Parquet"
470 );
471 }
472
473 #[cfg(feature = "codecs-parquet")]
475 #[test]
476 fn parquet_content_type_user_override_preserved() {
477 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
478 bucket: test-bucket
479 compression: none
480 content_type: "application/octet-stream"
481 encoding:
482 codec: text
483 batch_encoding:
484 codec: parquet
485 schema_mode: auto_infer
486 compression:
487 algorithm: gzip
488 level: 9
489 "#})
490 .unwrap();
491
492 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
493 let batch_config = vector_lib::codecs::encoding::BatchSerializerConfig::Parquet(p.clone());
494 let batch_serializer = batch_config.build_batch_serializer().unwrap();
495 let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
496
497 let mut api_options = config.options.clone();
498 if api_options.content_type.is_none() {
499 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
500 }
501
502 assert_eq!(
503 api_options.content_type.as_deref(),
504 Some("application/octet-stream"),
505 "User-specified Content-Type must not be overridden"
506 );
507 }
508
509 #[cfg(feature = "codecs-parquet")]
512 #[test]
513 fn parquet_batch_encoding_rejects_unsupported_codec() {
514 let err = serde_yaml::from_str::<S3SinkConfig>(
515 r#"
516 bucket: test-bucket
517 compression: none
518 encoding:
519 codec: text
520 batch_encoding:
521 codec: arrow_stream
522 "#,
523 )
524 .unwrap_err();
525
526 assert!(
527 err.to_string().contains("arrow_stream"),
528 "expected error to mention the offending codec, got: {err}"
529 );
530 }
531
532 #[cfg(feature = "codecs-parquet")]
534 #[test]
535 fn parquet_filename_extension_user_override() {
536 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
537 bucket: test-bucket
538 compression: none
539 filename_extension: pq
540 encoding:
541 codec: text
542 batch_encoding:
543 codec: parquet
544 schema_mode: auto_infer
545 "#})
546 .unwrap();
547
548 assert_eq!(config.filename_extension.as_deref(), Some("pq"));
549 }
550
551 #[cfg(feature = "codecs-parquet")]
553 #[test]
554 fn parquet_schema_mode_defaults_to_relaxed() {
555 use vector_lib::codecs::encoding::format::ParquetSchemaMode;
556
557 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
558 bucket: test-bucket
559 compression: none
560 encoding:
561 codec: text
562 batch_encoding:
563 codec: parquet
564 "#})
565 .unwrap();
566
567 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
568 assert_eq!(p.schema_mode, ParquetSchemaMode::Relaxed);
569 }
570
571 #[cfg(feature = "codecs-parquet")]
573 #[test]
574 fn parquet_schema_mode_strict_parsed() {
575 use vector_lib::codecs::encoding::format::ParquetSchemaMode;
576
577 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
578 bucket: test-bucket
579 compression: none
580 encoding:
581 codec: text
582 batch_encoding:
583 codec: parquet
584 schema_mode: strict
585 schema_file: tmp/something.schema
586 "#})
587 .unwrap();
588
589 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
590 assert_eq!(p.schema_mode, ParquetSchemaMode::Strict);
591 }
592
593 #[test]
594 fn confinement_rejects_unconfined_key_prefix() {
595 let template: Template = "{{ tenant }}".try_into().unwrap();
596 let err = template
597 .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
598 .unwrap_err();
599 assert!(
600 err.to_string().contains("no literal string prefix"),
601 "unexpected error: {err}"
602 );
603 }
604
605 #[test]
606 fn confinement_opt_out_allows_unconfined_key_prefix() {
607 let cfg = ConfinementConfig {
608 dangerously_allow_unconfined_template_resolution: true,
609 };
610 let template: Template = "{{ tenant }}".try_into().unwrap();
611 assert!(template.confine(&cfg, "aws_s3", "key_prefix").is_ok());
612 }
613
614 #[test]
615 fn confinement_blocks_dotdot_escape_at_render() {
616 use crate::event::Event;
617 use vector_lib::event::LogEvent;
618 use vrl::event_path;
619
620 let template: Template = "safe/{{ tenant }}/".try_into().unwrap();
621 let template = template
622 .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
623 .unwrap();
624 let mut event = Event::Log(LogEvent::from("x"));
625 event
626 .as_mut_log()
627 .insert(event_path!("tenant"), "../../escape");
628 assert!(template.render_string(&event).is_err());
629 }
630}