1- use std:: collections:: HashMap ;
1+ use std:: collections:: { BTreeMap , HashMap } ;
22
33use aws_sdk_cloudwatchlogs:: Client as CloudwatchLogsClient ;
44use futures:: FutureExt ;
5+ use http:: HeaderValue ;
56use serde:: { Deserialize , Deserializer , de} ;
67use tower:: ServiceBuilder ;
7- use vector_lib:: { codecs:: JsonSerializerConfig , configurable:: configurable_component, schema} ;
8+ use vector_lib:: {
9+ codecs:: JsonSerializerConfig , configurable:: configurable_component, schema,
10+ stream:: BatcherSettings ,
11+ } ;
812use vrl:: value:: Kind ;
913
1014use crate :: {
1115 aws:: { AwsAuthentication , ClientBuilder , RegionOrEndpoint , create_client} ,
1216 codecs:: { Encoder , EncodingConfig } ,
1317 config:: {
14- AcknowledgementsConfig , DataType , GenerateConfig , Input , ProxyConfig , SinkConfig ,
15- SinkContext ,
18+ AcknowledgementsConfig , DataType , DynValidatedSink , GenerateConfig , Input , ProxyConfig ,
19+ SinkConfig , SinkContext , ValidatedSink ,
1620 } ,
1721 sinks:: {
1822 Healthcheck , VectorSink ,
@@ -21,10 +25,11 @@ use crate::{
2125 retry:: CloudwatchRetryLogic , service:: CloudwatchLogsPartitionSvc , sink:: CloudwatchSink ,
2226 } ,
2327 util:: {
24- BatchConfig , Compression , ServiceBuilderExt , SinkBatchSettings , http:: RequestConfig ,
28+ BatchConfig , Compression , ServiceBuilderExt , SinkBatchSettings ,
29+ http:: { OrderedHeaderName , RequestConfig , validate_headers} ,
2530 } ,
2631 } ,
27- template:: { ConfinementConfig , Template } ,
32+ template:: { ConfinedTemplate , ConfinementConfig , Template } ,
2833 tls:: TlsConfig ,
2934} ;
3035
@@ -88,7 +93,7 @@ pub struct CloudwatchLogsSinkConfig {
8893 ///
8994 /// [group_name]: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Working-with-log-groups-and-streams.html
9095 #[ configurable( metadata( docs:: examples = "group-name" ) ) ]
91- #[ configurable( metadata( docs:: examples = "{{ file }}" ) ) ]
96+ #[ configurable( metadata( docs:: examples = "group- {{ file }}" ) ) ]
9297 pub group_name : Template ,
9398
9499 /// The [stream name][stream_name] of the target CloudWatch Logs stream.
@@ -98,7 +103,7 @@ pub struct CloudwatchLogsSinkConfig {
98103 /// unique per instance.
99104 ///
100105 /// [stream_name]: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Working-with-log-groups-and-streams.html
101- #[ configurable( metadata( docs:: examples = "{{ host }}" ) ) ]
106+ #[ configurable( metadata( docs:: examples = "stream- {{ host }}" ) ) ]
102107 #[ configurable( metadata( docs:: examples = "%Y-%m-%d" ) ) ]
103108 #[ configurable( metadata( docs:: examples = "stream-name" ) ) ]
104109 pub stream_name : Template ,
@@ -206,7 +211,40 @@ impl CloudwatchLogsSinkConfig {
206211#[ async_trait:: async_trait]
207212#[ typetag:: serde( name = "aws_cloudwatch_logs" ) ]
208213impl SinkConfig for CloudwatchLogsSinkConfig {
209- async fn build ( & self , cx : SinkContext ) -> crate :: Result < ( VectorSink , Healthcheck ) > {
214+ fn confinement_config ( & self ) -> Option < & crate :: template:: ConfinementConfig > {
215+ Some ( & self . confinement )
216+ }
217+
218+ fn input ( & self ) -> Input {
219+ let requirement =
220+ schema:: Requirement :: empty ( ) . optional_meaning ( "timestamp" , Kind :: timestamp ( ) ) ;
221+
222+ Input :: new ( self . encoding . config ( ) . input_type ( ) & DataType :: Log )
223+ . with_schema_requirement ( requirement)
224+ }
225+
226+ fn acknowledgements ( & self ) -> & AcknowledgementsConfig {
227+ & self . acknowledgements
228+ }
229+
230+ fn as_dyn_validated ( & self ) -> Option < & dyn DynValidatedSink > {
231+ Some ( self )
232+ }
233+ }
234+
235+ #[ derive( Clone , Debug ) ]
236+ pub struct ValidatedCloudwatchLogs {
237+ group_template : ConfinedTemplate ,
238+ stream_template : ConfinedTemplate ,
239+ batcher_settings : BatcherSettings ,
240+ headers : BTreeMap < OrderedHeaderName , HeaderValue > ,
241+ }
242+
243+ #[ async_trait:: async_trait]
244+ impl ValidatedSink for CloudwatchLogsSinkConfig {
245+ type Validated = ValidatedCloudwatchLogs ;
246+
247+ fn validate ( & self ) -> crate :: Result < ValidatedCloudwatchLogs > {
210248 let group_template =
211249 self . group_name
212250 . clone ( )
@@ -215,16 +253,37 @@ impl SinkConfig for CloudwatchLogsSinkConfig {
215253 self . stream_name
216254 . clone ( )
217255 . confine ( & self . confinement , Self :: NAME , "stream_name" ) ?;
218-
219256 let batcher_settings = self . batch . into_batcher_settings ( ) ?;
257+ let headers = validate_headers ( & self . request . headers ) ?;
258+
259+ Ok ( ValidatedCloudwatchLogs {
260+ group_template,
261+ stream_template,
262+ batcher_settings,
263+ headers,
264+ } )
265+ }
266+
267+ async fn build (
268+ & self ,
269+ validated : & ValidatedCloudwatchLogs ,
270+ cx : SinkContext ,
271+ ) -> crate :: Result < ( VectorSink , Healthcheck ) > {
272+ let ValidatedCloudwatchLogs {
273+ group_template,
274+ stream_template,
275+ batcher_settings,
276+ headers,
277+ } = validated. clone ( ) ;
220278 let request_settings = self . request . tower . into_settings ( ) ;
221279 let client = self . create_client ( cx. proxy ( ) ) . await ?;
222280 let svc = ServiceBuilder :: new ( )
223281 . settings ( request_settings, CloudwatchRetryLogic :: new ( ) )
224282 . service ( CloudwatchLogsPartitionSvc :: new (
225283 self . clone ( ) ,
226284 client. clone ( ) ,
227- ) ?) ;
285+ headers. clone ( ) ,
286+ ) ) ;
228287 let transformer = self . encoding . transformer ( ) ;
229288 let serializer = self . encoding . build ( ) ?;
230289 let encoder = Encoder :: < ( ) > :: new ( serializer) ;
@@ -242,22 +301,6 @@ impl SinkConfig for CloudwatchLogsSinkConfig {
242301 } ;
243302 Ok ( ( VectorSink :: from_event_streamsink ( sink) , healthcheck) )
244303 }
245-
246- fn confinement_config ( & self ) -> Option < & crate :: template:: ConfinementConfig > {
247- Some ( & self . confinement )
248- }
249-
250- fn input ( & self ) -> Input {
251- let requirement =
252- schema:: Requirement :: empty ( ) . optional_meaning ( "timestamp" , Kind :: timestamp ( ) ) ;
253-
254- Input :: new ( self . encoding . config ( ) . input_type ( ) & DataType :: Log )
255- . with_schema_requirement ( requirement)
256- }
257-
258- fn acknowledgements ( & self ) -> & AcknowledgementsConfig {
259- & self . acknowledgements
260- }
261304}
262305
263306impl GenerateConfig for CloudwatchLogsSinkConfig {
@@ -299,14 +342,71 @@ impl SinkBatchSettings for CloudwatchLogsDefaultBatchSettings {
299342
300343#[ cfg( test) ]
301344mod tests {
345+ use crate :: config:: ValidatedSink ;
302346 use crate :: sinks:: aws_cloudwatch_logs:: config:: CloudwatchLogsSinkConfig ;
303347 use crate :: template:: { ConfinementConfig , Template } ;
348+ use vector_lib:: codecs:: JsonSerializerConfig ;
349+
350+ #[ test]
351+ fn prepares_valid_config ( ) {
352+ let mut config = super :: default_config ( JsonSerializerConfig :: default ( ) . into ( ) ) ;
353+ config. group_name = "group-{{ file }}" . try_into ( ) . unwrap ( ) ;
354+ config. stream_name = "stream" . try_into ( ) . unwrap ( ) ;
355+
356+ let validated = config. validate ( ) . expect ( "preparation should succeed" ) ;
357+ assert_eq ! ( validated. group_template. to_string( ) , "group-{{ file }}" ) ;
358+ assert_eq ! ( validated. stream_template. to_string( ) , "stream" ) ;
359+ assert_eq ! ( validated. batcher_settings. item_limit, 10_000 ) ;
360+ }
304361
305362 #[ test]
306363 fn test_generate_config ( ) {
307364 crate :: test_util:: test_generate_config :: < CloudwatchLogsSinkConfig > ( ) ;
308365 }
309366
367+ #[ test]
368+ fn validate_rejects_invalid_header_name ( ) {
369+ let mut config = super :: default_config ( JsonSerializerConfig :: default ( ) . into ( ) ) ;
370+ config. group_name = "group" . try_into ( ) . unwrap ( ) ;
371+ config. stream_name = "stream" . try_into ( ) . unwrap ( ) ;
372+ config
373+ . request
374+ . headers
375+ . insert ( "invalid header name" . to_string ( ) , "value" . to_string ( ) ) ;
376+
377+ assert ! ( config. validate( ) . is_err( ) ) ;
378+ }
379+
380+ #[ test]
381+ fn validate_rejects_invalid_header_value ( ) {
382+ let mut config = super :: default_config ( JsonSerializerConfig :: default ( ) . into ( ) ) ;
383+ config. group_name = "group" . try_into ( ) . unwrap ( ) ;
384+ config. stream_name = "stream" . try_into ( ) . unwrap ( ) ;
385+ config. request . headers . insert (
386+ "valid-header" . to_string ( ) ,
387+ "value\n with newline" . to_string ( ) ,
388+ ) ;
389+
390+ assert ! ( config. validate( ) . is_err( ) ) ;
391+ }
392+
393+ #[ test]
394+ fn validate_retains_valid_headers ( ) {
395+ let mut config = super :: default_config ( JsonSerializerConfig :: default ( ) . into ( ) ) ;
396+ config. group_name = "group" . try_into ( ) . unwrap ( ) ;
397+ config. stream_name = "stream" . try_into ( ) . unwrap ( ) ;
398+ config
399+ . request
400+ . headers
401+ . insert ( "x-custom-header" . to_string ( ) , "custom-value" . to_string ( ) ) ;
402+
403+ let validated = config. validate ( ) . expect ( "preparation should succeed" ) ;
404+ assert_eq ! ( validated. headers. len( ) , 1 ) ;
405+ let ( name, value) = validated. headers . iter ( ) . next ( ) . unwrap ( ) ;
406+ assert_eq ! ( name. inner( ) , "x-custom-header" ) ;
407+ assert_eq ! ( value, "custom-value" ) ;
408+ }
409+
310410 #[ test]
311411 fn confinement_rejects_unconfined_group_name ( ) {
312412 let template = Template :: try_from ( "{{ group }}" ) . unwrap ( ) ;
0 commit comments