Skip to main content

rustfs_targets/target/
amqp.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! AMQP 0-9-1 event notification target.
16//!
17//! Publishes S3 events to RabbitMQ-compatible AMQP 0-9-1 brokers via `lapin`.
18//! Queue-store mode uses the shared target store and replays the same raw JSON
19//! body through `send_raw_from_store`.
20
21use 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
54/// Upper bound on how long a single publish and its publisher-confirm may block
55/// before being treated as a timeout (backlog#980).
56const 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
245/// Returns true for AMQP broker protocol errors that indicate a permanent,
246/// non-connectivity condition (e.g. the exchange/queue does not exist, access is
247/// refused, or a precondition failed). These must not be treated as transient
248/// connectivity errors, otherwise a misconfigured target triggers an endless
249/// reconnect storm instead of surfacing a delivery failure (backlog#973).
250fn 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            // 404 NOT_FOUND (missing exchange/queue), 403 ACCESS_REFUSED,
255            // 406 PRECONDITION_FAILED.
256            AMQPErrorKind::Soft(soft) => {
257                matches!(
258                    soft,
259                    AMQPSoftError::NOTFOUND | AMQPSoftError::ACCESSREFUSED | AMQPSoftError::PRECONDITIONFAILED
260                )
261            }
262            // 530 NOT_ALLOWED, 540 NOT_IMPLEMENTED, 402 INVALID_PATH.
263            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        // Reconnect explicitly so every new channel enables publisher confirms below.
299        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    /// When set, the coordinator drives TLS reload; inline fingerprint check is skipped.
343    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            // With mandatory=false the broker silently discards messages that
373            // route to no queue, and the publish is still confirmed as success.
374            // Warn so operators expecting reliable delivery know unroutable
375            // events are dropped without a trace (backlog#980).
376            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        // When a TLS reload adapter is attached, it drives connection rebuilds
412        // in the background. The inline per-send fingerprint check is skipped.
413        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        // Bound the publish and publisher-confirm waits so a stuck broker cannot
469        // block the send path indefinitely (backlog#980).
470        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/// Coordinated TLS hot-reload implementation for AMQP targets.
526///
527/// The coordinator calls these methods on a background poll loop to detect
528/// TLS file changes and rebuild the connection without restarting.
529#[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        // A disabled target is never active; avoid opening a connection for it
576        // (backlog#980).
577        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        // Capture any close failure but still run the remaining cleanup so a
615        // failed broker close does not leave stale TLS/adapter state behind
616        // (backlog#980).
617        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 a TLS reload adapter is attached, reset its error tracking
625        // so that a future re-init does not inherit stale failure state.
626        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            // AMQP targets record no terminal failures and keep no failed store.
663            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        // 404 (missing exchange/queue) is permanent: it must be a request-level
838        // error so a misconfigured target does not reconnect-storm (backlog#973).
839        let not_found = make(AMQPErrorKind::Soft(AMQPSoftError::NOTFOUND));
840        assert!(is_permanent_amqp_protocol_error(&not_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        // A transient soft error (broker resource locked) is not treated as permanent.
847        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}