Skip to main content

vector/sinks/amqp/
config.rs

1//! Configuration functionality for the `AMQP` sink.
2use lapin::{BasicProperties, types::ShortString, uri::AMQPUri};
3use vector_lib::{
4    codecs::TextSerializerConfig,
5    internal_event::{error_stage, error_type},
6};
7
8use super::{channel::AmqpSinkChannels, service::AmqpError, sink::AmqpSink};
9use crate::{
10    amqp::AmqpConfig,
11    config::{DynValidatedSink, ValidatedSink},
12    sinks::prelude::*,
13    template::ConfinementConfig,
14};
15
16/// AMQP properties configuration.
17#[configurable_component]
18#[configurable(title = "Configure the AMQP message properties.")]
19#[derive(Clone, Debug, Default)]
20pub struct AmqpPropertiesConfig {
21    /// Content-Type for the AMQP messages.
22    pub(crate) content_type: Option<String>,
23
24    /// Content-Encoding for the AMQP messages.
25    pub(crate) content_encoding: Option<String>,
26
27    /// Expiration for AMQP messages (in milliseconds).
28    pub(crate) expiration_ms: Option<u64>,
29
30    /// Priority for AMQP messages. It can be templated to an integer between 0 and 255 inclusive.
31    pub(crate) priority: Option<UnsignedIntTemplate>,
32}
33
34impl AmqpPropertiesConfig {
35    pub(super) fn build(&self, event: &Event) -> Option<BasicProperties> {
36        let mut prop = BasicProperties::default();
37        if let Some(content_type) = &self.content_type {
38            prop = prop.with_content_type(ShortString::from(content_type.clone()));
39        }
40        if let Some(content_encoding) = &self.content_encoding {
41            prop = prop.with_content_encoding(ShortString::from(content_encoding.clone()));
42        }
43        if let Some(expiration_ms) = &self.expiration_ms {
44            prop = prop.with_expiration(ShortString::from(expiration_ms.to_string()));
45        }
46        if let Some(priority_template) = &self.priority {
47            let priority = priority_template.render(event).unwrap_or_else(|error| {
48                warn!(
49                    message = "Failed to render numeric template for \"properties.priority\".",
50                    error = %error,
51                    error_type = error_type::TEMPLATE_FAILED,
52                    stage = error_stage::PROCESSING,
53                    internal_log_rate_limit = false,
54                );
55                Default::default()
56            });
57
58            // Clamp the value to the range of 0-255, as AMQP priority is a u8.
59            let priority = priority.clamp(0, u8::MAX.into()) as u8;
60            prop = prop.with_priority(priority);
61        }
62        Some(prop)
63    }
64}
65
66/// Configuration for the `amqp` sink.
67///
68/// Supports AMQP version 0.9.1
69#[configurable_component(sink(
70    "amqp",
71    "Send events to AMQP 0.9.1 compatible brokers like RabbitMQ."
72))]
73#[derive(Clone, Debug)]
74pub struct AmqpSinkConfig {
75    /// The exchange to publish messages to.
76    pub(crate) exchange: Template,
77
78    /// Template used to generate a routing key which corresponds to a queue binding.
79    pub(crate) routing_key: Option<Template>,
80
81    /// AMQP message properties.
82    pub(crate) properties: Option<AmqpPropertiesConfig>,
83
84    #[serde(flatten)]
85    pub(crate) connection: AmqpConfig,
86
87    #[configurable(derived)]
88    pub(crate) encoding: EncodingConfig,
89
90    #[configurable(derived)]
91    #[serde(
92        default,
93        deserialize_with = "crate::serde::bool_or_struct",
94        skip_serializing_if = "crate::serde::is_default"
95    )]
96    pub(crate) acknowledgements: AcknowledgementsConfig,
97
98    /// Maximum number of AMQP channels to keep active (channels are created as needed).
99    #[serde(default = "default_max_channels")]
100    pub(crate) max_channels: u32,
101
102    #[configurable(derived)]
103    #[serde(flatten)]
104    pub confinement: ConfinementConfig,
105}
106
107const fn default_max_channels() -> u32 {
108    4
109}
110
111impl Default for AmqpSinkConfig {
112    fn default() -> Self {
113        Self {
114            exchange: Template::try_from("vector").unwrap(),
115            routing_key: None,
116            properties: None,
117            encoding: TextSerializerConfig::default().into(),
118            connection: AmqpConfig::default(),
119            acknowledgements: AcknowledgementsConfig::default(),
120            max_channels: default_max_channels(),
121            confinement: ConfinementConfig::default(),
122        }
123    }
124}
125
126impl GenerateConfig for AmqpSinkConfig {
127    fn generate_config() -> serde_json::Value {
128        serde_yaml::from_str(indoc::indoc! {
129            r#"connection_string: "amqp://localhost:5672/%2f"
130            routing_key: user_id
131            exchange: test
132            encoding:
133              codec: json
134            max_channels: 4"#,
135        })
136        .unwrap()
137    }
138}
139
140#[async_trait::async_trait]
141#[typetag::serde(name = "amqp")]
142impl SinkConfig for AmqpSinkConfig {
143    fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
144        Some(&self.confinement)
145    }
146
147    fn input(&self) -> Input {
148        Input::new(DataType::Log)
149    }
150
151    fn acknowledgements(&self) -> &AcknowledgementsConfig {
152        &self.acknowledgements
153    }
154
155    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
156        Some(self)
157    }
158}
159
160#[derive(Clone, Debug)]
161pub struct ValidatedAmqpSink {
162    exchange: ConfinedTemplate,
163    routing_key: Option<ConfinedTemplate>,
164}
165
166#[async_trait::async_trait]
167impl ValidatedSink for AmqpSinkConfig {
168    type Validated = ValidatedAmqpSink;
169
170    fn validate(&self) -> crate::Result<ValidatedAmqpSink> {
171        if self.max_channels == 0 {
172            return Err(Box::new(AmqpError::PoolError {
173                error: "max_channels must be positive".into(),
174            }));
175        }
176        // Parse the connection string up front so malformed or unsupported-scheme
177        // AMQP URIs are rejected by `vector validate` rather than at build time.
178        self.connection
179            .connection_string
180            .parse::<AMQPUri>()
181            .map_err(|e| format!("Invalid connection string: {e}"))?;
182        let exchange = self
183            .exchange
184            .clone()
185            .confine(&self.confinement, Self::NAME, "exchange")?;
186        let routing_key = self
187            .routing_key
188            .clone()
189            .map(|t| t.confine(&self.confinement, Self::NAME, "routing_key"))
190            .transpose()?;
191        Ok(ValidatedAmqpSink {
192            exchange,
193            routing_key,
194        })
195    }
196
197    async fn build(
198        &self,
199        validated: &ValidatedAmqpSink,
200        _cx: SinkContext,
201    ) -> crate::Result<(VectorSink, Healthcheck)> {
202        let ValidatedAmqpSink {
203            exchange,
204            routing_key,
205        } = validated.clone();
206        let sink = AmqpSink::new(self.clone(), exchange, routing_key).await?;
207        let hc = healthcheck(sink.channels.clone()).boxed();
208        Ok((VectorSink::from_event_streamsink(sink), hc))
209    }
210}
211
212pub(super) async fn healthcheck(channels: AmqpSinkChannels) -> crate::Result<()> {
213    trace!("Healthcheck started.");
214
215    let channel = channels.get().await?;
216
217    if !channel.status().connected() {
218        return Err(Box::new(std::io::Error::new(
219            std::io::ErrorKind::BrokenPipe,
220            "Not Connected",
221        )));
222    }
223
224    trace!("Healthcheck completed.");
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::config::ValidatedSink;
232    use crate::config::format::{Format, deserialize};
233    use crate::template::{ConfinementConfig, Template};
234    use vrl::event_path;
235
236    #[test]
237    pub fn generate_config() {
238        crate::test_util::test_generate_config::<AmqpSinkConfig>();
239    }
240
241    #[test]
242    fn validate_rejects_zero_max_channels() {
243        let config = AmqpSinkConfig {
244            max_channels: 0,
245            ..Default::default()
246        };
247        let result = config.validate();
248        assert!(result.is_err(), "validation should reject max_channels = 0");
249    }
250
251    #[test]
252    fn validate_rejects_malformed_connection_string() {
253        let config = AmqpSinkConfig {
254            connection: AmqpConfig {
255                connection_string: "not a uri".to_string(),
256                ..Default::default()
257            },
258            ..Default::default()
259        };
260        let result = config.validate();
261        assert!(
262            result.is_err(),
263            "validation should reject a malformed connection string"
264        );
265    }
266
267    #[test]
268    fn validate_rejects_unsupported_scheme() {
269        let config = AmqpSinkConfig {
270            connection: AmqpConfig {
271                connection_string: "http://localhost:5672".to_string(),
272                ..Default::default()
273            },
274            ..Default::default()
275        };
276        let result = config.validate();
277        assert!(
278            result.is_err(),
279            "validation should reject an unsupported AMQP URI scheme"
280        );
281    }
282
283    #[test]
284    fn validate_returns_confined_templates() {
285        let config = AmqpSinkConfig {
286            exchange: Template::try_from("test-exchange").unwrap(),
287            routing_key: Some(Template::try_from("test-key").unwrap()),
288            ..Default::default()
289        };
290        let validated = config.validate().expect("validation should succeed");
291        assert_eq!(validated.exchange.to_string(), "test-exchange");
292        assert_eq!(
293            validated.routing_key.as_ref().unwrap().to_string(),
294            "test-key"
295        );
296    }
297
298    #[test]
299    fn confinement_rejects_unconfined_exchange() {
300        let template = Template::try_from("{{ exchange }}").unwrap();
301        let config = ConfinementConfig::default();
302        let result = template.confine(&config, "amqp", "exchange");
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn confinement_opt_out_allows_unconfined_exchange() {
308        let template = Template::try_from("{{ exchange }}").unwrap();
309        let config = ConfinementConfig {
310            dangerously_allow_unconfined_template_resolution: true,
311        };
312        let result = template.confine(&config, "amqp", "exchange");
313        assert!(result.is_ok());
314    }
315
316    #[test]
317    fn confinement_allows_prefixed_exchange() {
318        let template = Template::try_from("events-{{ env }}").unwrap();
319        let config = ConfinementConfig::default();
320        let result = template.confine(&config, "amqp", "exchange");
321        assert!(result.is_ok());
322    }
323
324    fn assert_config_priority_eq(config: AmqpSinkConfig, event: &LogEvent, priority: u8) {
325        assert_eq!(
326            config
327                .properties
328                .unwrap()
329                .priority
330                .unwrap()
331                .render(event)
332                .unwrap(),
333            priority as u64
334        );
335    }
336
337    #[test]
338    pub fn parse_config_priority_static() {
339        for (format, config) in [
340            (
341                Format::Yaml,
342                r#"
343            exchange: "test"
344            routing_key: "user_id"
345            encoding:
346                codec: "json"
347            connection_string: "amqp://user:password@127.0.0.1:5672/"
348            properties:
349                priority: 1
350            "#,
351            ),
352            (
353                Format::Toml,
354                r#"
355            exchange = "test"
356            routing_key = "user_id"
357            encoding.codec = "json"
358            connection_string = "amqp://user:password@127.0.0.1:5672/"
359            properties = { priority = 1 }
360            "#,
361            ),
362            (
363                Format::Json,
364                r#"
365            {
366                "exchange": "test",
367                "routing_key": "user_id",
368                "encoding": {
369                    "codec": "json"
370                },
371                "connection_string": "amqp://user:password@127.0.0.1:5672/",
372                "properties": {
373                    "priority": 1
374                }
375            }
376            "#,
377            ),
378        ] {
379            let config: AmqpSinkConfig = deserialize(config, format).unwrap();
380            let event = LogEvent::from_str_legacy("message");
381            assert_config_priority_eq(config, &event, 1);
382        }
383    }
384
385    #[test]
386    pub fn parse_config_priority_templated() {
387        for (format, config) in [
388            (
389                Format::Yaml,
390                r#"
391            exchange: "test"
392            routing_key: "user_id"
393            encoding:
394                codec: "json"
395            connection_string: "amqp://user:password@127.0.0.1:5672/"
396            properties:
397                priority: "{{ .priority }}"
398            "#,
399            ),
400            (
401                Format::Toml,
402                r#"
403            exchange = "test"
404            routing_key = "user_id"
405            encoding.codec = "json"
406            connection_string = "amqp://user:password@127.0.0.1:5672/"
407            properties = { priority = "{{ .priority }}" }
408            "#,
409            ),
410            (
411                Format::Json,
412                r#"
413            {
414                "exchange": "test",
415                "routing_key": "user_id",
416                "encoding": {
417                    "codec": "json"
418                },
419                "connection_string": "amqp://user:password@127.0.0.1:5672/",
420                "properties": {
421                    "priority": "{{ .priority }}"
422                }
423            }
424            "#,
425            ),
426        ] {
427            let config: AmqpSinkConfig = deserialize(config, format).unwrap();
428            let event = {
429                let mut event = LogEvent::from_str_legacy("message");
430                event.insert(event_path!("priority"), 2);
431                event
432            };
433            assert_config_priority_eq(config, &event, 2);
434        }
435    }
436}