1use crate::plugin::PluginEvent;
29use crate::{
30 StoreError, Target,
31 arn::TargetID,
32 error::TargetError,
33 runtime::tls::{
34 ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
35 validate_tls_material,
36 },
37 store::{Key, Store},
38 target::{
39 ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
40 TargetType, build_queued_payload, open_target_queue_store, persist_queued_payload_to_store, redacted_optional_secret,
41 redacted_secret, with_delivery_deadline,
42 },
43};
44use async_trait::async_trait;
45use deadpool_postgres::{Client as PooledClient, Manager, ManagerConfig, Pool, RecyclingMethod, Runtime, Timeouts};
46use rustfs_config::{POSTGRES_DSN_STRING, POSTGRES_TLS_CA, POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY};
47use rustfs_s3_types::EventName;
48use rustfs_tls_runtime::{load_certs, load_private_key};
49use std::fmt;
50use std::path::Path;
51use std::sync::Arc;
52use std::time::Duration;
53use tokio_postgres::Config;
54use tokio_postgres_rustls::MakeRustlsConnect;
55use tracing::{info, instrument, warn};
56use url::Url;
57use uuid::Uuid;
58
59const TARGET_LOG_KEY_FIELD: &str = "Key";
60
61const POSTGRES_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
64const POSTGRES_POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
66const POSTGRES_POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
68const POSTGRES_POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
70const POSTGRES_POOL_CHECKOUT_HARD_LIMIT: Duration = Duration::from_secs(20);
73const POSTGRES_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
75
76fn is_object_removed_event(event: &EventName) -> bool {
81 event.as_str().starts_with("s3:ObjectRemoved")
82}
83
84async fn checkout_client(pool: &Pool, context: &str) -> Result<PooledClient, TargetError> {
91 match tokio::time::timeout(POSTGRES_POOL_CHECKOUT_HARD_LIMIT, pool.get()).await {
92 Ok(Ok(client)) => Ok(client),
93 Ok(Err(e)) => Err(map_pool_error(e, context)),
94 Err(_) => Err(TargetError::Timeout(format!(
95 "{context}: pool checkout exceeded {}s hard limit",
96 POSTGRES_POOL_CHECKOUT_HARD_LIMIT.as_secs()
97 ))),
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum PostgresFormat {
107 Namespace,
108 Access,
109}
110
111impl PostgresFormat {
112 pub fn as_str(&self) -> &'static str {
113 match self {
114 PostgresFormat::Namespace => "namespace",
115 PostgresFormat::Access => "access",
116 }
117 }
118}
119
120pub fn parse_postgres_format(value: Option<&str>) -> Result<PostgresFormat, TargetError> {
125 let raw = value.unwrap_or("").trim();
126 if raw.is_empty() {
127 return Ok(PostgresFormat::Namespace);
128 }
129 match raw.to_ascii_lowercase().as_str() {
130 "namespace" => Ok(PostgresFormat::Namespace),
131 "access" => Ok(PostgresFormat::Access),
132 other => Err(TargetError::Configuration(format!(
133 "PostgreSQL format must be 'namespace' or 'access', got: {other}"
134 ))),
135 }
136}
137
138#[derive(Clone, PartialEq, Eq)]
140pub struct PostgresDsn {
141 pub host: String,
142 pub port: u16,
143 pub user: String,
144 pub password: Option<String>,
145 pub database: String,
146 pub schema: String,
147}
148
149impl fmt::Debug for PostgresDsn {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 f.debug_struct("PostgresDsn")
152 .field("host", &self.host)
153 .field("port", &self.port)
154 .field("user", &self.user)
155 .field("password", &redacted_optional_secret(self.password.as_deref()))
156 .field("database", &self.database)
157 .field("schema", &self.schema)
158 .finish()
159 }
160}
161
162impl PostgresDsn {
163 pub fn parse(dsn_string: &str) -> Result<Self, TargetError> {
168 let input = dsn_string.trim();
169 if input.is_empty() {
170 return Err(TargetError::Configuration(format!("PostgreSQL {POSTGRES_DSN_STRING} cannot be empty")));
171 }
172
173 let url = Url::parse(input).map_err(|e| TargetError::Configuration(format!("invalid PostgreSQL dsn_string: {e}")))?;
174 let scheme = url.scheme().to_ascii_lowercase();
175 if scheme != "postgres" && scheme != "postgresql" {
176 return Err(TargetError::Configuration(
177 "invalid PostgreSQL dsn_string: URL scheme must be postgres or postgresql".to_string(),
178 ));
179 }
180
181 if url.host_str().is_none() {
182 return Err(TargetError::Configuration(
183 "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
184 ));
185 }
186
187 let user = url.username().trim();
188 if user.is_empty() {
189 return Err(TargetError::Configuration(
190 "invalid PostgreSQL dsn_string: user cannot be empty".to_string(),
191 ));
192 }
193
194 let host = url.host_str().unwrap_or_default().trim();
195 if host.is_empty() {
196 return Err(TargetError::Configuration(
197 "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
198 ));
199 }
200 let port = url.port().unwrap_or(5432);
201
202 let database = url.path().trim_start_matches('/').trim();
203 if database.is_empty() {
204 return Err(TargetError::Configuration(
205 "invalid PostgreSQL dsn_string: database cannot be empty".to_string(),
206 ));
207 }
208
209 let mut schema = "public".to_string();
210 for (key, value) in url.query_pairs() {
211 if !key.eq_ignore_ascii_case("search_path") {
212 return Err(TargetError::Configuration(format!(
213 "invalid PostgreSQL dsn_string: unsupported query parameter '{key}'"
214 )));
215 }
216 let value = value.trim();
217 if value.is_empty() {
218 return Err(TargetError::Configuration(
219 "invalid PostgreSQL dsn_string: search_path cannot be empty".to_string(),
220 ));
221 }
222 let first_schema = value
223 .split(',')
224 .next()
225 .map(str::trim)
226 .filter(|segment| !segment.is_empty())
227 .ok_or_else(|| {
228 TargetError::Configuration(
229 "invalid PostgreSQL dsn_string: search_path must contain at least one schema".to_string(),
230 )
231 })?;
232 validate_pg_identifier(first_schema, "schema")?;
233 schema = first_schema.to_string();
234 }
235
236 Ok(PostgresDsn {
237 host: host.to_string(),
238 port,
239 user: user.to_string(),
240 password: url.password().map(ToOwned::to_owned),
241 database: database.to_string(),
242 schema,
243 })
244 }
245}
246
247pub(crate) fn redact_postgres_dsn(dsn_string: &str) -> String {
250 let input = dsn_string.trim();
251 if input.is_empty() {
252 return String::new();
253 }
254
255 let mut url = match Url::parse(input) {
256 Ok(url) => url,
257 Err(_) => return "***".to_string(),
258 };
259
260 let scheme = url.scheme().to_ascii_lowercase();
261 if scheme != "postgres" && scheme != "postgresql" {
262 return "***".to_string();
263 }
264
265 if url.password().is_some() {
266 let _ = url.set_password(Some("***"));
267 }
268
269 let mut query_pairs: Vec<(String, String)> = Vec::new();
270 let mut has_password_param = false;
271 for (key, value) in url.query_pairs() {
272 if key.eq_ignore_ascii_case("password") {
273 has_password_param = true;
274 query_pairs.push((key.into_owned(), "***".to_string()));
275 } else {
276 query_pairs.push((key.into_owned(), value.into_owned()));
277 }
278 }
279 if has_password_param {
280 url.set_query(None);
281 let mut serializer = url.query_pairs_mut();
282 for (key, value) in query_pairs {
283 serializer.append_pair(&key, &value);
284 }
285 }
286
287 url.to_string()
288}
289
290pub fn validate_pg_identifier(name: &str, kind: &str) -> Result<(), TargetError> {
296 if name.is_empty() {
297 return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
298 }
299 let mut chars = name.chars();
300 let Some(first) = chars.next() else {
301 return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
302 };
303 if !(first.is_ascii_alphabetic() || first == '_') {
304 return Err(TargetError::Configuration(format!(
305 "PostgreSQL {kind} must start with a letter or underscore"
306 )));
307 }
308 for c in chars {
309 if !(c.is_ascii_alphanumeric() || c == '_') {
310 return Err(TargetError::Configuration(format!(
311 "PostgreSQL {kind} must match ^[A-Za-z_][A-Za-z0-9_]*$"
312 )));
313 }
314 }
315 Ok(())
316}
317
318#[derive(Clone)]
323pub struct PostgresArgs {
324 pub enable: bool,
325
326 pub dsn_string: String,
328
329 pub schema: String,
331 pub table: String,
332 pub format: PostgresFormat,
333
334 pub tls_required: bool,
336 pub tls_ca: String,
337 pub tls_client_cert: String,
338 pub tls_client_key: String,
339
340 pub queue_dir: String,
342 pub queue_limit: u64,
343
344 pub target_type: TargetType,
345}
346
347impl fmt::Debug for PostgresArgs {
348 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349 f.debug_struct("PostgresArgs")
350 .field("enable", &self.enable)
351 .field("dsn_string", &redact_postgres_dsn(&self.dsn_string))
352 .field("schema", &self.schema)
353 .field("table", &self.table)
354 .field("format", &self.format)
355 .field("tls_required", &self.tls_required)
356 .field("tls_ca", &self.tls_ca)
357 .field("tls_client_cert", &self.tls_client_cert)
358 .field("tls_client_key", &redacted_secret(&self.tls_client_key))
359 .field("queue_dir", &self.queue_dir)
360 .field("queue_limit", &self.queue_limit)
361 .field("target_type", &self.target_type)
362 .finish()
363 }
364}
365
366impl PostgresArgs {
367 pub fn validate(&self) -> Result<(), TargetError> {
368 if !self.enable {
369 return Ok(());
370 }
371
372 let parsed = PostgresDsn::parse(&self.dsn_string)?;
373
374 if self.schema.trim().is_empty() {
375 return Err(TargetError::Configuration("PostgreSQL schema cannot be empty".to_string()));
376 }
377 validate_pg_identifier(&self.schema, "schema")?;
378 if self.schema != parsed.schema {
379 return Err(TargetError::Configuration(format!(
380 "PostgreSQL schema must match DSN search_path first schema ('{}')",
381 parsed.schema
382 )));
383 }
384 validate_pg_identifier(&self.table, "table")?;
385
386 if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
388 return Err(TargetError::Configuration(format!(
389 "PostgreSQL {POSTGRES_TLS_CLIENT_CERT} and {POSTGRES_TLS_CLIENT_KEY} must be specified together"
390 )));
391 }
392
393 if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
395 return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CA} must be an absolute path")));
396 }
397 if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
398 return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_CERT} must be an absolute path")));
399 }
400 if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
401 return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_KEY} must be an absolute path")));
402 }
403
404 if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
405 return Err(TargetError::Configuration(
406 "PostgreSQL queue directory must be an absolute path".to_string(),
407 ));
408 }
409
410 Ok(())
411 }
412}
413
414pub fn qualified_table(schema: &str, table: &str) -> String {
421 format!(r#""{schema}"."{table}""#)
422}
423
424pub fn namespace_upsert_sql(schema: &str, table: &str) -> String {
426 format!(
427 "INSERT INTO {} (key, value) VALUES ($1, $2::jsonb) \
428 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
429 qualified_table(schema, table)
430 )
431}
432
433pub fn namespace_delete_sql(schema: &str, table: &str) -> String {
437 format!("DELETE FROM {} WHERE key = $1", qualified_table(schema, table))
438}
439
440pub fn access_insert_sql(schema: &str, table: &str) -> String {
444 format!(
445 "INSERT INTO {} (event_id, event_name, key, value, queued_at_ms) \
446 VALUES ($1, $2, $3, $4::jsonb, $5) \
447 ON CONFLICT (event_id) DO NOTHING",
448 qualified_table(schema, table)
449 )
450}
451
452pub fn table_probe_sql(schema: &str, table: &str) -> String {
455 format!("SELECT 1 FROM {} LIMIT 0", qualified_table(schema, table))
456}
457
458pub fn build_tls_config(args: &PostgresArgs) -> Result<rustls::ClientConfig, TargetError> {
465 super::ensure_rustls_provider_installed();
466
467 let mut root_store = rustls::RootCertStore::empty();
468
469 if args.tls_ca.is_empty() {
470 let result = rustls_native_certs::load_native_certs();
471 if !result.errors.is_empty() {
472 warn!(error_count = result.errors.len(), "some native CA certs failed to load");
473 }
474 if result.certs.is_empty() {
475 return Err(TargetError::Configuration(
476 "no native CA certs available; specify tls_ca explicitly".to_string(),
477 ));
478 }
479 for cert in result.certs {
480 let _ = root_store.add(cert);
483 }
484 } else {
485 let certs =
486 load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CA}: {e}")))?;
487 for cert in certs {
488 root_store
489 .add(cert)
490 .map_err(|e| TargetError::Configuration(format!("failed to add CA cert: {e}")))?;
491 }
492 }
493
494 let builder = rustls::ClientConfig::builder().with_root_certificates(root_store);
495
496 let client_config = if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
497 let certs = load_certs(&args.tls_client_cert)
498 .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_CERT}: {e}")))?;
499 let key = load_private_key(&args.tls_client_key)
500 .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_KEY}: {e}")))?;
501
502 builder
503 .with_client_auth_cert(certs, key)
504 .map_err(|e| TargetError::Configuration(format!("invalid mTLS pair: {e}")))?
505 } else {
506 builder.with_no_client_auth()
507 };
508
509 Ok(client_config)
510}
511
512pub fn build_pool(args: &PostgresArgs) -> Result<Pool, TargetError> {
517 let parsed = PostgresDsn::parse(&args.dsn_string)?;
518 let mut pg_config = Config::new();
519 pg_config
520 .host(&parsed.host)
521 .port(parsed.port)
522 .user(&parsed.user)
523 .dbname(&parsed.database)
524 .connect_timeout(POSTGRES_CONNECT_TIMEOUT)
527 .options(format!("-c search_path={}", parsed.schema));
528 if let Some(password) = parsed.password.as_deref()
529 && !password.is_empty()
530 {
531 pg_config.password(password);
532 }
533
534 let manager_config = ManagerConfig {
535 recycling_method: RecyclingMethod::Fast,
536 };
537
538 let manager = if args.tls_required {
539 let tls_config = build_tls_config(args)?;
540 let connector = MakeRustlsConnect::new(tls_config);
541 Manager::from_config(pg_config, connector, manager_config)
542 } else {
543 Manager::from_config(pg_config, tokio_postgres::NoTls, manager_config)
544 };
545
546 Pool::builder(manager)
551 .runtime(Runtime::Tokio1)
552 .timeouts(Timeouts {
553 wait: Some(POSTGRES_POOL_WAIT_TIMEOUT),
554 create: Some(POSTGRES_POOL_CREATE_TIMEOUT),
555 recycle: Some(POSTGRES_POOL_RECYCLE_TIMEOUT),
556 })
557 .build()
558 .map_err(|e| TargetError::Configuration(format!("failed to build PostgreSQL pool: {e}")))
559}
560
561fn map_pg_sqlstate(code: &str, detail: &str) -> TargetError {
575 match code.get(..2).unwrap_or("") {
576 "08" => TargetError::NotConnected,
577 "28" => TargetError::Authentication(detail.to_string()),
578 "23" | "42" => TargetError::Configuration(detail.to_string()),
579 "40" => TargetError::Timeout(detail.to_string()),
580 _ => TargetError::Request(detail.to_string()),
581 }
582}
583
584pub fn map_pg_error(err: &tokio_postgres::Error, context: &str) -> TargetError {
593 if err.is_closed() {
594 return TargetError::NotConnected;
595 }
596 if let Some(db_err) = err.as_db_error() {
597 let detail = format!("{context}: {db_err}");
598 return map_pg_sqlstate(db_err.code().code(), &detail);
599 }
600 TargetError::NotConnected
601}
602
603pub fn map_pool_error(err: deadpool_postgres::PoolError, context: &str) -> TargetError {
605 match err {
606 deadpool_postgres::PoolError::Timeout(_) => TargetError::Timeout(format!("{context}: pool timeout")),
607 deadpool_postgres::PoolError::Backend(pg_err) => map_pg_error(&pg_err, context),
608 deadpool_postgres::PoolError::Closed => TargetError::NotConnected,
609 other => TargetError::Request(format!("{context}: {other}")),
610 }
611}
612
613fn resolve_payload_key(payload: &serde_json::Value, meta: &QueuedPayloadMeta) -> String {
614 payload
615 .get(TARGET_LOG_KEY_FIELD)
616 .and_then(serde_json::Value::as_str)
617 .map(ToOwned::to_owned)
618 .unwrap_or_else(|| {
619 let decoded_object =
620 crate::target::decode_object_name(&meta.object_name).unwrap_or_else(|_| meta.object_name.clone());
621 format!("{}/{}", meta.bucket_name, decoded_object)
622 })
623}
624
625pub struct PostgresTarget<E>
637where
638 E: PluginEvent,
639{
640 id: TargetID,
641 args: PostgresArgs,
642 pool: Arc<parking_lot::Mutex<Pool>>,
643 tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
644 tls_adapter: Option<TlsReloadAdapter<Pool>>,
647 namespace_sql: String,
648 namespace_delete_sql: String,
649 access_sql: String,
650 store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
651 delivery_counters: Arc<TargetDeliveryCounters>,
652 _phantom: std::marker::PhantomData<E>,
653}
654
655impl<E> PostgresTarget<E>
656where
657 E: PluginEvent,
658{
659 pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
660 Box::new(PostgresTarget::<E> {
661 id: self.id.clone(),
662 args: self.args.clone(),
663 pool: Arc::clone(&self.pool),
664 tls_state: Arc::clone(&self.tls_state),
665 tls_adapter: self.tls_adapter.clone(),
666 namespace_sql: self.namespace_sql.clone(),
667 namespace_delete_sql: self.namespace_delete_sql.clone(),
668 access_sql: self.access_sql.clone(),
669 store: self.store.as_ref().map(|s| s.boxed_clone()),
670 delivery_counters: Arc::clone(&self.delivery_counters),
671 _phantom: std::marker::PhantomData,
672 })
673 }
674
675 #[instrument(skip(args), fields(target_id_as_string = %id))]
676 pub fn new(id: String, args: PostgresArgs) -> Result<Self, TargetError> {
677 args.validate()?;
678 let target_id = TargetID::new(id, ChannelTargetType::Postgres.as_str().to_string());
679 let pool = build_pool(&args)?;
680
681 let queue_store = open_target_queue_store(
682 &args.queue_dir,
683 args.queue_limit,
684 args.target_type,
685 ChannelTargetType::Postgres.as_str(),
686 &target_id,
687 "Failed to open store for PostgreSQL target",
688 )?;
689
690 Ok(Self {
691 id: target_id,
692 namespace_sql: namespace_upsert_sql(&args.schema, &args.table),
693 namespace_delete_sql: namespace_delete_sql(&args.schema, &args.table),
694 access_sql: access_insert_sql(&args.schema, &args.table),
695 args,
696 pool: Arc::new(parking_lot::Mutex::new(pool)),
697 tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
698 tls_adapter: None,
699 store: queue_store,
700 delivery_counters: Arc::new(TargetDeliveryCounters::default()),
701 _phantom: std::marker::PhantomData,
702 })
703 }
704
705 async fn send_body(&self, body: &[u8], event_id: &str, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
710 if self.tls_adapter.is_none() {
713 let next_fingerprint =
714 super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
715 .await?;
716 let tls_changed = {
717 let tls_state_guard = self.tls_state.lock();
718 tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint)
719 };
720 if tls_changed {
721 let new_pool = build_pool(&self.args)?;
722 *self.pool.lock() = new_pool;
723 self.tls_state.lock().refresh(next_fingerprint);
724 }
725 }
726
727 let pool = self.pool.lock().clone();
728 let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
729
730 let payload: serde_json::Value =
731 serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse JSON payload: {e}")))?;
732
733 let key = resolve_payload_key(&payload, meta);
734
735 with_delivery_deadline(POSTGRES_DELIVERY_TIMEOUT, "PostgreSQL delivery", async {
736 match self.args.format {
737 PostgresFormat::Namespace if is_object_removed_event(&meta.event_name) => {
741 client.execute(&self.namespace_delete_sql, &[&key]).await
742 }
743 PostgresFormat::Namespace => client.execute(&self.namespace_sql, &[&key, &payload]).await,
744 PostgresFormat::Access => {
745 let event_name_str = meta.event_name.to_string();
746 let queued_at_ms = meta.queued_at_unix_ms as i64;
747 client
748 .execute(&self.access_sql, &[&event_id, &event_name_str, &key, &payload, &queued_at_ms])
749 .await
750 }
751 }
752 .map_err(|err| map_pg_error(&err, "PostgreSQL insert failed"))
753 })
754 .await?;
755
756 self.delivery_counters.record_success();
757 Ok(())
758 }
759
760 async fn probe_table(&self) -> Result<(), TargetError> {
763 let pool = self.pool.lock().clone();
764 let client = checkout_client(&pool, "PostgreSQL pool checkout failed during init probe").await?;
765 let sql = table_probe_sql(&self.args.schema, &self.args.table);
766 client
767 .execute(sql.as_str(), &[])
768 .await
769 .map_err(|e| map_pg_error(&e, "PostgreSQL table probe failed"))?;
770 Ok(())
771 }
772}
773
774#[async_trait]
775impl<E> ReloadableTargetTls for PostgresTarget<E>
776where
777 E: PluginEvent,
778{
779 type Material = Pool;
780
781 fn tls_input_set(&self) -> TargetTlsInputSet {
782 TargetTlsInputSet {
783 ca_path: self.args.tls_ca.clone(),
784 client_cert_path: self.args.tls_client_cert.clone(),
785 client_key_path: self.args.tls_client_key.clone(),
786 target_label: format!("postgres:{}", self.id.id),
787 }
788 }
789
790 async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
791 build_pool(&self.args)
792 }
793
794 async fn apply_tls_material(
795 &self,
796 _generation: TargetTlsGeneration,
797 material: Arc<Self::Material>,
798 _mode: ReloadApplyMode,
799 ) -> Result<(), TargetError> {
800 *self.pool.lock() = (*material).clone();
801 Ok(())
802 }
803
804 async fn validate_tls_files(&self) -> Result<(), TargetError> {
805 validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
806 }
807}
808
809#[async_trait]
810impl<E> Target<E> for PostgresTarget<E>
811where
812 E: PluginEvent,
813{
814 fn id(&self) -> TargetID {
815 self.id.clone()
816 }
817
818 async fn is_active(&self) -> Result<bool, TargetError> {
819 if !self.is_enabled() {
820 return Ok(false);
821 }
822
823 match tokio::time::timeout(Duration::from_secs(10), async {
824 let pool = self.pool.lock().clone();
825 let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
826 client
827 .execute("SELECT 1", &[])
828 .await
829 .map_err(|e| map_pg_error(&e, "PostgreSQL liveness probe failed"))?;
830 Ok::<(), TargetError>(())
831 })
832 .await
833 {
834 Ok(Ok(())) => Ok(true),
835 Ok(Err(err)) => Err(err),
836 Err(_) => Err(TargetError::Timeout("PostgreSQL liveness probe timed out after 10s".to_string())),
837 }
838 }
839
840 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
841 let queued = match build_queued_payload(event.as_ref()) {
842 Ok(queued) => queued,
843 Err(err) => {
844 self.delivery_counters.record_final_failure();
845 return Err(err);
846 }
847 };
848
849 if let Some(store) = &self.store {
850 if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
851 self.delivery_counters.record_final_failure();
852 return Err(e);
853 }
854 Ok(())
855 } else {
856 let event_id = Uuid::new_v4().to_string();
859 if let Err(err) = self.send_body(&queued.body, &event_id, &queued.meta).await {
860 self.delivery_counters.record_final_failure();
861 return Err(err);
862 }
863 Ok(())
864 }
865 }
866
867 async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
868 let event_id = key.to_string();
871 self.send_body(&body, &event_id, &meta).await
872 }
873
874 async fn close(&self) -> Result<(), TargetError> {
875 self.pool.lock().close();
876 info!(target_id = %self.id, "PostgreSQL target closed");
878 Ok(())
879 }
880
881 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
882 self.store.as_deref()
883 }
884
885 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
886 self.clone_box()
887 }
888
889 async fn init(&self) -> Result<(), TargetError> {
890 if !self.is_enabled() {
891 return Ok(());
892 }
893 match self.probe_table().await {
894 Ok(()) => Ok(()),
895 Err(err) if self.store.is_some() => {
896 warn!(target_id = %self.id, error = %err, "PostgreSQL init probe failed; events will buffer in store");
897 Ok(())
898 }
899 Err(err) => Err(err),
900 }
901 }
902
903 fn is_enabled(&self) -> bool {
904 self.args.enable
905 }
906
907 fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
908 self.delivery_counters.snapshot(
909 self.store.as_deref().map_or(0, |store| store.len() as u64),
910 0,
912 )
913 }
914
915 fn record_final_failure(&self) {
916 self.delivery_counters.record_final_failure();
917 }
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923 use crate::target::REDACTED_SECRET;
924
925 fn base_args() -> PostgresArgs {
926 PostgresArgs {
927 enable: true,
928 dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
929 schema: "public".to_string(),
930 table: "rustfs_events_namespace".to_string(),
931 format: PostgresFormat::Namespace,
932 tls_required: false,
933 tls_ca: String::new(),
934 tls_client_cert: String::new(),
935 tls_client_key: String::new(),
936 queue_dir: String::new(),
937 queue_limit: 100_000,
938 target_type: TargetType::NotifyEvent,
939 }
940 }
941
942 #[test]
943 fn validate_disabled_skips_all_checks() {
944 let args = PostgresArgs {
945 enable: false,
946 dsn_string: String::new(),
947 schema: String::new(),
948 table: String::new(),
949 ..base_args()
950 };
951 assert!(args.validate().is_ok());
952 }
953
954 #[test]
955 fn validate_accepts_base_args() {
956 assert!(base_args().validate().is_ok());
957 }
958
959 #[tokio::test]
960 async fn is_active_returns_false_when_disabled() {
961 let target = PostgresTarget::<String>::new(
962 "postgres:test".to_string(),
963 PostgresArgs {
964 enable: false,
965 dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
966 ..base_args()
967 },
968 )
969 .expect("disabled target should still construct");
970
971 assert!(!target.is_active().await.expect("disabled target should not probe"));
972 }
973
974 #[test]
975 fn validate_rejects_empty_dsn_string() {
976 let args = PostgresArgs {
977 dsn_string: String::new(),
978 ..base_args()
979 };
980 let err = args.validate().expect_err("empty dsn string should fail");
981 assert!(err.to_string().contains("dsn_string cannot be empty"));
982 }
983
984 #[test]
985 fn validate_rejects_invalid_dsn_string() {
986 let args = PostgresArgs {
987 dsn_string: "postgres://".to_string(),
988 ..base_args()
989 };
990 let err = args.validate().expect_err("invalid dsn should fail");
991 assert!(err.to_string().contains("invalid PostgreSQL dsn_string"));
992 }
993
994 #[test]
995 fn validate_rejects_invalid_schema_identifier() {
996 let args = PostgresArgs {
997 schema: "public; DROP TABLE".to_string(),
998 ..base_args()
999 };
1000 let err = args.validate().expect_err("invalid schema should fail");
1001 assert!(err.to_string().contains("schema"));
1002 }
1003
1004 #[test]
1005 fn validate_rejects_invalid_table_identifier() {
1006 let args = PostgresArgs {
1007 table: "events;".to_string(),
1008 ..base_args()
1009 };
1010 let err = args.validate().expect_err("invalid table should fail");
1011 assert!(err.to_string().contains("table"));
1012 }
1013
1014 #[test]
1015 fn validate_rejects_table_starting_with_digit() {
1016 let args = PostgresArgs {
1017 table: "1events".to_string(),
1018 ..base_args()
1019 };
1020 let err = args.validate().expect_err("digit-leading table should fail");
1021 assert!(err.to_string().contains("table"));
1022 }
1023
1024 #[test]
1025 fn validate_rejects_mtls_without_key() {
1026 let args = PostgresArgs {
1027 tls_client_cert: "/etc/ssl/client.pem".to_string(),
1028 tls_client_key: String::new(),
1029 ..base_args()
1030 };
1031 let err = args.validate().expect_err("missing key should fail");
1032 assert!(err.to_string().contains("must be specified together"));
1033 }
1034
1035 #[test]
1036 fn validate_rejects_relative_queue_dir() {
1037 let args = PostgresArgs {
1038 queue_dir: "relative/path".to_string(),
1039 ..base_args()
1040 };
1041 let err = args.validate().expect_err("relative queue_dir should fail");
1042 assert!(err.to_string().contains("absolute path"));
1043 }
1044
1045 #[test]
1046 fn validate_rejects_relative_tls_ca() {
1047 let args = PostgresArgs {
1048 tls_ca: "ca.pem".to_string(),
1049 ..base_args()
1050 };
1051 let err = args.validate().expect_err("relative tls_ca should fail");
1052 assert!(err.to_string().contains("absolute path"));
1053 }
1054
1055 #[test]
1056 fn parse_format_defaults_to_namespace() {
1057 assert_eq!(parse_postgres_format(None).expect("ok"), PostgresFormat::Namespace);
1058 assert_eq!(parse_postgres_format(Some("")).expect("ok"), PostgresFormat::Namespace);
1059 assert_eq!(parse_postgres_format(Some(" ")).expect("ok"), PostgresFormat::Namespace);
1060 }
1061
1062 #[test]
1063 fn parse_format_accepts_variants() {
1064 assert_eq!(parse_postgres_format(Some("namespace")).expect("ok"), PostgresFormat::Namespace);
1065 assert_eq!(parse_postgres_format(Some("ACCESS")).expect("ok"), PostgresFormat::Access);
1066 assert_eq!(parse_postgres_format(Some("Access")).expect("ok"), PostgresFormat::Access);
1067 }
1068
1069 #[test]
1070 fn parse_format_rejects_unknown() {
1071 let err = parse_postgres_format(Some("structured")).expect_err("unknown format should fail");
1072 assert!(err.to_string().contains("must be 'namespace' or 'access'"));
1073 }
1074
1075 #[test]
1076 fn parse_dsn_extracts_search_path_schema() {
1077 let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit,public")
1078 .expect("dsn should parse");
1079 assert_eq!(parsed.host, "localhost");
1080 assert_eq!(parsed.port, 5432);
1081 assert_eq!(parsed.user, "postgres");
1082 assert_eq!(parsed.password.as_deref(), Some("secret"));
1083 assert_eq!(parsed.database, "rustfs_events");
1084 assert_eq!(parsed.schema, "audit");
1085 }
1086
1087 #[test]
1088 fn parse_dsn_defaults_schema_to_public() {
1089 let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events").expect("dsn should parse");
1090 assert_eq!(parsed.schema, "public");
1091 }
1092
1093 #[test]
1094 fn parse_dsn_rejects_invalid_scheme() {
1095 let err = PostgresDsn::parse("mysql://user:pass@localhost:5432/db").expect_err("scheme should fail");
1096 assert!(err.to_string().contains("scheme must be postgres or postgresql"));
1097 }
1098
1099 #[test]
1100 fn parse_dsn_rejects_invalid_search_path_identifier() {
1101 let err = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public;drop")
1102 .expect_err("invalid search_path should fail");
1103 assert!(err.to_string().contains("schema"));
1104 }
1105
1106 #[test]
1107 fn validate_rejects_schema_mismatch_with_dsn_search_path() {
1108 let args = PostgresArgs {
1109 schema: "public".to_string(),
1110 dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit".to_string(),
1111 ..base_args()
1112 };
1113 let err = args.validate().expect_err("schema mismatch should fail");
1114 assert!(err.to_string().contains("schema must match DSN search_path"));
1115 }
1116
1117 #[test]
1118 fn debug_masks_password() {
1119 let args = base_args();
1120 let rendered = format!("{args:?}");
1121 assert!(!rendered.contains("secret"), "password leaked: {rendered}");
1122 assert!(rendered.contains("postgres:***@"));
1123 }
1124
1125 #[test]
1126 fn debug_masks_password_when_empty_shows_blank() {
1127 let args = PostgresArgs {
1128 dsn_string: "postgres://postgres@localhost:5432/rustfs_events?search_path=public".to_string(),
1129 ..base_args()
1130 };
1131 let rendered = format!("{args:?}");
1132 assert!(!rendered.contains(":***@"));
1133 }
1134
1135 #[test]
1136 fn debug_redacts_postgres_dsn_password() {
1137 let dsn = PostgresDsn::parse("postgres://postgres:pg-secret@localhost:5432/rustfs_events?search_path=public")
1138 .expect("valid DSN");
1139
1140 let rendered = format!("{dsn:?}");
1141
1142 assert!(!rendered.contains("pg-secret"));
1143 assert!(rendered.contains(REDACTED_SECRET));
1144 assert!(rendered.contains("rustfs_events"));
1145 }
1146
1147 #[test]
1148 fn redact_postgres_dsn_masks_password_query_parameter() {
1149 let redacted = redact_postgres_dsn("postgres://postgres@localhost:5432/db?search_path=public&password=secret");
1150 assert!(!redacted.contains("secret"));
1151 assert!(redacted.contains("password=%2A%2A%2A") || redacted.contains("password=***"));
1152 }
1153
1154 #[test]
1155 fn qualified_table_double_quotes_both_parts() {
1156 assert_eq!(qualified_table("public", "events"), r#""public"."events""#);
1157 assert_eq!(qualified_table("audit", "rustfs_events"), r#""audit"."rustfs_events""#);
1158 }
1159
1160 #[test]
1161 fn namespace_upsert_uses_on_conflict_update() {
1162 let sql = namespace_upsert_sql("public", "events");
1163 assert!(sql.contains("ON CONFLICT (key) DO UPDATE"));
1164 assert!(sql.contains(r#""public"."events""#));
1165 assert!(sql.contains("$2::jsonb"));
1166 }
1167
1168 #[test]
1169 fn access_insert_uses_event_id_pk_with_on_conflict_do_nothing() {
1170 let sql = access_insert_sql("public", "events_access");
1171 assert!(sql.contains("event_id"));
1172 assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
1173 assert!(sql.contains(r#""public"."events_access""#));
1174 assert!(sql.contains("$4::jsonb"));
1175 }
1176
1177 #[test]
1178 fn namespace_delete_targets_row_by_key() {
1179 let sql = namespace_delete_sql("public", "events");
1180 assert!(sql.starts_with("DELETE FROM"));
1181 assert!(sql.contains(r#""public"."events""#));
1182 assert!(sql.contains("WHERE key = $1"));
1183 }
1184
1185 #[test]
1186 fn is_object_removed_event_matches_all_removed_variants() {
1187 assert!(is_object_removed_event(&EventName::ObjectRemovedDelete));
1188 assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteMarkerCreated));
1189 assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteAllVersions));
1190 assert!(is_object_removed_event(&EventName::ObjectRemovedAll));
1191 assert!(!is_object_removed_event(&EventName::ObjectCreatedPut));
1192 assert!(!is_object_removed_event(&EventName::ObjectAccessedGet));
1193 }
1194
1195 #[test]
1196 fn map_pg_sqlstate_classifies_transaction_rollback_as_transient() {
1197 assert!(matches!(map_pg_sqlstate("40001", "ctx: serialization"), TargetError::Timeout(_)));
1200 assert!(matches!(map_pg_sqlstate("40P01", "ctx: deadlock"), TargetError::Timeout(_)));
1201 assert!(matches!(map_pg_sqlstate("40000", "ctx: rollback"), TargetError::Timeout(_)));
1202 }
1203
1204 #[test]
1205 fn map_pg_sqlstate_classifies_connection_and_permanent_errors() {
1206 assert!(matches!(map_pg_sqlstate("08006", "ctx"), TargetError::NotConnected));
1207 assert!(matches!(map_pg_sqlstate("08001", "ctx"), TargetError::NotConnected));
1208 assert!(matches!(map_pg_sqlstate("28P01", "ctx: auth"), TargetError::Authentication(_)));
1209 assert!(matches!(map_pg_sqlstate("23505", "ctx: unique"), TargetError::Configuration(_)));
1210 assert!(matches!(map_pg_sqlstate("42P01", "ctx: undefined_table"), TargetError::Configuration(_)));
1211 assert!(matches!(map_pg_sqlstate("22001", "ctx: data"), TargetError::Request(_)));
1213 assert!(matches!(map_pg_sqlstate("", "ctx: empty"), TargetError::Request(_)));
1214 }
1215
1216 #[test]
1217 fn transient_pg_errors_are_connectivity_errors() {
1218 assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40001", "ctx")));
1221 assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40P01", "ctx")));
1222 }
1223
1224 #[test]
1225 fn table_probe_does_not_select_rows() {
1226 let sql = table_probe_sql("public", "events");
1227 assert!(sql.contains("LIMIT 0"));
1228 assert!(sql.contains(r#""public"."events""#));
1229 }
1230
1231 #[test]
1232 fn validate_pg_identifier_accepts_alphanumerics() {
1233 assert!(validate_pg_identifier("events", "table").is_ok());
1234 assert!(validate_pg_identifier("rustfs_events_v2", "table").is_ok());
1235 assert!(validate_pg_identifier("_underscored", "table").is_ok());
1236 }
1237
1238 #[test]
1239 fn validate_pg_identifier_rejects_dot_and_quote() {
1240 assert!(validate_pg_identifier("public.events", "table").is_err());
1241 assert!(validate_pg_identifier("events\"DROP", "table").is_err());
1242 assert!(validate_pg_identifier("a b", "table").is_err());
1243 }
1244
1245 #[test]
1246 fn resolve_payload_key_prefers_serialized_key_field() {
1247 let payload = serde_json::json!({
1248 "EventName": "s3:ObjectCreated:Put",
1249 "Key": "bucket-a/folder/object.txt",
1250 "Records": []
1251 });
1252 let meta = QueuedPayloadMeta::new(
1253 rustfs_s3_types::EventName::ObjectCreatedPut,
1254 "bucket-a".to_string(),
1255 "fallback%2Fvalue.txt".to_string(),
1256 "application/json",
1257 0,
1258 );
1259
1260 assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/folder/object.txt");
1261 }
1262
1263 #[test]
1264 fn resolve_payload_key_falls_back_to_decoded_meta_key() {
1265 let payload = serde_json::json!({
1266 "EventName": "s3:ObjectCreated:Put",
1267 "Records": []
1268 });
1269 let meta = QueuedPayloadMeta::new(
1270 rustfs_s3_types::EventName::ObjectCreatedPut,
1271 "bucket-a".to_string(),
1272 "hello+world%2Ftest.txt".to_string(),
1273 "application/json",
1274 0,
1275 );
1276
1277 assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/hello world/test.txt");
1278 }
1279}