1use crate::plugin::PluginEvent;
22use crate::{
23 StoreError, Target,
24 arn::TargetID,
25 error::TargetError,
26 runtime::tls::{
27 ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
28 validate_tls_material,
29 },
30 store::{Key, Store},
31 target::{
32 ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
33 TargetTlsState, TargetType, build_queued_payload_with_records, build_target_tls_fingerprint, is_connectivity_error,
34 open_target_queue_store, persist_queued_payload_to_store,
35 },
36};
37use async_trait::async_trait;
38use lapin::{
39 BasicProperties, Channel, Confirmation, Connection, ConnectionProperties, ErrorKind as LapinErrorKind,
40 options::{BasicPublishOptions, ConfirmSelectOptions},
41 tcp::{OwnedIdentity, OwnedTLSConfig},
42};
43use parking_lot::Mutex;
44use rustfs_config::{AMQP_TLS_CA, AMQP_TLS_CLIENT_CERT, AMQP_TLS_CLIENT_KEY};
45use rustfs_tls_runtime::load_cert_bundle_der_bytes;
46use std::fmt;
47use std::path::Path;
48use std::sync::Arc;
49use std::time::Duration;
50use tokio::sync::Mutex as AsyncMutex;
51use tracing::{info, instrument, warn};
52use url::Url;
53
54const AMQP_PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
57
58#[derive(Clone)]
59pub struct AMQPArgs {
60 pub enable: bool,
61 pub url: Url,
62 pub exchange: String,
63 pub routing_key: String,
64 pub mandatory: bool,
65 pub persistent: bool,
66 pub username: String,
67 pub password: String,
68 pub tls_ca: String,
69 pub tls_client_cert: String,
70 pub tls_client_key: String,
71 pub queue_dir: String,
72 pub queue_limit: u64,
73 pub target_type: TargetType,
74}
75
76impl fmt::Debug for AMQPArgs {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.debug_struct("AMQPArgs")
79 .field("enable", &self.enable)
80 .field("url", &redacted_amqp_url(&self.url))
81 .field("exchange", &self.exchange)
82 .field("routing_key", &self.routing_key)
83 .field("mandatory", &self.mandatory)
84 .field("persistent", &self.persistent)
85 .field("username", &self.username)
86 .field("password", if self.password.is_empty() { &"" } else { &"***REDACTED***" })
87 .field("tls_ca", &self.tls_ca)
88 .field("tls_client_cert", &self.tls_client_cert)
89 .field(
90 "tls_client_key",
91 if self.tls_client_key.is_empty() {
92 &""
93 } else {
94 &"***REDACTED***"
95 },
96 )
97 .field("queue_dir", &self.queue_dir)
98 .field("queue_limit", &self.queue_limit)
99 .field("target_type", &self.target_type)
100 .finish()
101 }
102}
103
104impl AMQPArgs {
105 pub fn validate(&self) -> Result<(), TargetError> {
106 if !self.enable {
107 return Ok(());
108 }
109
110 validate_amqp_url(&self.url)?;
111
112 if self.exchange.trim().is_empty() {
113 return Err(TargetError::Configuration("AMQP exchange cannot be empty".to_string()));
114 }
115 if self.routing_key.trim().is_empty() {
116 return Err(TargetError::Configuration("AMQP routing_key cannot be empty".to_string()));
117 }
118
119 let url_has_credentials = !self.url.username().is_empty() || self.url.password().is_some();
120 let config_has_credentials = !self.username.is_empty() || !self.password.is_empty();
121 if self.username.is_empty() != self.password.is_empty() {
122 return Err(TargetError::Configuration(
123 "AMQP username and password must be specified together".to_string(),
124 ));
125 }
126 if url_has_credentials && config_has_credentials {
127 return Err(TargetError::Configuration(
128 "AMQP credentials must be specified either in url or username/password, not both".to_string(),
129 ));
130 }
131
132 validate_amqp_tls_paths(self)?;
133
134 if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
135 return Err(TargetError::Configuration("AMQP queue directory must be an absolute path".to_string()));
136 }
137
138 Ok(())
139 }
140}
141
142fn redacted_amqp_url(url: &Url) -> String {
143 if url.password().is_none() {
144 return url.to_string();
145 }
146 let mut redacted = url.clone();
147 let _ = redacted.set_password(Some("***REDACTED***"));
148 redacted.to_string()
149}
150
151pub fn validate_amqp_url(url: &Url) -> Result<(), TargetError> {
152 match url.scheme() {
153 "amqp" | "amqps" => {
154 if url.host_str().is_none() {
155 return Err(TargetError::Configuration("AMQP URL is missing host".to_string()));
156 }
157 Ok(())
158 }
159 scheme => Err(TargetError::Configuration(format!(
160 "Unsupported AMQP URL scheme: {scheme} (only amqp and amqps are allowed)"
161 ))),
162 }
163}
164
165fn validate_amqp_tls_paths(args: &AMQPArgs) -> Result<(), TargetError> {
166 let has_tls_settings = !args.tls_ca.is_empty() || !args.tls_client_cert.is_empty() || !args.tls_client_key.is_empty();
167 if has_tls_settings && args.url.scheme() != "amqps" {
168 return Err(TargetError::Configuration(
169 "AMQP TLS settings are only allowed with amqps URLs".to_string(),
170 ));
171 }
172
173 if args.tls_client_cert.is_empty() != args.tls_client_key.is_empty() {
174 return Err(TargetError::Configuration(
175 "AMQP tls_client_cert and tls_client_key must be specified together".to_string(),
176 ));
177 }
178
179 if !args.tls_ca.is_empty() && !Path::new(&args.tls_ca).is_absolute() {
180 return Err(TargetError::Configuration(format!("{AMQP_TLS_CA} must be an absolute path")));
181 }
182 if !args.tls_client_cert.is_empty() && !Path::new(&args.tls_client_cert).is_absolute() {
183 return Err(TargetError::Configuration(format!("{AMQP_TLS_CLIENT_CERT} must be an absolute path")));
184 }
185 if !args.tls_client_key.is_empty() && !Path::new(&args.tls_client_key).is_absolute() {
186 return Err(TargetError::Configuration(format!("{AMQP_TLS_CLIENT_KEY} must be an absolute path")));
187 }
188
189 Ok(())
190}
191
192fn connection_url(args: &AMQPArgs) -> Result<String, TargetError> {
193 let mut url = args.url.clone();
194 if !args.username.is_empty() {
195 url.set_username(&args.username)
196 .map_err(|_| TargetError::Configuration("AMQP username cannot be set on URL".to_string()))?;
197 url.set_password(Some(&args.password))
198 .map_err(|_| TargetError::Configuration("AMQP password cannot be set on URL".to_string()))?;
199 }
200 Ok(url.to_string())
201}
202
203async fn build_tls_config(args: &AMQPArgs) -> Result<OwnedTLSConfig, TargetError> {
204 let cert_chain = if args.tls_ca.is_empty() {
205 None
206 } else {
207 let certs_der = load_cert_bundle_der_bytes(&args.tls_ca)
208 .map_err(|e| TargetError::Configuration(format!("Failed to parse {AMQP_TLS_CA}: {e}")))?;
209 if certs_der.is_empty() {
210 return Err(TargetError::Configuration(format!(
211 "{AMQP_TLS_CA} did not contain any parsable certificates"
212 )));
213 }
214 let pem = tokio::fs::read_to_string(&args.tls_ca)
215 .await
216 .map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CA}: {e}")))?;
217 Some(pem)
218 };
219
220 let identity = if args.tls_client_cert.is_empty() {
221 None
222 } else {
223 let _ = load_cert_bundle_der_bytes(&args.tls_client_cert)
224 .map_err(|e| TargetError::Configuration(format!("Failed to parse {AMQP_TLS_CLIENT_CERT}: {e}")))?;
225 let pem = tokio::fs::read(&args.tls_client_cert)
226 .await
227 .map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CLIENT_CERT}: {e}")))?;
228 let key = tokio::fs::read(&args.tls_client_key)
229 .await
230 .map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CLIENT_KEY}: {e}")))?;
231 Some(OwnedIdentity::PKCS8 { pem, key })
232 };
233
234 Ok(OwnedTLSConfig { identity, cert_chain })
235}
236
237fn build_publish_properties(args: &AMQPArgs) -> BasicProperties {
238 let mut properties = BasicProperties::default().with_content_type("application/json".into());
239 if args.persistent {
240 properties = properties.with_delivery_mode(2);
241 }
242 properties
243}
244
245fn is_permanent_amqp_protocol_error(err: &lapin::Error) -> bool {
251 use lapin::protocol::{AMQPErrorKind, AMQPHardError, AMQPSoftError};
252 if let LapinErrorKind::ProtocolError(amqp_err) = err.kind() {
253 return match amqp_err.kind() {
254 AMQPErrorKind::Soft(soft) => {
257 matches!(
258 soft,
259 AMQPSoftError::NOTFOUND | AMQPSoftError::ACCESSREFUSED | AMQPSoftError::PRECONDITIONFAILED
260 )
261 }
262 AMQPErrorKind::Hard(hard) => {
264 matches!(
265 hard,
266 AMQPHardError::NOTALLOWED | AMQPHardError::NOTIMPLEMENTED | AMQPHardError::INVALIDPATH
267 )
268 }
269 };
270 }
271 false
272}
273
274fn map_lapin_error(err: lapin::Error, context: &str) -> TargetError {
275 let message = format!("{context}: {err}");
276 if is_permanent_amqp_protocol_error(&err) {
277 return TargetError::Request(message);
278 }
279 match err.kind() {
280 LapinErrorKind::IOError(io_err) if io_err.kind() == std::io::ErrorKind::TimedOut => TargetError::Timeout(message),
281 LapinErrorKind::IOError(_)
282 | LapinErrorKind::InvalidConnectionState(_)
283 | LapinErrorKind::InvalidChannelState(..)
284 | LapinErrorKind::MissingHeartbeatError
285 | LapinErrorKind::ProtocolError(_)
286 if err.can_be_recovered() =>
287 {
288 TargetError::NotConnected
289 }
290 _ => TargetError::Network(message),
291 }
292}
293
294pub async fn connect_amqp(args: &AMQPArgs) -> Result<AMQPConnection, TargetError> {
295 args.validate()?;
296 tokio::time::timeout(Duration::from_secs(5), async {
297 let url = connection_url(args)?;
298 let properties = ConnectionProperties::default();
300 let connection = if args.url.scheme() == "amqps" && (!args.tls_ca.is_empty() || !args.tls_client_cert.is_empty()) {
301 Connection::connect_with_config(
302 &url,
303 properties,
304 build_tls_config(args).await?,
305 lapin::runtime::default_runtime()
306 .map_err(|e| TargetError::Initialization(format!("Failed to create AMQP runtime: {e}")))?,
307 )
308 .await
309 } else {
310 Connection::connect(&url, properties).await
311 }
312 .map_err(|e| map_lapin_error(e, "Failed to connect to AMQP broker"))?;
313
314 let channel = connection
315 .create_channel()
316 .await
317 .map_err(|e| map_lapin_error(e, "Failed to create AMQP channel"))?;
318 channel
319 .confirm_select(ConfirmSelectOptions::default())
320 .await
321 .map_err(|e| map_lapin_error(e, "Failed to enable AMQP publisher confirms"))?;
322
323 Ok(AMQPConnection { connection, channel })
324 })
325 .await
326 .unwrap_or_else(|_| Err(TargetError::Timeout("AMQP connection timed out".to_string())))
327}
328
329pub struct AMQPConnection {
330 pub(crate) connection: Connection,
331 pub(crate) channel: Channel,
332}
333
334pub struct AMQPTarget<E>
335where
336 E: PluginEvent,
337{
338 id: TargetID,
339 args: AMQPArgs,
340 connection: Arc<Mutex<Option<Arc<AMQPConnection>>>>,
341 tls_state: Arc<Mutex<TargetTlsState>>,
342 tls_adapter: Option<TlsReloadAdapter<AMQPConnection>>,
344 connect_lock: Arc<AsyncMutex<()>>,
345 store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
346 delivery_counters: Arc<TargetDeliveryCounters>,
347 _phantom: std::marker::PhantomData<E>,
348}
349
350impl<E> AMQPTarget<E>
351where
352 E: PluginEvent,
353{
354 pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
355 Box::new(AMQPTarget::<E> {
356 id: self.id.clone(),
357 args: self.args.clone(),
358 connection: Arc::clone(&self.connection),
359 tls_state: Arc::clone(&self.tls_state),
360 tls_adapter: self.tls_adapter.clone(),
361 connect_lock: Arc::clone(&self.connect_lock),
362 store: self.store.as_ref().map(|s| s.boxed_clone()),
363 delivery_counters: Arc::clone(&self.delivery_counters),
364 _phantom: std::marker::PhantomData,
365 })
366 }
367
368 #[instrument(skip(args), fields(target_id_as_string = %id))]
369 pub fn new(id: String, args: AMQPArgs) -> Result<Self, TargetError> {
370 args.validate()?;
371 if args.enable && !args.mandatory {
372 warn!(
377 target_id = %id,
378 exchange = %args.exchange,
379 routing_key = %args.routing_key,
380 "AMQP target has mandatory=false: messages that route to no queue are silently dropped. Set mandatory=true for reliable delivery."
381 );
382 }
383 let target_id = TargetID::new(id, ChannelTargetType::Amqp.as_str().to_string());
384 let queue_store = open_target_queue_store(
385 &args.queue_dir,
386 args.queue_limit,
387 args.target_type,
388 ChannelTargetType::Amqp.as_str(),
389 &target_id,
390 "Failed to open store for AMQP target",
391 )?;
392
393 Ok(Self {
394 id: target_id,
395 args,
396 connection: Arc::new(Mutex::new(None)),
397 tls_state: Arc::new(Mutex::new(TargetTlsState::default())),
398 tls_adapter: None,
399 connect_lock: Arc::new(AsyncMutex::new(())),
400 store: queue_store,
401 delivery_counters: Arc::new(TargetDeliveryCounters::default()),
402 _phantom: std::marker::PhantomData,
403 })
404 }
405
406 fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
407 build_queued_payload_with_records(event, vec![event.clone()])
408 }
409
410 async fn get_or_connect(&self) -> Result<Arc<AMQPConnection>, TargetError> {
411 if let Some(adapter) = &self.tls_adapter {
414 let material = adapter.current_material();
415 if material.connection.status().connected() && material.channel.status().connected() {
416 return Ok(material);
417 }
418 self.clear_connection_handle();
419 } else {
420 let next_fingerprint =
421 build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key).await?;
422 let tls_changed = {
423 let tls_state_guard = self.tls_state.lock();
424 tls_state_guard.needs_update(&next_fingerprint)
425 };
426 if tls_changed {
427 self.clear_connection_handle();
428 self.tls_state.lock().refresh(next_fingerprint);
429 }
430 }
431
432 if let Some(connection) = self.connection.lock().clone()
433 && connection.connection.status().connected()
434 && connection.channel.status().connected()
435 {
436 return Ok(connection);
437 }
438
439 let _guard = self.connect_lock.lock().await;
440 if let Some(connection) = self.connection.lock().clone()
441 && connection.connection.status().connected()
442 && connection.channel.status().connected()
443 {
444 return Ok(connection);
445 }
446
447 let connection = Arc::new(connect_amqp(&self.args).await?);
448 let mut guard = self.connection.lock();
449 *guard = Some(Arc::clone(&connection));
450 Ok(connection)
451 }
452
453 fn clear_connection_handle(&self) {
454 *self.connection.lock() = None;
455 }
456
457 fn clear_connection_cache(&self) {
458 self.clear_connection_handle();
459 self.tls_state.lock().reset();
460 }
461
462 fn clear_connection(&self) {
463 self.clear_connection_cache();
464 }
465
466 async fn send_body(&self, body: &[u8]) -> Result<(), TargetError> {
467 let connection = self.get_or_connect().await?;
468 let publish = match tokio::time::timeout(
471 AMQP_PUBLISH_TIMEOUT,
472 connection.channel.basic_publish(
473 self.args.exchange.clone().into(),
474 self.args.routing_key.clone().into(),
475 BasicPublishOptions {
476 mandatory: self.args.mandatory,
477 ..BasicPublishOptions::default()
478 },
479 body,
480 build_publish_properties(&self.args),
481 ),
482 )
483 .await
484 {
485 Ok(publish) => publish,
486 Err(_) => {
487 self.clear_connection();
488 return Err(TargetError::Timeout("AMQP publish timed out".to_string()));
489 }
490 };
491
492 let confirm_future = match publish {
493 Ok(confirm) => confirm,
494 Err(err) => {
495 self.clear_connection();
496 return Err(map_lapin_error(err, "Failed to publish AMQP message"));
497 }
498 };
499
500 let confirm = match tokio::time::timeout(AMQP_PUBLISH_TIMEOUT, confirm_future).await {
501 Ok(confirm) => confirm,
502 Err(_) => {
503 self.clear_connection();
504 return Err(TargetError::Timeout("AMQP publisher confirm timed out".to_string()));
505 }
506 };
507
508 match confirm {
509 Ok(Confirmation::Ack(None) | Confirmation::NotRequested) => {
510 self.delivery_counters.record_success();
511 Ok(())
512 }
513 Ok(Confirmation::Ack(Some(returned)) | Confirmation::Nack(Some(returned))) => {
514 Err(TargetError::Request(format!("AMQP broker returned message: {}", returned.reply_text)))
515 }
516 Ok(Confirmation::Nack(None)) => Err(TargetError::Request("AMQP broker negatively acknowledged message".to_string())),
517 Err(err) => {
518 self.clear_connection();
519 Err(map_lapin_error(err, "Failed to confirm AMQP publish"))
520 }
521 }
522 }
523}
524
525#[async_trait]
530impl<E> ReloadableTargetTls for AMQPTarget<E>
531where
532 E: PluginEvent,
533{
534 type Material = AMQPConnection;
535
536 fn tls_input_set(&self) -> TargetTlsInputSet {
537 TargetTlsInputSet {
538 ca_path: self.args.tls_ca.clone(),
539 client_cert_path: self.args.tls_client_cert.clone(),
540 client_key_path: self.args.tls_client_key.clone(),
541 target_label: format!("amqp:{}", self.id.id),
542 }
543 }
544
545 async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
546 connect_amqp(&self.args).await
547 }
548
549 async fn apply_tls_material(
550 &self,
551 _generation: TargetTlsGeneration,
552 material: Arc<Self::Material>,
553 _mode: ReloadApplyMode,
554 ) -> Result<(), TargetError> {
555 let mut guard = self.connection.lock();
556 *guard = Some(material);
557 Ok(())
558 }
559
560 async fn validate_tls_files(&self) -> Result<(), TargetError> {
561 validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
562 }
563}
564
565#[async_trait]
566impl<E> Target<E> for AMQPTarget<E>
567where
568 E: PluginEvent,
569{
570 fn id(&self) -> TargetID {
571 self.id.clone()
572 }
573
574 async fn is_active(&self) -> Result<bool, TargetError> {
575 if !self.args.enable {
578 return Ok(false);
579 }
580 let connection = self.get_or_connect().await?;
581 Ok(connection.connection.status().connected() && connection.channel.status().connected())
582 }
583
584 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
585 let queued = match self.build_queued_payload(&event) {
586 Ok(queued) => queued,
587 Err(err) => {
588 self.delivery_counters.record_final_failure();
589 return Err(err);
590 }
591 };
592
593 if let Some(store) = &self.store {
594 if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
595 self.delivery_counters.record_final_failure();
596 return Err(e);
597 }
598 Ok(())
599 } else {
600 if let Err(err) = self.send_body(&queued.body).await {
601 self.delivery_counters.record_final_failure();
602 return Err(err);
603 }
604 Ok(())
605 }
606 }
607
608 async fn send_raw_from_store(&self, _key: Key, body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
609 self.send_body(&body).await
610 }
611
612 async fn close(&self) -> Result<(), TargetError> {
613 let connection = self.connection.lock().take();
614 let mut close_result = Ok(());
618 if let Some(connection) = connection
619 && let Err(e) = connection.connection.close(200, "OK".into()).await
620 {
621 close_result = Err(map_lapin_error(e, "Failed to close AMQP connection"));
622 }
623 self.tls_state.lock().reset();
624 if let Some(adapter) = &self.tls_adapter {
627 *adapter.runtime_state().last_error.write() = None;
628 }
629 info!(target_id = %self.id, "AMQP target closed");
630 close_result
631 }
632
633 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
634 self.store.as_deref()
635 }
636
637 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
638 self.clone_box()
639 }
640
641 async fn init(&self) -> Result<(), TargetError> {
642 if !self.is_enabled() {
643 return Ok(());
644 }
645 match self.get_or_connect().await {
646 Ok(_) => Ok(()),
647 Err(err) if self.store.is_some() && is_connectivity_error(&err) => {
648 warn!(target_id = %self.id, error = %err, "AMQP init failed; events will buffer in store");
649 Ok(())
650 }
651 Err(err) => Err(err),
652 }
653 }
654
655 fn is_enabled(&self) -> bool {
656 self.args.enable
657 }
658
659 fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
660 self.delivery_counters.snapshot(
661 self.store.as_deref().map_or(0, |store| store.len() as u64),
662 0,
664 )
665 }
666
667 fn record_final_failure(&self) {
668 self.delivery_counters.record_final_failure();
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use rustfs_s3_types::EventName;
676 use serde_json::json;
677 use std::path::PathBuf;
678 use std::sync::Arc;
679 use uuid::Uuid;
680
681 fn valid_args() -> AMQPArgs {
682 AMQPArgs {
683 enable: true,
684 url: Url::parse("amqp://127.0.0.1:5672/%2f").unwrap(),
685 exchange: "rustfs.events".to_string(),
686 routing_key: "objects".to_string(),
687 mandatory: false,
688 persistent: true,
689 username: String::new(),
690 password: String::new(),
691 tls_ca: String::new(),
692 tls_client_cert: String::new(),
693 tls_client_key: String::new(),
694 queue_dir: String::new(),
695 queue_limit: 10,
696 target_type: TargetType::NotifyEvent,
697 }
698 }
699
700 fn unreachable_args() -> AMQPArgs {
701 AMQPArgs {
702 url: Url::parse("amqp://127.0.0.1:1/%2f").unwrap(),
703 ..valid_args()
704 }
705 }
706
707 fn test_event() -> Arc<EntityTarget<serde_json::Value>> {
708 Arc::new(EntityTarget {
709 object_name: "object.txt".to_string(),
710 bucket_name: "bucket".to_string(),
711 event_name: EventName::ObjectCreatedPut,
712 data: json!({"ok": true}),
713 })
714 }
715
716 fn temp_store_dir(name: &str) -> PathBuf {
717 std::env::temp_dir().join(format!("rustfs-amqp-target-{name}-{}", Uuid::new_v4()))
718 }
719
720 fn assert_connect_failure(err: &TargetError) {
721 assert!(
722 matches!(err, TargetError::NotConnected | TargetError::Timeout(_)),
723 "unexpected error: {err}"
724 );
725 }
726
727 #[test]
728 fn new_rejects_invalid_args() {
729 let mut args = valid_args();
730 args.exchange.clear();
731
732 let err = match AMQPTarget::<serde_json::Value>::new("primary".to_string(), args) {
733 Ok(_) => panic!("invalid args should fail"),
734 Err(err) => err,
735 };
736
737 assert!(err.to_string().contains("exchange cannot be empty"));
738 }
739
740 #[test]
741 fn new_accepts_queue_mode() {
742 let mut args = valid_args();
743 args.queue_dir = temp_store_dir("queue-mode").to_string_lossy().to_string();
744
745 let target =
746 AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("queue mode should be supported");
747
748 assert!(target.store().is_some());
749 let _ = std::fs::remove_dir_all(args.queue_dir);
750 }
751
752 #[tokio::test]
753 async fn save_with_store_queues_event_without_broker() {
754 let mut args = unreachable_args();
755 args.queue_dir = temp_store_dir("save-store").to_string_lossy().to_string();
756 let target = AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("target should build");
757
758 target
759 .save(test_event())
760 .await
761 .expect("store-backed save should queue without broker");
762
763 assert_eq!(target.delivery_snapshot().queue_length, 1);
764 assert_eq!(target.delivery_snapshot().failed_messages, 0);
765 let _ = std::fs::remove_dir_all(args.queue_dir);
766 }
767
768 #[tokio::test]
769 async fn save_without_store_returns_connection_error() {
770 let target =
771 AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
772
773 let err = target
774 .save(test_event())
775 .await
776 .expect_err("direct publish should fail without broker");
777
778 assert_connect_failure(&err);
779 assert_eq!(target.delivery_snapshot().failed_messages, 1);
780 }
781
782 #[tokio::test]
783 async fn init_with_store_allows_broker_to_recover_later() {
784 let mut args = unreachable_args();
785 args.queue_dir = temp_store_dir("init-store").to_string_lossy().to_string();
786 let target = AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("target should build");
787
788 target.init().await.expect("store-backed init should tolerate broker failure");
789 let _ = std::fs::remove_dir_all(args.queue_dir);
790 }
791
792 #[tokio::test]
793 async fn init_without_store_returns_connection_error() {
794 let target =
795 AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
796
797 let err = target
798 .init()
799 .await
800 .expect_err("init should fail without broker when no store exists");
801
802 assert_connect_failure(&err);
803 }
804
805 #[tokio::test]
806 async fn send_raw_from_store_returns_connection_error() {
807 let target =
808 AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
809 let key = Key {
810 name: "queued".to_string(),
811 extension: ".event".to_string(),
812 item_count: 1,
813 compress: false,
814 };
815 let meta = QueuedPayloadMeta::new(
816 EventName::ObjectCreatedPut,
817 "bucket".to_string(),
818 "object.txt".to_string(),
819 "application/json",
820 2,
821 );
822
823 let err = target
824 .send_raw_from_store(key, b"{}".to_vec(), meta)
825 .await
826 .expect_err("queue replay should fail without broker");
827
828 assert_connect_failure(&err);
829 }
830
831 #[test]
832 fn permanent_amqp_protocol_errors_are_not_connectivity_errors() {
833 use lapin::protocol::{AMQPError, AMQPErrorKind, AMQPHardError, AMQPSoftError};
834
835 let make = |kind: AMQPErrorKind| lapin::Error::from(LapinErrorKind::ProtocolError(AMQPError::new(kind, "boom".into())));
836
837 let not_found = make(AMQPErrorKind::Soft(AMQPSoftError::NOTFOUND));
840 assert!(is_permanent_amqp_protocol_error(¬_found));
841 assert!(matches!(map_lapin_error(not_found, "publish"), TargetError::Request(_)));
842
843 assert!(is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Soft(AMQPSoftError::ACCESSREFUSED))));
844 assert!(is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Hard(AMQPHardError::NOTALLOWED))));
845
846 assert!(!is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Soft(
848 AMQPSoftError::RESOURCELOCKED
849 ))));
850 }
851
852 #[test]
853 fn debug_masks_secret_values() {
854 let args = AMQPArgs {
855 url: Url::parse("amqp://guest:secret@127.0.0.1:5672/%2f").unwrap(),
856 password: "secret".to_string(),
857 tls_client_key: "/tmp/client.key".to_string(),
858 ..valid_args()
859 };
860 let rendered = format!("{args:?}");
861
862 assert!(!rendered.contains("guest:secret"));
863 assert!(!rendered.contains("password: \"secret\""));
864 assert!(!rendered.contains("tls_client_key: \"/tmp/client.key\""));
865 assert!(rendered.contains("***REDACTED***"));
866 }
867}