Skip to main content

vector/sinks/aws_s3/
config.rs

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/// Batch encoding configuration for the `aws_s3` sink.
45#[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    /// Encodes events in [Apache Parquet][apache_parquet] columnar format.
54    ///
55    /// [apache_parquet]: https://parquet.apache.org/
56    Parquet(ParquetSerializerConfig),
57}
58
59/// Configuration for the `aws_s3` sink.
60#[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    /// The S3 bucket name.
68    ///
69    /// This must not include a leading `s3://` or a trailing `/`.
70    #[configurable(metadata(docs::examples = "my-bucket"))]
71    pub bucket: String,
72
73    /// A prefix to apply to all object keys.
74    ///
75    /// Prefixes are useful for partitioning objects, such as by creating an object key that
76    /// stores objects under a particular directory. If using a prefix for this purpose, it must end
77    /// in `/` to act as a directory path. A trailing `/` is **not** automatically added.
78    #[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    /// The timestamp format for the time component of the object key.
86    ///
87    /// By default, object keys are appended with a timestamp that reflects when the objects are
88    /// sent to S3, such that the resulting object key is functionally equivalent to joining the key
89    /// prefix with the formatted timestamp, such as `date=2022-07-18/1658176486`.
90    ///
91    /// This would represent a `key_prefix` set to `date=%F/` and the timestamp of Mon Jul 18 2022
92    /// 20:34:44 GMT+0000, with the `filename_time_format` being set to `%s`, which renders
93    /// timestamps in seconds since the Unix epoch.
94    ///
95    /// Supports the common [`strftime`][chrono_strftime_specifiers] specifiers found in most
96    /// languages.
97    ///
98    /// When set to an empty string, no timestamp is appended to the key prefix.
99    ///
100    /// [chrono_strftime_specifiers]: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
101    #[serde(default = "default_filename_time_format")]
102    pub filename_time_format: String,
103
104    /// Whether or not to append a UUID v4 token to the end of the object key.
105    ///
106    /// The UUID is appended to the timestamp portion of the object key, such that if the object key
107    /// generated is `date=2022-07-18/1658176486`, setting this field to `true` results
108    /// in an object key that looks like `date=2022-07-18/1658176486-30f6652c-71da-4f9f-800d-a1189c47c547`.
109    ///
110    /// This ensures there are no name collisions, and can be useful in high-volume workloads where
111    /// object keys must be unique.
112    #[serde(default = "crate::serde::default_true")]
113    #[configurable(metadata(docs::human_name = "Append UUID to Filename"))]
114    pub filename_append_uuid: bool,
115
116    /// The filename extension to use in the object key.
117    ///
118    /// This overrides setting the extension based on the configured `compression`.
119    #[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    /// Batch encoding configuration for columnar formats.
132    ///
133    /// When set, events are encoded together as a batch in a columnar format (Parquet)
134    /// instead of the standard per-event framing-based encoding. The columnar format handles
135    /// its own internal compression, so the top-level `compression` setting is bypassed.
136    #[cfg(feature = "codecs-parquet")]
137    #[configurable(derived)]
138    #[serde(default)]
139    pub batch_encoding: Option<S3BatchEncoding>,
140
141    /// Compression configuration.
142    ///
143    /// All compression algorithms use the default compression level unless otherwise specified.
144    ///
145    /// Some cloud storage API clients and browsers handle decompression transparently, so
146    /// depending on how they are accessed, files may not always appear to be compressed.
147    #[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    /// Specifies which addressing style to use.
179    ///
180    /// This controls if the bucket name is in the hostname or part of the URL.
181    #[serde(default = "crate::serde::default_true")]
182    pub force_path_style: bool,
183
184    /// Specifies retry strategy for failed requests.
185    ///
186    /// By default, the sink only retries attempts it deems possible to retry.
187    /// These settings extend the default behavior.
188    #[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        // Build our S3 client/service, which is what we'll ultimately feed
315        // requests into in order to ship files to S3.  We build this here in
316        // order to configure the client/service with retries, concurrency
317        // limits, rate limits, and whatever else the client should have.
318        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        // Configure our partitioning/batching.
330        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        // When batch_encoding is configured (e.g., Parquet), use batch mode
343        // with internal compression and appropriate file extension.
344        #[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            // Auto-detect Content-Type from batch format. Users can still
353            // override via `options.content_type`; we only set it when unset.
354            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                // Batch formats handle their own compression internally
382                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    /// Correct TOML shape: `batch_encoding.codec = "parquet"` with `schema_mode = "auto_infer"`.
452    #[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    /// Content-Type must be auto-detected as `application/vnd.apache.parquet`
478    /// when `batch_encoding` is set and `content_type` is not explicitly provided.
479    #[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    /// When user explicitly sets `content_type`, the auto-detection must not override it.
537    #[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    /// Codecs other than `parquet` must be rejected at parse time, since
573    /// `S3BatchEncoding` only exposes the `parquet` variant.
574    #[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    /// Explicit filename_extension overrides the `.parquet` default.
596    #[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    /// `schema_mode` defaults to `relaxed` when not specified.
615    #[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    /// Explicit `schema_mode = "strict"` is correctly parsed.
635    #[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}