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 stream::BatcherSettings,
16};
17
18use super::sink::S3RequestOptions;
19use crate::{
20 aws::{AwsAuthentication, RegionOrEndpoint},
21 codecs::{Encoder, EncodingConfigWithFraming, SinkType},
22 config::{
23 AcknowledgementsConfig, DynValidatedSink, GenerateConfig, Input, ProxyConfig, SinkConfig,
24 SinkContext, ValidatedSink,
25 },
26 sinks::{
27 Healthcheck,
28 s3_common::{
29 self,
30 config::{RetryStrategy, S3Options},
31 partitioner::S3KeyPartitioner,
32 service::S3Service,
33 sink::S3Sink,
34 },
35 util::{
36 BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
37 TowerRequestConfig, timezone_to_offset,
38 },
39 },
40 template::{ConfinedTemplate, ConfinementConfig, Template},
41 tls::TlsConfig,
42};
43
44#[cfg(feature = "codecs-parquet")]
46#[configurable_component]
47#[derive(Clone, Debug)]
48#[serde(tag = "codec", rename_all = "snake_case")]
49#[configurable(metadata(
50 docs::enum_tag_description = "The codec to use for batch encoding events."
51))]
52pub enum S3BatchEncoding {
53 Parquet(ParquetSerializerConfig),
57}
58
59#[configurable_component(sink(
61 "aws_s3",
62 "Store observability events in the AWS S3 object storage system."
63))]
64#[derive(Clone, Debug)]
65#[serde(deny_unknown_fields)]
66pub struct S3SinkConfig {
67 #[configurable(metadata(docs::examples = "my-bucket"))]
71 pub bucket: String,
72
73 #[serde(default = "default_key_prefix")]
79 #[configurable(metadata(docs::templateable))]
80 #[configurable(metadata(docs::examples = "date=%F/hour=%H"))]
81 #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d"))]
82 #[configurable(metadata(docs::examples = "application_id={{ application_id }}/date=%F"))]
83 pub key_prefix: String,
84
85 #[serde(default = "default_filename_time_format")]
102 pub filename_time_format: String,
103
104 #[serde(default = "crate::serde::default_true")]
113 #[configurable(metadata(docs::human_name = "Append UUID to Filename"))]
114 pub filename_append_uuid: bool,
115
116 #[configurable(metadata(docs::examples = "json"))]
120 pub filename_extension: Option<String>,
121
122 #[serde(flatten)]
123 pub options: S3Options,
124
125 #[serde(flatten)]
126 pub region: RegionOrEndpoint,
127
128 #[serde(flatten)]
129 pub encoding: EncodingConfigWithFraming,
130
131 #[cfg(feature = "codecs-parquet")]
137 #[configurable(derived)]
138 #[serde(default)]
139 pub batch_encoding: Option<S3BatchEncoding>,
140
141 #[configurable(derived)]
148 #[serde(default = "Compression::gzip_default")]
149 pub compression: Compression,
150
151 #[configurable(derived)]
152 #[serde(default)]
153 pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
154
155 #[configurable(derived)]
156 #[serde(default)]
157 pub request: TowerRequestConfig,
158
159 #[configurable(derived)]
160 pub tls: Option<TlsConfig>,
161
162 #[configurable(derived)]
163 #[serde(default)]
164 pub auth: AwsAuthentication,
165
166 #[configurable(derived)]
167 #[serde(
168 default,
169 deserialize_with = "crate::serde::bool_or_struct",
170 skip_serializing_if = "crate::serde::is_default"
171 )]
172 pub acknowledgements: AcknowledgementsConfig,
173
174 #[configurable(derived)]
175 #[serde(default)]
176 pub timezone: Option<TimeZone>,
177
178 #[serde(default = "crate::serde::default_true")]
182 pub force_path_style: bool,
183
184 #[configurable(derived)]
189 #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")]
190 pub retry_strategy: RetryStrategy,
191
192 #[serde(flatten)]
193 pub confinement: ConfinementConfig,
194}
195
196pub(super) fn default_key_prefix() -> String {
197 "date=%F".to_string()
198}
199
200pub(super) fn default_filename_time_format() -> String {
201 "%s".to_string()
202}
203
204impl GenerateConfig for S3SinkConfig {
205 fn generate_config() -> serde_json::Value {
206 serde_json::to_value(Self {
207 bucket: "".to_owned(),
208 key_prefix: default_key_prefix(),
209 filename_time_format: default_filename_time_format(),
210 filename_append_uuid: true,
211 filename_extension: None,
212 options: S3Options::default(),
213 region: RegionOrEndpoint::default(),
214 encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
215 #[cfg(feature = "codecs-parquet")]
216 batch_encoding: None,
217 compression: Compression::gzip_default(),
218 batch: BatchConfig::default(),
219 request: TowerRequestConfig::default(),
220 tls: Some(TlsConfig::default()),
221 auth: AwsAuthentication::default(),
222 acknowledgements: Default::default(),
223 timezone: Default::default(),
224 force_path_style: Default::default(),
225 retry_strategy: Default::default(),
226 confinement: ConfinementConfig::default(),
227 })
228 .unwrap()
229 }
230}
231
232#[async_trait::async_trait]
233#[typetag::serde(name = "aws_s3")]
234impl SinkConfig for S3SinkConfig {
235 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
236 Some(&self.confinement)
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 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
254 Some(self)
255 }
256}
257
258#[derive(Clone, Debug)]
259pub struct ValidatedAwsS3 {
260 batch_settings: BatcherSettings,
261 key_prefix: ConfinedTemplate,
262 ssekms_key_id: Option<ConfinedTemplate>,
263}
264
265#[async_trait::async_trait]
266impl ValidatedSink for S3SinkConfig {
267 type Validated = ValidatedAwsS3;
268
269 fn validate(&self) -> crate::Result<ValidatedAwsS3> {
270 let batch_settings = self.batch.into_batcher_settings()?;
271
272 let key_prefix = Template::try_from(self.key_prefix.clone())?.confine(
273 &self.confinement,
274 Self::NAME,
275 "key_prefix",
276 )?;
277
278 let ssekms_key_id = self
279 .options
280 .ssekms_key_id
281 .as_ref()
282 .cloned()
283 .map(|ssekms_key_id| Template::try_from(ssekms_key_id.as_str()))
284 .transpose()?
285 .map(|t| t.confine(&self.confinement, Self::NAME, "ssekms_key_id"))
286 .transpose()?;
287
288 Ok(ValidatedAwsS3 {
289 batch_settings,
290 key_prefix,
291 ssekms_key_id,
292 })
293 }
294
295 async fn build(
296 &self,
297 validated: &ValidatedAwsS3,
298 cx: SinkContext,
299 ) -> crate::Result<(VectorSink, Healthcheck)> {
300 let service = self.create_service(&cx.proxy).await?;
301 let healthcheck = self.build_healthcheck(service.client())?;
302 let sink = self.build_processor(service, cx, validated)?;
303 Ok((sink, healthcheck))
304 }
305}
306
307impl S3SinkConfig {
308 pub fn build_processor(
309 &self,
310 service: S3Service,
311 cx: SinkContext,
312 validated: &ValidatedAwsS3,
313 ) -> crate::Result<VectorSink> {
314 let request_limits = self.request.into_settings();
319 let retry_strategy = self.retry_strategy.clone();
320 let service = ServiceBuilder::new()
321 .settings(request_limits, retry_strategy)
322 .service(service);
323
324 let offset = self
325 .timezone
326 .or(cx.globals.timezone)
327 .and_then(timezone_to_offset);
328
329 let batch_settings = validated.batch_settings;
331
332 let key_prefix = validated.key_prefix.clone().with_tz_offset(offset);
333 let ssekms_key_id = validated
334 .ssekms_key_id
335 .clone()
336 .map(|t| t.with_tz_offset(offset));
337
338 let partitioner = S3KeyPartitioner::new(key_prefix, ssekms_key_id, None);
339
340 let transformer = self.encoding.transformer();
341
342 #[cfg(feature = "codecs-parquet")]
345 if let Some(batch_encoding) = &self.batch_encoding {
346 let S3BatchEncoding::Parquet(parquet_config) = batch_encoding;
347 let resolved_batch_config = BatchSerializerConfig::Parquet(parquet_config.clone());
348
349 let batch_serializer = resolved_batch_config.build_batch_serializer()?;
350 let batch_encoder = BatchEncoder::new(batch_serializer);
351
352 let mut api_options = self.options.clone();
355 if api_options.content_type.is_none() {
356 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
357 }
358
359 let encoder = EncoderKind::Batch(batch_encoder);
360
361 let filename_extension = self.filename_extension.clone().or_else(|| {
362 Some(
363 match batch_encoding {
364 S3BatchEncoding::Parquet(_) => "parquet",
365 }
366 .to_string(),
367 )
368 });
369
370 if self.compression != Compression::None {
371 warn!("Top level compression setting ignored when batch_encoding set to parquet.")
372 }
373
374 let request_options = S3RequestOptions {
375 bucket: self.bucket.clone(),
376 api_options,
377 filename_extension,
378 filename_time_format: self.filename_time_format.clone(),
379 filename_append_uuid: self.filename_append_uuid,
380 encoder: (transformer, encoder),
381 compression: Compression::None,
383 filename_tz_offset: offset,
384 };
385
386 let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
387 return Ok(VectorSink::from_event_streamsink(sink));
388 }
389
390 let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
391 let encoder = EncoderKind::Framed(Box::new(Encoder::<Framer>::new(framer, serializer)));
392
393 let request_options = S3RequestOptions {
394 bucket: self.bucket.clone(),
395 api_options: self.options.clone(),
396 filename_extension: self.filename_extension.clone(),
397 filename_time_format: self.filename_time_format.clone(),
398 filename_append_uuid: self.filename_append_uuid,
399 encoder: (transformer, encoder),
400 compression: self.compression,
401 filename_tz_offset: offset,
402 };
403
404 let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
405
406 Ok(VectorSink::from_event_streamsink(sink))
407 }
408
409 pub fn build_healthcheck(&self, client: S3Client) -> crate::Result<Healthcheck> {
410 s3_common::config::build_healthcheck(self.bucket.clone(), client)
411 }
412
413 pub async fn create_service(&self, proxy: &ProxyConfig) -> crate::Result<S3Service> {
414 s3_common::config::create_service(
415 &self.region,
416 &self.auth,
417 proxy,
418 self.tls.as_ref(),
419 self.force_path_style,
420 )
421 .await
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::S3SinkConfig;
428 use crate::config::ValidatedSink;
429 use crate::template::{ConfinementConfig, Template};
430
431 #[test]
432 fn prepares_valid_config() {
433 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
434 bucket: test-bucket
435 compression: none
436 encoding:
437 codec: text
438 "#})
439 .unwrap();
440
441 let validated = config.validate().expect("preparation should succeed");
442 assert_eq!(validated.key_prefix.to_string(), "date=%F");
443 assert!(validated.ssekms_key_id.is_none());
444 }
445
446 #[test]
447 fn generate_config() {
448 crate::test_util::test_generate_config::<S3SinkConfig>();
449 }
450
451 #[cfg(feature = "codecs-parquet")]
453 #[test]
454 fn parquet_batch_encoding_correct_toml_shape() {
455 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
456 bucket: test-bucket
457 compression: none
458 encoding:
459 codec: text
460 batch_encoding:
461 schema_mode: auto_infer
462 codec: parquet
463 compression:
464 algorithm: snappy
465 "#})
466 .expect("correct batch_encoding shape should parse");
467
468 let batch_enc = config
469 .batch_encoding
470 .expect("batch_encoding should be Some");
471 let super::S3BatchEncoding::Parquet(ref p) = batch_enc;
472 use vector_lib::codecs::encoding::format::{ParquetCompression, ParquetSchemaMode};
473 assert_eq!(p.schema_mode, ParquetSchemaMode::AutoInfer);
474 assert_eq!(p.compression, ParquetCompression::Snappy);
475 }
476
477 #[cfg(feature = "codecs-parquet")]
480 #[test]
481 fn parquet_content_type_auto_detected() {
482 use vector_lib::codecs::encoding::format::{
483 ParquetCompression, ParquetSchemaMode, ParquetSerializerConfig,
484 };
485
486 use crate::sinks::s3_common::config::S3Options;
487 use crate::sinks::util::{BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression};
488 use vector_lib::codecs::TextSerializerConfig;
489 use vector_lib::codecs::encoding::{BatchSerializerConfig, FramingConfig};
490
491 let parquet_config = ParquetSerializerConfig {
492 schema_mode: ParquetSchemaMode::AutoInfer,
493 compression: ParquetCompression::Snappy,
494 ..Default::default()
495 };
496
497 let config = S3SinkConfig {
498 bucket: "test".to_string(),
499 key_prefix: super::default_key_prefix(),
500 filename_time_format: super::default_filename_time_format(),
501 filename_append_uuid: true,
502 filename_extension: None,
503 options: S3Options::default(),
504 region: crate::aws::RegionOrEndpoint::with_both("us-east-1", "http://localhost:4566"),
505 encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
506 batch_encoding: Some(super::S3BatchEncoding::Parquet(parquet_config)),
507 compression: Compression::None,
508 batch: BatchConfig::<BulkSizeBasedDefaultBatchSettings>::default(),
509 request: Default::default(),
510 tls: Default::default(),
511 auth: Default::default(),
512 acknowledgements: Default::default(),
513 timezone: Default::default(),
514 force_path_style: true,
515 retry_strategy: Default::default(),
516 confinement: ConfinementConfig::default(),
517 };
518
519 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
520 let batch_config = BatchSerializerConfig::Parquet(p.clone());
521 let batch_serializer = batch_config.build_batch_serializer().unwrap();
522 let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
523
524 let mut api_options = config.options.clone();
525 if api_options.content_type.is_none() {
526 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
527 }
528
529 assert_eq!(
530 api_options.content_type.as_deref(),
531 Some("application/vnd.apache.parquet"),
532 "Content-Type must be auto-detected for Parquet"
533 );
534 }
535
536 #[cfg(feature = "codecs-parquet")]
538 #[test]
539 fn parquet_content_type_user_override_preserved() {
540 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
541 bucket: test-bucket
542 compression: none
543 content_type: "application/octet-stream"
544 encoding:
545 codec: text
546 batch_encoding:
547 codec: parquet
548 schema_mode: auto_infer
549 compression:
550 algorithm: gzip
551 level: 9
552 "#})
553 .unwrap();
554
555 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
556 let batch_config = vector_lib::codecs::encoding::BatchSerializerConfig::Parquet(p.clone());
557 let batch_serializer = batch_config.build_batch_serializer().unwrap();
558 let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
559
560 let mut api_options = config.options.clone();
561 if api_options.content_type.is_none() {
562 api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
563 }
564
565 assert_eq!(
566 api_options.content_type.as_deref(),
567 Some("application/octet-stream"),
568 "User-specified Content-Type must not be overridden"
569 );
570 }
571
572 #[cfg(feature = "codecs-parquet")]
575 #[test]
576 fn parquet_batch_encoding_rejects_unsupported_codec() {
577 let err = serde_yaml::from_str::<S3SinkConfig>(
578 r#"
579 bucket: test-bucket
580 compression: none
581 encoding:
582 codec: text
583 batch_encoding:
584 codec: arrow_stream
585 "#,
586 )
587 .unwrap_err();
588
589 assert!(
590 err.to_string().contains("arrow_stream"),
591 "expected error to mention the offending codec, got: {err}"
592 );
593 }
594
595 #[cfg(feature = "codecs-parquet")]
597 #[test]
598 fn parquet_filename_extension_user_override() {
599 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
600 bucket: test-bucket
601 compression: none
602 filename_extension: pq
603 encoding:
604 codec: text
605 batch_encoding:
606 codec: parquet
607 schema_mode: auto_infer
608 "#})
609 .unwrap();
610
611 assert_eq!(config.filename_extension.as_deref(), Some("pq"));
612 }
613
614 #[cfg(feature = "codecs-parquet")]
616 #[test]
617 fn parquet_schema_mode_defaults_to_relaxed() {
618 use vector_lib::codecs::encoding::format::ParquetSchemaMode;
619
620 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
621 bucket: test-bucket
622 compression: none
623 encoding:
624 codec: text
625 batch_encoding:
626 codec: parquet
627 "#})
628 .unwrap();
629
630 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
631 assert_eq!(p.schema_mode, ParquetSchemaMode::Relaxed);
632 }
633
634 #[cfg(feature = "codecs-parquet")]
636 #[test]
637 fn parquet_schema_mode_strict_parsed() {
638 use vector_lib::codecs::encoding::format::ParquetSchemaMode;
639
640 let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
641 bucket: test-bucket
642 compression: none
643 encoding:
644 codec: text
645 batch_encoding:
646 codec: parquet
647 schema_mode: strict
648 schema_file: tmp/something.schema
649 "#})
650 .unwrap();
651
652 let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
653 assert_eq!(p.schema_mode, ParquetSchemaMode::Strict);
654 }
655
656 #[test]
657 fn confinement_rejects_unconfined_key_prefix() {
658 let template: Template = "{{ tenant }}".try_into().unwrap();
659 let err = template
660 .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
661 .unwrap_err();
662 assert!(
663 err.to_string().contains("no literal string prefix"),
664 "unexpected error: {err}"
665 );
666 }
667
668 #[test]
669 fn confinement_opt_out_allows_unconfined_key_prefix() {
670 let cfg = ConfinementConfig {
671 dangerously_allow_unconfined_template_resolution: true,
672 };
673 let template: Template = "{{ tenant }}".try_into().unwrap();
674 assert!(template.confine(&cfg, "aws_s3", "key_prefix").is_ok());
675 }
676
677 #[test]
678 fn confinement_blocks_dotdot_escape_at_render() {
679 use crate::event::Event;
680 use vector_lib::event::LogEvent;
681 use vrl::event_path;
682
683 let template: Template = "safe/{{ tenant }}/".try_into().unwrap();
684 let template = template
685 .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
686 .unwrap();
687 let mut event = Event::Log(LogEvent::from("x"));
688 event
689 .as_mut_log()
690 .insert(event_path!("tenant"), "../../escape");
691 assert!(template.render_string(&event).is_err());
692 }
693}