Skip to main content

vector/sources/aws_kinesis_firehose/
handlers.rs

1use std::collections::HashMap;
2
3use base64::prelude::{BASE64_STANDARD, Engine as _};
4use bytes::Bytes;
5use chrono::Utc;
6use futures::StreamExt;
7use snafu::{ResultExt, Snafu};
8use vector_common::constants::GZIP_MAGIC;
9use vector_lib::{
10    EstimatedJsonEncodedSizeOf,
11    codecs::{DecoderFramedRead, StreamDecodingError},
12    config::{LegacyKey, LogNamespace},
13    event::BatchNotifier,
14    finalization::AddBatchNotifier,
15    internal_event::{
16        ByteSize, BytesReceived, CountByteSize, InternalEventHandle as _, Registered,
17    },
18    lookup::{PathPrefix, metadata_path, path},
19    source_sender::SendError,
20};
21use vrl::{
22    compiler::SecretTarget,
23    value::{KeyString, ObjectMap, Value},
24};
25use warp::reject;
26
27use super::{
28    Compression,
29    errors::{ParseRecordsSnafu, RequestError},
30    models::{EncodedFirehoseRecord, FirehoseRequest, FirehoseResponse},
31};
32use crate::{
33    SourceSender,
34    codecs::Decoder,
35    config::log_schema,
36    event::{BatchStatus, Event},
37    internal_events::{
38        AwsKinesisFirehoseAutomaticRecordDecodeError, EventsReceived, StreamClosedError,
39    },
40    sources::{
41        aws_kinesis_firehose::AwsKinesisFirehoseConfig,
42        http_server::HttpConfigParamKind,
43        util::decompression::{CappedDecoder, is_decompressed_size_limit_error},
44    },
45};
46
47#[derive(Clone)]
48pub(super) struct Context {
49    pub(super) compression: Compression,
50    pub(super) store_access_key: bool,
51    pub(super) decoder: Decoder,
52    pub(super) acknowledgements: bool,
53    pub(super) bytes_received: Registered<BytesReceived>,
54    pub(super) out: SourceSender,
55    pub(super) log_namespace: LogNamespace,
56    pub(super) common_attributes: Vec<HttpConfigParamKind>,
57}
58
59/// Publishes decoded events from the FirehoseRequest to the pipeline
60pub(super) async fn firehose(
61    request_id: String,
62    source_arn: String,
63    common_attributes: HashMap<String, String>,
64    request: FirehoseRequest,
65    mut context: Context,
66) -> Result<impl warp::Reply, reject::Rejection> {
67    let log_namespace = context.log_namespace;
68    let events_received = register!(EventsReceived);
69    let common_attributes_map =
70        build_common_attributes_map(&context.common_attributes, &common_attributes);
71
72    for record in request.records {
73        let bytes = decode_record(&record, context.compression)
74            .with_context(|_| ParseRecordsSnafu {
75                request_id: request_id.clone(),
76            })
77            .map_err(reject::custom)?;
78        context.bytes_received.emit(ByteSize(bytes.len()));
79
80        let mut stream = DecoderFramedRead::new(bytes.as_ref(), context.decoder.clone());
81        loop {
82            match stream.next().await {
83                Some(Ok((mut events, _byte_size))) => {
84                    events_received.emit(CountByteSize(
85                        events.len(),
86                        events.estimated_json_encoded_size_of(),
87                    ));
88
89                    let (batch, receiver) = if context.acknowledgements {
90                        {
91                            let (batch, receiver) = BatchNotifier::new_with_receiver();
92                            (Some(batch), Some(receiver))
93                        }
94                    } else {
95                        (None, None)
96                    };
97
98                    let now = Utc::now();
99                    for event in &mut events {
100                        if let Some(batch) = &batch {
101                            event.add_batch_notifier(batch.clone());
102                        }
103                        if let Event::Log(log) = event {
104                            log_namespace.insert_vector_metadata(
105                                log,
106                                log_schema().source_type_key(),
107                                path!("source_type"),
108                                Bytes::from_static(AwsKinesisFirehoseConfig::NAME.as_bytes()),
109                            );
110                            // This handles the transition from the original timestamp logic. Originally the
111                            // `timestamp_key` was always populated by the `request.timestamp` time.
112                            match log_namespace {
113                                LogNamespace::Vector => {
114                                    log.insert(metadata_path!("vector", "ingest_timestamp"), now);
115                                    log.insert(
116                                        metadata_path!(AwsKinesisFirehoseConfig::NAME, "timestamp"),
117                                        request.timestamp,
118                                    );
119                                }
120                                LogNamespace::Legacy => {
121                                    if let Some(timestamp_key) = log_schema().timestamp_key() {
122                                        log.try_insert(
123                                            (PathPrefix::Event, timestamp_key),
124                                            request.timestamp,
125                                        );
126                                    }
127                                }
128                            };
129
130                            log_namespace.insert_source_metadata(
131                                AwsKinesisFirehoseConfig::NAME,
132                                log,
133                                Some(LegacyKey::InsertIfEmpty(path!("request_id"))),
134                                path!("request_id"),
135                                request_id.to_owned(),
136                            );
137                            log_namespace.insert_source_metadata(
138                                AwsKinesisFirehoseConfig::NAME,
139                                log,
140                                Some(LegacyKey::InsertIfEmpty(path!("source_arn"))),
141                                path!("source_arn"),
142                                source_arn.to_owned(),
143                            );
144
145                            if !common_attributes_map.is_empty() {
146                                log_namespace.insert_source_metadata(
147                                    AwsKinesisFirehoseConfig::NAME,
148                                    log,
149                                    Some(LegacyKey::InsertIfEmpty(path!("common_attributes"))),
150                                    path!("common_attributes"),
151                                    common_attributes_map.clone(),
152                                );
153                            }
154
155                            if context.store_access_key
156                                && let Some(access_key) = &request.access_key
157                            {
158                                log.metadata_mut()
159                                    .secrets_mut()
160                                    .insert_secret("aws_kinesis_firehose_access_key", access_key);
161                            }
162                        }
163                    }
164
165                    let count = events.len();
166                    match context.out.send_batch(events).await {
167                        Ok(()) => (),
168                        Err(SendError::Closed) => {
169                            emit!(StreamClosedError { count });
170                            let error = RequestError::ShuttingDown {
171                                request_id: request_id.clone(),
172                            };
173                            warp::reject::custom(error);
174                        }
175                        Err(SendError::Timeout) => unreachable!("No timeout is configured here"),
176                    }
177
178                    drop(batch);
179                    if let Some(receiver) = receiver {
180                        match receiver.await {
181                            BatchStatus::Delivered => Ok(()),
182                            BatchStatus::Rejected => {
183                                Err(warp::reject::custom(RequestError::DeliveryFailed {
184                                    request_id: request_id.clone(),
185                                }))
186                            }
187                            BatchStatus::Errored => {
188                                Err(warp::reject::custom(RequestError::DeliveryErrored {
189                                    request_id: request_id.clone(),
190                                }))
191                            }
192                        }?;
193                    }
194                }
195                Some(Err(error)) => {
196                    // Error is logged by `vector_lib::codecs::Decoder`, no further
197                    // handling is needed here.
198                    if !error.can_continue() {
199                        break;
200                    }
201                }
202                None => break,
203            }
204        }
205    }
206
207    Ok(warp::reply::json(&FirehoseResponse {
208        request_id: request_id.clone(),
209        timestamp: Utc::now(),
210        error_message: None,
211    }))
212}
213
214#[derive(Debug, Snafu)]
215pub enum RecordDecodeError {
216    #[snafu(display("Could not base64 decode request data: {}", source))]
217    Base64 { source: base64::DecodeError },
218    #[snafu(display("Could not decompress request data as {}: {}", compression, source))]
219    Decompression {
220        source: std::io::Error,
221        compression: Compression,
222    },
223}
224
225/// Decodes a Firehose record.
226fn decode_record(
227    record: &EncodedFirehoseRecord,
228    compression: Compression,
229) -> Result<Bytes, RecordDecodeError> {
230    let buf = BASE64_STANDARD
231        .decode(record.data.as_bytes())
232        .context(Base64Snafu {})?;
233
234    if buf.is_empty() {
235        return Ok(Bytes::default());
236    }
237
238    match compression {
239        Compression::None => Ok(Bytes::from(buf)),
240        Compression::Gzip => decode_gzip(&buf[..]).with_context(|_| DecompressionSnafu {
241            compression: compression.to_owned(),
242        }),
243        Compression::Auto => {
244            if is_gzip(&buf) {
245                decode_gzip(&buf[..]).or_else(|error| {
246                    // An exceeded size cap means the magic bytes really were gzip and the payload
247                    // is oversized, so reject it. Only fall back to forwarding the raw bytes when
248                    // auto-detection guessed wrong (valid-looking magic, but not actually gzip).
249                    if is_decompressed_size_limit_error(&error) {
250                        return Err(error).with_context(|_| DecompressionSnafu {
251                            compression: Compression::Gzip,
252                        });
253                    }
254                    emit!(AwsKinesisFirehoseAutomaticRecordDecodeError {
255                        compression: Compression::Gzip,
256                        error
257                    });
258                    Ok(Bytes::from(buf))
259                })
260            } else {
261                // only support gzip for now
262                Ok(Bytes::from(buf))
263            }
264        }
265    }
266}
267
268fn is_gzip(data: &[u8]) -> bool {
269    // The header length of a GZIP file is 10 bytes. The first two bytes of the constant comes from
270    // the GZIP file format specification, which is the fixed member header identification bytes.
271    // The third byte is the compression method, of which only one is defined which is 8 for the
272    // deflate algorithm.
273    //
274    // Reference: https://datatracker.ietf.org/doc/html/rfc1952 Section 2.3
275    data.starts_with(GZIP_MAGIC)
276}
277
278fn decode_gzip(data: &[u8]) -> std::io::Result<Bytes> {
279    // Cap the decompressed output so a gzip-bomb record cannot drive unbounded allocation.
280    CappedDecoder::gzip(data).decompress().map(Bytes::from)
281}
282
283fn build_common_attributes_map(
284    common_attributes_config: &[HttpConfigParamKind],
285    common_attributes: &HashMap<String, String>,
286) -> ObjectMap {
287    let mut common_attributes_map = ObjectMap::new();
288
289    for common_attribute_config in common_attributes_config {
290        match common_attribute_config {
291            HttpConfigParamKind::Exact(common_attribute_name) => {
292                let value = common_attributes
293                    .get(common_attribute_name)
294                    .map(String::as_bytes);
295                common_attributes_map.insert(
296                    KeyString::from(common_attribute_name.to_owned()),
297                    Value::from(value.map(Bytes::copy_from_slice)),
298                );
299            }
300            HttpConfigParamKind::Glob(common_attribute_pattern) => {
301                for common_attribute_name in common_attributes.keys() {
302                    if common_attribute_pattern.matches(common_attribute_name) {
303                        let value = common_attributes
304                            .get(common_attribute_name)
305                            .map(String::as_bytes);
306                        common_attributes_map.insert(
307                            KeyString::from(common_attribute_name.to_owned()),
308                            Value::from(value.map(Bytes::copy_from_slice)),
309                        );
310                    }
311                }
312            }
313        }
314    }
315
316    common_attributes_map
317}
318
319#[cfg(test)]
320mod tests {
321    use std::io::Write as _;
322
323    use flate2::{Compression, write::GzEncoder};
324
325    use super::*;
326
327    const CONTENT: &[u8] = b"Example";
328
329    #[test]
330    fn correctly_detects_gzipped_content() {
331        assert!(!is_gzip(CONTENT));
332        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
333        encoder.write_all(CONTENT).unwrap();
334        let compressed = encoder.finish().unwrap();
335        assert!(is_gzip(&compressed));
336    }
337}