1use 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#[configurable_component]
18#[configurable(title = "Configure the AMQP message properties.")]
19#[derive(Clone, Debug, Default)]
20pub struct AmqpPropertiesConfig {
21 pub(crate) content_type: Option<String>,
23
24 pub(crate) content_encoding: Option<String>,
26
27 pub(crate) expiration_ms: Option<u64>,
29
30 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 let priority = priority.clamp(0, u8::MAX.into()) as u8;
60 prop = prop.with_priority(priority);
61 }
62 Some(prop)
63 }
64}
65
66#[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 pub(crate) exchange: Template,
77
78 pub(crate) routing_key: Option<Template>,
80
81 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 #[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 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}