1use crate::plugin::PluginEvent;
16use crate::{
17 StoreError, Target,
18 arn::TargetID,
19 error::TargetError,
20 runtime::tls::{
21 ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
22 validate_tls_material,
23 },
24 store::{Key, Store},
25 target::{
26 ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
27 TargetTlsState, TargetType, build_queued_payload_with_records, build_target_tls_fingerprint, is_connectivity_error,
28 open_target_queue_store, persist_queued_payload_to_store, redacted_secret, sanitize_queue_dir_component,
29 with_delivery_deadline,
30 },
31};
32use async_trait::async_trait;
33use parking_lot::Mutex;
37use pulsar::{Authentication, Producer, Pulsar, TokioExecutor};
38use rustfs_tls_runtime::load_cert_bundle_der_bytes;
39use std::fmt;
40use std::path::Path;
41use std::sync::Arc;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::time::Duration;
44use tokio::sync::Mutex as AsyncMutex;
45use tracing::{info, instrument, warn};
46use url::Url;
47use uuid::Uuid;
48
49const PULSAR_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
50const PULSAR_FAILED_DELIVERY_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1);
51
52#[derive(Clone)]
53pub struct PulsarArgs {
54 pub enable: bool,
55 pub broker: String,
56 pub topic: String,
57 pub auth_token: String,
58 pub username: String,
59 pub password: String,
60 pub tls_ca: String,
61 pub tls_allow_insecure: bool,
62 pub tls_hostname_verification: bool,
63 pub queue_dir: String,
64 pub queue_limit: u64,
65 pub target_type: TargetType,
66}
67
68impl fmt::Debug for PulsarArgs {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_struct("PulsarArgs")
71 .field("enable", &self.enable)
72 .field("broker", &self.broker)
73 .field("topic", &self.topic)
74 .field("auth_token", &redacted_secret(&self.auth_token))
75 .field("username", &self.username)
76 .field("password", &redacted_secret(&self.password))
77 .field("tls_ca", &self.tls_ca)
78 .field("tls_allow_insecure", &self.tls_allow_insecure)
79 .field("tls_hostname_verification", &self.tls_hostname_verification)
80 .field("queue_dir", &self.queue_dir)
81 .field("queue_limit", &self.queue_limit)
82 .field("target_type", &self.target_type)
83 .finish()
84 }
85}
86
87impl PulsarArgs {
88 pub fn validate(&self) -> Result<(), TargetError> {
89 if !self.enable {
90 return Ok(());
91 }
92
93 validate_pulsar_broker(&self.broker)?;
94
95 if self.topic.trim().is_empty() {
96 return Err(TargetError::Configuration("Pulsar topic cannot be empty".to_string()));
97 }
98
99 if !self.auth_token.is_empty() && (!self.username.is_empty() || !self.password.is_empty()) {
100 return Err(TargetError::Configuration(
101 "Pulsar supports either auth_token or username/password auth, not both".to_string(),
102 ));
103 }
104
105 if self.username.is_empty() != self.password.is_empty() {
106 return Err(TargetError::Configuration(
107 "Pulsar username and password must be specified together".to_string(),
108 ));
109 }
110
111 if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
112 return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string()));
113 }
114
115 if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
116 return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
117 }
118
119 let parsed = Url::parse(&self.broker)
120 .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{}')", self.broker)))?;
121 let tls_enabled = parsed.scheme() == "pulsar+ssl";
122 if !tls_enabled && !self.tls_ca.is_empty() {
130 return Err(TargetError::Configuration(
131 "Pulsar tls_ca is only allowed with pulsar+ssl brokers".to_string(),
132 ));
133 }
134
135 Ok(())
136 }
137}
138
139pub fn validate_pulsar_broker(broker: &str) -> Result<Url, TargetError> {
140 let url = Url::parse(broker)
141 .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{broker}')")))?;
142
143 match url.scheme() {
144 "pulsar" | "pulsar+ssl" => {}
145 _ => {
146 return Err(TargetError::Configuration(
147 "Pulsar broker must use pulsar:// or pulsar+ssl://".to_string(),
148 ));
149 }
150 }
151
152 if !url.username().is_empty() || url.password().is_some() {
153 return Err(TargetError::Configuration(
154 "Pulsar broker URL must not embed username or password".to_string(),
155 ));
156 }
157
158 if url.host_str().is_none() {
159 return Err(TargetError::Configuration("Pulsar broker is missing host".to_string()));
160 }
161
162 Ok(url)
163}
164
165fn pulsar_producer_name(target_id: &TargetID, target_type: TargetType, nonce: Uuid) -> String {
166 let target_type = match target_type {
167 TargetType::NotifyEvent => "notify",
168 TargetType::AuditLog => "audit",
169 };
170
171 format!("rustfs-{target_type}-pulsar-{}-{nonce}", sanitize_queue_dir_component(&target_id.id))
172}
173
174pub async fn connect_pulsar(args: &PulsarArgs) -> Result<Pulsar<TokioExecutor>, TargetError> {
175 args.validate()?;
176
177 let mut builder = Pulsar::builder(args.broker.clone(), TokioExecutor);
178
179 if !args.auth_token.is_empty() {
180 builder = builder.with_auth(Authentication {
181 name: "token".to_string(),
182 data: args.auth_token.clone().into_bytes(),
183 });
184 } else if !args.username.is_empty() {
185 builder =
186 builder.with_auth_provider(pulsar::authentication::basic::BasicAuthentication::new(&args.username, &args.password));
187 }
188
189 if !args.tls_ca.is_empty() {
190 let certs = load_cert_bundle_der_bytes(&args.tls_ca)
191 .map_err(|e| TargetError::Configuration(format!("Failed to parse Pulsar tls_ca: {e}")))?;
192 if certs.is_empty() {
193 return Err(TargetError::Configuration(
194 "Pulsar tls_ca did not contain any parsable certificates".to_string(),
195 ));
196 }
197 builder = builder
198 .with_certificate_chain_file(&args.tls_ca)
199 .map_err(|e| TargetError::Configuration(format!("Failed to load Pulsar tls_ca: {e}")))?;
200 }
201
202 builder = builder
203 .with_allow_insecure_connection(args.tls_allow_insecure)
204 .with_tls_hostname_verification_enabled(args.tls_hostname_verification);
205
206 builder
207 .build()
208 .await
209 .map_err(|e| TargetError::Network(format!("Failed to connect to Pulsar broker: {e}")))
210}
211
212pub struct PulsarTarget<E>
213where
214 E: PluginEvent,
215{
216 id: TargetID,
217 args: PulsarArgs,
218 client: Mutex<Option<Pulsar<TokioExecutor>>>,
219 tls_state: Mutex<TargetTlsState>,
220 tls_adapter: Option<TlsReloadAdapter<Pulsar<TokioExecutor>>>,
222 producer: AsyncMutex<Option<Producer<TokioExecutor>>>,
223 store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
224 connected: AtomicBool,
225 delivery_counters: Arc<TargetDeliveryCounters>,
226 _phantom: std::marker::PhantomData<E>,
227}
228
229impl<E> PulsarTarget<E>
230where
231 E: PluginEvent,
232{
233 pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
234 Box::new(PulsarTarget::<E> {
235 id: self.id.clone(),
236 args: self.args.clone(),
237 client: Mutex::new(self.client.lock().clone()),
238 tls_state: Mutex::new(self.tls_state.lock().clone()),
239 tls_adapter: self.tls_adapter.clone(),
240 producer: AsyncMutex::new(None),
241 store: self.store.as_ref().map(|s| s.boxed_clone()),
242 connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)),
243 delivery_counters: Arc::clone(&self.delivery_counters),
244 _phantom: std::marker::PhantomData,
245 })
246 }
247
248 #[instrument(skip(args), fields(target_id_as_string = %id))]
249 pub fn new(id: String, args: PulsarArgs) -> Result<Self, TargetError> {
250 args.validate()?;
251 let target_id = TargetID::new(id, ChannelTargetType::Pulsar.as_str().to_string());
252 let queue_store = open_target_queue_store(
253 &args.queue_dir,
254 args.queue_limit,
255 args.target_type,
256 ChannelTargetType::Pulsar.as_str(),
257 &target_id,
258 "Failed to open store for Pulsar target",
259 )?;
260
261 Ok(Self {
262 id: target_id,
263 args,
264 client: Mutex::new(None),
265 tls_state: Mutex::new(TargetTlsState::default()),
266 tls_adapter: None,
267 producer: AsyncMutex::new(None),
268 store: queue_store,
269 connected: AtomicBool::new(false),
270 delivery_counters: Arc::new(TargetDeliveryCounters::default()),
271 _phantom: std::marker::PhantomData,
272 })
273 }
274
275 fn clear_cached_client_connection(&self) {
276 self.client.lock().take();
277 }
278
279 fn clear_cached_client(&self) {
280 self.clear_cached_client_connection();
281 self.tls_state.lock().reset();
282 }
283
284 async fn clear_failed_delivery_state(&self) {
285 match tokio::time::timeout(PULSAR_FAILED_DELIVERY_CLEANUP_TIMEOUT, self.producer.lock()).await {
286 Ok(mut producer) => {
287 producer.take();
288 }
289 Err(_) => {
290 warn!(
291 target_id = %self.id,
292 reason = "producer_cleanup_lock_timeout",
293 "Timed out clearing the Pulsar producer after a failed delivery"
294 );
295 }
296 }
297 self.clear_cached_client();
298 self.connected.store(false, Ordering::SeqCst);
299 }
300
301 async fn get_or_connect_client(&self) -> Result<Pulsar<TokioExecutor>, TargetError> {
302 if let Some(adapter) = &self.tls_adapter {
305 let material = adapter.current_material();
306 {
307 let mut guard = self.client.lock();
308 *guard = Some((*material).clone());
309 }
310 } else {
311 let next_fingerprint = build_target_tls_fingerprint(&self.args.tls_ca, "", "").await?;
312 let tls_changed = {
313 let tls_state_guard = self.tls_state.lock();
314 tls_state_guard.needs_update(&next_fingerprint)
315 };
316 if tls_changed {
317 self.clear_cached_client_connection();
318 self.tls_state.lock().refresh(next_fingerprint);
319 }
320 }
321
322 if let Some(client) = self.client.lock().clone() {
323 return Ok(client);
324 }
325
326 let client = connect_pulsar(&self.args).await?;
327 self.connected.store(true, Ordering::SeqCst);
328 let mut guard = self.client.lock();
329 let shared = guard.get_or_insert_with(|| client.clone()).clone();
330 Ok(shared)
331 }
332
333 async fn init_producer(&self) -> Result<(), TargetError> {
334 if self.producer.lock().await.is_some() {
335 return Ok(());
336 }
337
338 let client = self.get_or_connect_client().await?;
339 let producer = client
340 .producer()
341 .with_topic(self.args.topic.clone())
342 .with_name(pulsar_producer_name(&self.id, self.args.target_type, Uuid::new_v4()))
343 .build()
344 .await
345 .map_err(|e| TargetError::Network(format!("Failed to create Pulsar producer: {e}")))?;
346
347 let mut guard = self.producer.lock().await;
348 if guard.is_none() {
349 *guard = Some(producer);
350 }
351 Ok(())
352 }
353
354 fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
355 build_queued_payload_with_records(event, vec![event.clone()])
356 }
357
358 async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
359 let result = with_delivery_deadline(PULSAR_DELIVERY_TIMEOUT, "Pulsar delivery", async {
360 self.init_producer().await?;
361 let mut guard = self.producer.lock().await;
362 let producer = guard
363 .as_mut()
364 .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
365 let receipt = producer
366 .send_non_blocking(body)
367 .await
368 .map_err(|e| TargetError::Request(format!("Failed to send Pulsar message: {e}")))?;
369 receipt
370 .await
371 .map_err(|e| TargetError::Request(format!("Failed to receive Pulsar receipt: {e}")))?;
372 self.delivery_counters.record_success();
373 Ok(())
374 })
375 .await;
376
377 if let Err(err) = &result
378 && is_connectivity_error(err)
379 {
380 self.clear_failed_delivery_state().await;
381 }
382 result
383 }
384}
385
386#[async_trait]
392impl<E> ReloadableTargetTls for PulsarTarget<E>
393where
394 E: PluginEvent,
395{
396 type Material = Pulsar<TokioExecutor>;
397
398 fn tls_input_set(&self) -> TargetTlsInputSet {
399 TargetTlsInputSet {
400 ca_path: self.args.tls_ca.clone(),
401 client_cert_path: String::new(),
402 client_key_path: String::new(),
403 target_label: format!("pulsar:{}", self.id.id),
404 }
405 }
406
407 async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
408 connect_pulsar(&self.args).await
409 }
410
411 async fn apply_tls_material(
412 &self,
413 _generation: TargetTlsGeneration,
414 material: Arc<Self::Material>,
415 _mode: ReloadApplyMode,
416 ) -> Result<(), TargetError> {
417 {
419 let mut guard = self.client.lock();
420 *guard = Some((*material).clone());
421 }
422 {
424 let mut producer = self.producer.lock().await;
425 *producer = None;
426 }
427 Ok(())
428 }
429
430 async fn validate_tls_files(&self) -> Result<(), TargetError> {
431 validate_tls_material(&self.args.tls_ca, "", "")
433 }
434}
435
436#[async_trait]
437impl<E> Target<E> for PulsarTarget<E>
438where
439 E: PluginEvent,
440{
441 fn id(&self) -> TargetID {
442 self.id.clone()
443 }
444
445 async fn is_active(&self) -> Result<bool, TargetError> {
446 self.init_producer().await?;
447 let guard = self.producer.lock().await;
448 let producer = guard
449 .as_ref()
450 .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
451 producer
452 .check_connection()
453 .await
454 .map_err(|e| TargetError::Network(format!("Pulsar health check failed: {e}")))?;
455 Ok(true)
456 }
457
458 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
459 let queued = match self.build_queued_payload(&event) {
460 Ok(queued) => queued,
461 Err(err) => {
462 self.delivery_counters.record_final_failure();
463 return Err(err);
464 }
465 };
466
467 if let Some(store) = &self.store {
468 if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
469 self.delivery_counters.record_final_failure();
470 return Err(e);
471 }
472 Ok(())
473 } else {
474 if let Err(err) = self.send_body(queued.body).await {
475 self.delivery_counters.record_final_failure();
476 return Err(err);
477 }
478 Ok(())
479 }
480 }
481
482 async fn send_raw_from_store(&self, _key: Key, body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
483 self.send_body(body).await
484 }
485
486 async fn close(&self) -> Result<(), TargetError> {
487 let mut producer = self.producer.lock().await;
488 if let Some(producer) = producer.as_mut() {
489 producer
490 .close()
491 .await
492 .map_err(|e| TargetError::Network(format!("Failed to close Pulsar producer: {e}")))?;
493 }
494 *producer = None;
495 self.clear_cached_client();
496 self.connected.store(false, Ordering::SeqCst);
497 if let Some(adapter) = &self.tls_adapter {
500 *adapter.runtime_state().last_error.write() = None;
501 }
502 info!(target_id = %self.id, "Pulsar target closed");
503 Ok(())
504 }
505
506 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
507 self.store.as_deref()
508 }
509
510 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
511 self.clone_box()
512 }
513
514 async fn init(&self) -> Result<(), TargetError> {
515 if !self.is_enabled() {
516 return Ok(());
517 }
518 self.init_producer().await
519 }
520
521 fn is_enabled(&self) -> bool {
522 self.args.enable
523 }
524
525 fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
526 self.delivery_counters.snapshot(
527 self.store.as_deref().map_or(0, |store| store.len() as u64),
528 0,
530 )
531 }
532
533 fn record_final_failure(&self) {
534 self.delivery_counters.record_final_failure();
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::target::REDACTED_SECRET;
542
543 fn base_args() -> PulsarArgs {
544 PulsarArgs {
545 enable: true,
546 broker: "pulsar://127.0.0.1:6650".to_string(),
547 topic: "persistent://public/default/rustfs-events".to_string(),
548 auth_token: String::new(),
549 username: String::new(),
550 password: String::new(),
551 tls_ca: String::new(),
552 tls_allow_insecure: false,
553 tls_hostname_verification: true,
554 queue_dir: String::new(),
555 queue_limit: 0,
556 target_type: TargetType::NotifyEvent,
557 }
558 }
559
560 #[tokio::test(start_paused = true)]
561 async fn failed_delivery_cleanup_is_bounded_when_the_producer_lock_is_busy() {
562 let target = Arc::new(PulsarTarget::<String>::new("pulsar:test".to_string(), base_args()).expect("target should build"));
563 target.connected.store(true, Ordering::SeqCst);
564 let producer_guard = target.producer.lock().await;
565 let cleanup = {
566 let target = Arc::clone(&target);
567 tokio::spawn(async move { target.clear_failed_delivery_state().await })
568 };
569
570 cleanup.await.expect("cleanup task should not panic");
571
572 assert!(!target.connected.load(Ordering::SeqCst));
573 drop(producer_guard);
574 }
575
576 #[test]
577 fn debug_redacts_pulsar_secret_fields() {
578 let args = PulsarArgs {
579 auth_token: "pulsar-token".to_string(),
580 password: "pulsar-password".to_string(),
581 ..base_args()
582 };
583
584 let rendered = format!("{args:?}");
585
586 assert!(!rendered.contains("pulsar-token"));
587 assert!(!rendered.contains("pulsar-password"));
588 assert!(rendered.contains(REDACTED_SECRET));
589 assert!(rendered.contains("persistent://public/default/rustfs-events"));
590 }
591
592 #[test]
593 fn validate_pulsar_rejects_mixed_auth_methods() {
594 let args = PulsarArgs {
595 auth_token: "token".to_string(),
596 username: "user".to_string(),
597 password: "pass".to_string(),
598 ..base_args()
599 };
600 assert!(args.validate().is_err());
601 }
602
603 #[test]
604 fn validate_pulsar_rejects_relative_queue_dir() {
605 let args = PulsarArgs {
606 queue_dir: "relative/path".to_string(),
607 ..base_args()
608 };
609 assert!(args.validate().is_err());
610 }
611
612 #[test]
613 fn validate_pulsar_accepts_inert_tls_toggles_on_plaintext_broker() {
614 let args = PulsarArgs {
619 tls_allow_insecure: true,
620 tls_hostname_verification: false,
621 ..base_args()
622 };
623 args.validate().expect("inert TLS toggles must not fail a plaintext broker");
624 }
625
626 #[test]
627 fn validate_pulsar_rejects_tls_ca_on_plaintext_broker() {
628 let args = PulsarArgs {
629 tls_ca: "/etc/ssl/certs/ca.pem".to_string(),
630 ..base_args()
631 };
632 assert!(args.validate().is_err());
633 }
634
635 #[test]
636 fn pulsar_producer_name_keeps_target_context_and_unique_suffix() {
637 let id = TargetID::new("test2".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
638 let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
639
640 let name = pulsar_producer_name(&id, TargetType::NotifyEvent, nonce);
641
642 assert_eq!(name, "rustfs-notify-pulsar-test2-12345678-90ab-cdef-1234-567890abcdef");
643 }
644
645 #[test]
646 fn pulsar_producer_name_includes_target_type() {
647 let id = TargetID::new("audit-target".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
648 let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
649
650 let name = pulsar_producer_name(&id, TargetType::AuditLog, nonce);
651
652 assert_eq!(name, "rustfs-audit-pulsar-audit-target-12345678-90ab-cdef-1234-567890abcdef");
653 }
654
655 #[test]
656 fn pulsar_producer_name_sanitizes_target_id() {
657 let raw_target_id = "tenant:alpha/beta";
658 let id = TargetID::new(raw_target_id.to_string(), ChannelTargetType::Pulsar.as_str().to_string());
659 let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
660
661 let name = pulsar_producer_name(&id, TargetType::NotifyEvent, nonce);
662
663 assert!(!name.contains(':'));
664 assert!(!name.contains('/'));
665 assert_eq!(
666 name,
667 format!(
668 "rustfs-notify-pulsar-{}-12345678-90ab-cdef-1234-567890abcdef",
669 sanitize_queue_dir_component(raw_target_id)
670 )
671 );
672 }
673
674 #[test]
675 fn pulsar_producer_name_changes_for_each_producer_generation() {
676 let id = TargetID::new("test".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
677 let first = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
678 let second = Uuid::from_u128(0xfedcba09_8765_4321_fedc_ba0987654321);
679
680 assert_ne!(
681 pulsar_producer_name(&id, TargetType::NotifyEvent, first),
682 pulsar_producer_name(&id, TargetType::NotifyEvent, second)
683 );
684 }
685
686 #[test]
687 fn init_producer_uses_generated_unique_producer_name_contract() {
688 fn without_ascii_whitespace(value: &str) -> String {
689 let mut compacted = String::with_capacity(value.len());
690 compacted.extend(value.bytes().filter(|byte| !byte.is_ascii_whitespace()).map(char::from));
691 compacted
692 }
693
694 let source = without_ascii_whitespace(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/target/pulsar.rs")));
695 let expected_name_call = [
696 ".with_name(",
697 "pulsar_producer_name(&self.id, self.args.target_type, Uuid::new_v4())",
698 ")",
699 ]
700 .concat();
701 let forbidden_target_id_call = [".with_name(", "self.id.id.clone()", ")"].concat();
702
703 assert!(source.contains(&without_ascii_whitespace(&expected_name_call)));
704 assert!(!source.contains(&without_ascii_whitespace(&forbidden_target_id_call)));
705 }
706}