Files
rustfs/crates/targets/src/target/redis.rs
T
cxymds 1655f3192e fix(notify): unify runtime lifecycle coordination (#5088)
* fix(notify): unify runtime lifecycle coordination

* fix(notify): repair lifecycle convergence checks

* fix(admin): expose effective notify state (#5097)
2026-07-22 05:01:15 +00:00

1424 lines
53 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::plugin::PluginEvent;
use crate::{
StoreError, Target,
arn::TargetID,
error::TargetError,
runtime::tls::{
ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
validate_tls_material,
},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, build_queued_payload, invalidate_cache_on_connectivity_error, is_connectivity_error,
mark_target_disconnected_on_connectivity_error, open_target_queue_store, persist_queued_payload_to_store,
with_delivery_deadline,
},
};
use async_trait::async_trait;
use redis::{
AsyncCommands, Client, ClientTlsConfig, ConnectionInfo, IntoConnectionInfo, RedisError, TlsCertificates,
aio::{ConnectionManager, ConnectionManagerConfig},
cmd,
io::tcp::{TcpSettings, socket2},
};
use rustfs_config::{REDIS_TLS_CA, REDIS_TLS_CLIENT_CERT, REDIS_TLS_CLIENT_KEY, REDIS_TLS_POLICY};
use rustls::pki_types::CertificateDer;
use rustls::pki_types::pem::PemObject;
use std::fmt;
use std::io::BufReader;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{debug, info, instrument, warn};
use url::Url;
const REDIS_CONNECTION_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5);
const REDIS_RESPONSE_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5);
fn redis_total_delivery_timeout(args: &RedisArgs) -> Duration {
let attempts = u32::try_from(args.max_retry_attempts).unwrap_or(u32::MAX);
let per_attempt = args
.connection_timeout
.unwrap_or(REDIS_CONNECTION_TIMEOUT_DEFAULT)
.saturating_add(args.response_timeout.unwrap_or(REDIS_RESPONSE_TIMEOUT_DEFAULT))
.saturating_add(args.max_retry_delay.unwrap_or(Duration::from_secs(2)));
per_attempt.saturating_mul(attempts)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedisTlsPolicy {
SystemCa,
CustomCa,
}
impl RedisTlsPolicy {
fn parse(value: &str) -> Result<Self, TargetError> {
match value.trim() {
value if value.eq_ignore_ascii_case("system_ca") => Ok(Self::SystemCa),
value if value.eq_ignore_ascii_case("custom_ca") => Ok(Self::CustomCa),
_ => Err(TargetError::Configuration(
"Redis tls_policy must be one of: system_ca, custom_ca".to_string(),
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RedisTlsConfig {
pub policy: Option<RedisTlsPolicy>,
pub ca_path: String,
pub client_cert_path: String,
pub client_key_path: String,
pub allow_insecure: bool,
}
impl RedisTlsConfig {
pub fn from_values(
policy: Option<&str>,
ca_path: Option<&str>,
client_cert_path: Option<&str>,
client_key_path: Option<&str>,
allow_insecure: Option<&str>,
) -> Result<Self, TargetError> {
let policy = match policy.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => Some(RedisTlsPolicy::parse(value)?),
None => None,
};
let allow_insecure = allow_insecure
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
value
.parse::<rustfs_config::EnableState>()
.map(rustfs_config::EnableState::is_enabled)
.or_else(|_| value.parse::<bool>())
.map_err(|_| TargetError::Configuration("Redis tls_allow_insecure must be a boolean value".to_string()))
})
.transpose()?
.unwrap_or(false);
Ok(Self {
policy,
ca_path: ca_path.unwrap_or_default().trim().to_string(),
client_cert_path: client_cert_path.unwrap_or_default().trim().to_string(),
client_key_path: client_key_path.unwrap_or_default().trim().to_string(),
allow_insecure,
})
}
}
#[derive(Clone)]
pub struct RedisArgs {
/// Whether the target is enabled
pub enable: bool,
/// The Redis server URL in format: `{redis|rediss|valkey|valkeys}://[<username>][:<password>@]<hostname>[:port][/<db>]`
pub url: Url,
/// The Redis pub/sub channel to publish to
pub channel: String,
/// The username for the Redis connection (leave it empty if you parse with url)
pub username: Option<String>,
/// The password for the Redis connection (leave it empty if you parse with url)
pub password: Option<String>,
/// TLS configuration
pub tls: RedisTlsConfig,
/// The keep alive interval
pub keep_alive: Duration,
/// The directory to store events in case of failure
pub queue_dir: String,
/// The maximum number of events to store
pub queue_limit: u64,
/// Maximum number of synchronous publish retries per payload
pub max_retry_attempts: usize,
/// Maximum number of reconnect retries in the underlying connection manager (6 if not provided)
pub reconnect_retry_attempts: Option<usize>,
/// Minimum retry delay between publish retry attempts (100ms if not provided)
pub min_retry_delay: Option<Duration>,
/// Maximum retry delay between publish retry attempts (2s if not provided)
pub max_retry_delay: Option<Duration>,
/// Timeout for establishing a Redis connection (5s if not provided)
pub connection_timeout: Option<Duration>,
/// Timeout for command responses (5s if not provided)
pub response_timeout: Option<Duration>,
/// Internal command buffer size for the multiplexed connection (50 if not provided)
pub pipeline_buffer_size: Option<usize>,
/// the target type
pub target_type: TargetType,
}
fn redact_redis_url(url: &Url) -> String {
let mut redacted = url.clone();
if redacted.password().is_some() {
let _ = redacted.set_password(Some("***"));
}
redacted.to_string()
}
impl fmt::Debug for RedisArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RedisArgs")
.field("enable", &self.enable)
.field("url", &redact_redis_url(&self.url))
.field("channel", &self.channel)
.field("username", &self.username)
.field(
"password",
if self.password.as_deref().unwrap_or_default().is_empty() {
&""
} else {
&"***REDACTED***"
},
)
.field("tls", &self.tls)
.field("keep_alive", &self.keep_alive)
.field("queue_dir", &self.queue_dir)
.field("queue_limit", &self.queue_limit)
.field("max_retry_attempts", &self.max_retry_attempts)
.field("reconnect_retry_attempts", &self.reconnect_retry_attempts)
.field("min_retry_delay", &self.min_retry_delay)
.field("max_retry_delay", &self.max_retry_delay)
.field("connection_timeout", &self.connection_timeout)
.field("response_timeout", &self.response_timeout)
.field("pipeline_buffer_size", &self.pipeline_buffer_size)
.field("target_type", &self.target_type)
.finish()
}
}
impl RedisArgs {
pub fn validate(&self) -> Result<(), TargetError> {
if !self.enable {
return Ok(());
}
validate_redis_url(&self.url)?;
validate_redis_tls_config(&self.url, &self.tls)?;
if self.channel.trim().is_empty() {
return Err(TargetError::Configuration("Redis channel cannot be empty".to_string()));
}
if self.username.as_deref().unwrap_or_default().is_empty() != self.password.as_deref().unwrap_or_default().is_empty()
&& !(self.username.is_none() && self.password.is_none())
{
return Err(TargetError::Configuration(
"Redis username and password must be specified together when provided explicitly".to_string(),
));
}
if self.max_retry_attempts == 0 {
return Err(TargetError::Configuration(
"Redis max_retry_attempts must be greater than zero".to_string(),
));
}
if self.connection_timeout == Some(Duration::ZERO) {
return Err(TargetError::Configuration(
"Redis connection_timeout must be greater than zero".to_string(),
));
}
if self.response_timeout == Some(Duration::ZERO) {
return Err(TargetError::Configuration("Redis response_timeout must be greater than zero".to_string()));
}
if self.pipeline_buffer_size == Some(0) {
return Err(TargetError::Configuration(
"Redis pipeline_buffer_size must be greater than zero".to_string(),
));
}
if let (Some(min_retry_delay), Some(max_retry_delay)) = (self.min_retry_delay, self.max_retry_delay)
&& max_retry_delay < min_retry_delay
{
return Err(TargetError::Configuration(
"Redis max_retry_delay must be greater than or equal to min_retry_delay".to_string(),
));
}
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
return Err(TargetError::Configuration("Redis queue_dir path should be absolute".to_string()));
}
Ok(())
}
}
pub fn validate_redis_url(url: &Url) -> Result<(), TargetError> {
let _: ConnectionInfo = url.clone().into_connection_info().map_err(map_redis_error)?;
Ok(())
}
fn validate_redis_tls_config(url: &Url, tls: &RedisTlsConfig) -> Result<(), TargetError> {
let secure_scheme = matches!(url.scheme(), "rediss" | "valkeys");
if !tls.client_cert_path.is_empty() && !Path::new(&tls.client_cert_path).is_absolute() {
return Err(TargetError::Configuration(format!("{REDIS_TLS_CLIENT_CERT} must be an absolute path")));
}
if !tls.client_key_path.is_empty() && !Path::new(&tls.client_key_path).is_absolute() {
return Err(TargetError::Configuration(format!("{REDIS_TLS_CLIENT_KEY} must be an absolute path")));
}
if tls.client_cert_path.is_empty() != tls.client_key_path.is_empty() {
return Err(TargetError::Configuration(
"Redis tls_client_cert and tls_client_key must be specified together".to_string(),
));
}
if !secure_scheme {
if tls.policy.is_some()
|| !tls.ca_path.is_empty()
|| !tls.client_cert_path.is_empty()
|| !tls.client_key_path.is_empty()
|| tls.allow_insecure
{
return Err(TargetError::Configuration(
"TLS settings are only allowed for rediss/valkeys schemes".to_string(),
));
}
return Ok(());
}
if let Some(policy) = tls.policy {
match policy {
RedisTlsPolicy::SystemCa => {
if !tls.ca_path.is_empty() {
return Err(TargetError::Configuration(format!(
"{REDIS_TLS_CA} is not allowed when {REDIS_TLS_POLICY}=system_ca"
)));
}
}
RedisTlsPolicy::CustomCa => {
if tls.ca_path.is_empty() {
return Err(TargetError::Configuration(format!(
"{REDIS_TLS_CA} is required when {REDIS_TLS_POLICY}=custom_ca"
)));
}
if !Path::new(&tls.ca_path).is_absolute() {
return Err(TargetError::Configuration(format!("{REDIS_TLS_CA} must be an absolute path")));
}
}
}
} else if !tls.ca_path.is_empty() && !Path::new(&tls.ca_path).is_absolute() {
return Err(TargetError::Configuration(format!("{REDIS_TLS_CA} must be an absolute path")));
}
Ok(())
}
pub struct RedisTarget<E>
where
E: PluginEvent,
{
id: TargetID,
args: RedisArgs,
/// Redis client, wrapped in a lock so TLS hot-reload can atomically replace it.
publisher_client: Arc<parking_lot::Mutex<Client>>,
publisher: Arc<Mutex<Option<ConnectionManager>>>,
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
/// Business-level liveness flag.
///
/// We only flip this to `false` on final/terminal failure paths (for example: init failed,
/// publish exhausted retries, or the target was explicitly closed). Temporary reconnectable
/// errors only invalidate the cached publisher so that a later request can lazily rebuild it.
connected: Arc<AtomicBool>,
/// TLS fingerprint tracking for hot reload (inline fallback path).
tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
/// Adapter that bridges this target to the TLS reload coordinator.
/// When `Some`, the target uses coordinator-managed material; when `None`,
/// it falls back to inline fingerprint-based change detection.
tls_adapter: Option<TlsReloadAdapter<Client>>,
delivery_counters: Arc<TargetDeliveryCounters>,
_phantom: std::marker::PhantomData<E>,
}
impl<E> RedisTarget<E>
where
E: PluginEvent,
{
#[instrument(skip(args), fields(target_id_as_string = %id))]
pub fn new(id: String, args: RedisArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Redis.as_str().to_string());
let publisher_client = build_redis_client(&args)?;
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Redis.as_str(),
&target_id,
"Failed to open store for Redis target",
)?;
info!(target_id = %target_id, "Redis target created");
Ok(Self {
id: target_id,
args,
publisher_client: Arc::new(parking_lot::Mutex::new(publisher_client)),
publisher: Arc::new(Mutex::new(None)),
store: queue_store,
connected: Arc::new(AtomicBool::new(false)),
tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
tls_adapter: None,
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
_phantom: std::marker::PhantomData,
})
}
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(Self {
id: self.id.clone(),
args: self.args.clone(),
publisher_client: Arc::clone(&self.publisher_client),
publisher: Arc::clone(&self.publisher),
store: self.store.as_ref().map(|s| s.boxed_clone()),
connected: Arc::clone(&self.connected),
tls_state: Arc::clone(&self.tls_state),
tls_adapter: self.tls_adapter.clone(),
delivery_counters: Arc::clone(&self.delivery_counters),
_phantom: std::marker::PhantomData,
})
}
async fn get_or_create_publisher(&self) -> Result<ConnectionManager, TargetError> {
// Adapter-managed path: use the material directly from the TLS reload adapter.
if let Some(adapter) = &self.tls_adapter {
let client: Client = (*adapter.current_material()).clone();
// Ensure the client is also stored locally so close() can drain it.
*self.publisher_client.lock() = client.clone();
let manager = client
.get_connection_manager_lazy(build_redis_connection_manager_config(&self.args))
.map_err(map_redis_error)?;
*self.publisher.lock().await = Some(manager.clone());
return Ok(manager);
}
// Inline fingerprint fallback path (no coordinator).
let secure_scheme = matches!(self.args.url.scheme(), "rediss" | "valkeys");
if secure_scheme {
let next_fingerprint = super::build_target_tls_fingerprint(
&self.args.tls.ca_path,
&self.args.tls.client_cert_path,
&self.args.tls.client_key_path,
)
.await?;
let tls_changed = {
let tls_state_guard = self.tls_state.lock();
tls_state_guard.needs_update(&next_fingerprint)
};
if tls_changed {
let new_client = build_redis_client(&self.args)?;
*self.publisher_client.lock() = new_client;
self.invalidate_cached_publisher().await;
self.tls_state.lock().refresh(next_fingerprint);
}
}
let mut guard = self.publisher.lock().await;
if let Some(manager) = guard.clone() {
return Ok(manager);
}
let client = self.publisher_client.lock().clone();
let manager = client
.get_connection_manager_lazy(build_redis_connection_manager_config(&self.args))
.map_err(map_redis_error)?;
*guard = Some(manager.clone());
Ok(manager)
}
async fn invalidate_cached_publisher(&self) {
// Intentionally does not touch `connected`: invalidating the current manager only means
// "recreate the publisher on the next attempt", not "this target is now definitively
// inactive". That distinction preserves the business semantics of `is_active()`.
*self.publisher.lock().await = None;
}
async fn ensure_publisher_ready(&self) -> Result<(), TargetError> {
let mut publisher = self.get_or_create_publisher().await?;
match cmd("PING").query_async::<String>(&mut publisher).await {
Ok(_) => Ok(()),
Err(err) => {
let mapped = map_redis_error(err);
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
Err(mapped)
}
}
}
async fn init_inner(&self) -> Result<(), TargetError> {
if let Err(err) = self.ensure_publisher_ready().await {
self.connected.store(false, Ordering::SeqCst);
return Err(err);
}
self.connected.store(true, Ordering::SeqCst);
Ok(())
}
#[instrument(skip(self, body, meta), fields(target_id = %self.id))]
async fn send_body(&self, body: Vec<u8>, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
debug!(
target = %self.id,
bucket = %meta.bucket_name,
object = %meta.object_name,
event = %meta.event_name,
payload_len = body.len(),
channel = %self.args.channel,
"Sending Redis payload"
);
let result = with_delivery_deadline(redis_total_delivery_timeout(&self.args), "Redis delivery", async {
let mut attempt = 0usize;
let mut last_error = None;
while attempt < self.args.max_retry_attempts {
attempt += 1;
let connection_timeout = self.args.connection_timeout.unwrap_or(REDIS_CONNECTION_TIMEOUT_DEFAULT);
let mut publisher = match with_delivery_deadline(
connection_timeout,
"Redis connection",
self.get_or_create_publisher(),
)
.await
{
Ok(publisher) => publisher,
Err(err) => {
invalidate_cache_on_connectivity_error(&err, || self.invalidate_cached_publisher()).await;
return Err(err);
}
};
let response_timeout = self.args.response_timeout.unwrap_or(REDIS_RESPONSE_TIMEOUT_DEFAULT);
match with_delivery_deadline(response_timeout, "Redis publish response", async {
publisher
.publish::<_, _, i64>(self.args.channel.as_str(), body.as_slice())
.await
.map_err(map_redis_error)
})
.await
{
Ok(receiver_count) => {
// PUBLISH returns the number of subscribers that received the
// message. Redis pub/sub is best-effort: with zero subscribers
// the event is delivered to no one, yet the durable copy is
// deleted. Warn so operators relying on reliable delivery are
// not silently losing events (backlog#982).
if receiver_count == 0 {
warn!(
target_id = %self.id,
channel = %self.args.channel,
"Redis PUBLISH reached 0 subscribers; the event was not received by any consumer (pub/sub is best-effort)"
);
}
debug!(
target_id = %self.id,
channel = %self.args.channel,
attempt,
receiver_count,
"Event published to Redis channel"
);
self.delivery_counters.record_success();
return Ok(());
}
Err(mapped) => {
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
warn!(
target_id = %self.id,
channel = %self.args.channel,
attempt,
max_attempts = self.args.max_retry_attempts,
error = %mapped,
"Redis publish attempt failed"
);
if !is_connectivity_error(&mapped) || attempt >= self.args.max_retry_attempts {
last_error = Some(mapped);
break;
}
last_error = Some(mapped);
tokio::time::sleep(compute_retry_delay(
attempt,
self.args.min_retry_delay.unwrap_or(Duration::from_millis(100)),
self.args.max_retry_delay.unwrap_or(Duration::from_secs(2)),
))
.await;
}
}
}
Err(last_error.unwrap_or(TargetError::Unknown(
"Redis publish failed without a captured error".to_string(),
)))
})
.await;
if let Err(err) = &result {
invalidate_cache_on_connectivity_error(err, || self.invalidate_cached_publisher()).await;
self.connected.store(false, Ordering::SeqCst);
}
result
}
}
#[async_trait]
impl<E> Target<E> for RedisTarget<E>
where
E: PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
if !self.is_enabled() {
return Ok(false);
}
// Reuse the cached ConnectionManager for the health probe (via
// ensure_publisher_ready) instead of building a brand-new manager — and
// thus a fresh TCP+TLS handshake — on every health check (backlog#982).
// ensure_publisher_ready already invalidates the cached manager on a
// connectivity error so the next attempt rebuilds it.
match tokio::time::timeout(REDIS_CONNECTION_TIMEOUT_DEFAULT, self.ensure_publisher_ready()).await {
Ok(Ok(())) => {
self.connected.store(true, Ordering::SeqCst);
Ok(true)
}
Ok(Err(err)) => {
mark_target_disconnected_on_connectivity_error(&self.connected, &err);
Err(err)
}
Err(_) => {
let timeout_err = TargetError::Timeout("Redis connection timed out".to_string());
invalidate_cache_on_connectivity_error(&timeout_err, || self.invalidate_cached_publisher()).await;
mark_target_disconnected_on_connectivity_error(&self.connected, &timeout_err);
Err(timeout_err)
}
}
}
async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
let queued = match build_queued_payload(event.as_ref()) {
Ok(queued) => queued,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(err);
}
};
if let Some(store) = &self.store {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(e);
}
debug!(target_id = %self.id, "Event saved to store for Redis target");
Ok(())
} else {
if !self.is_enabled() {
return Err(TargetError::Disabled);
}
if let Err(err) = self.init_inner().await {
self.delivery_counters.record_final_failure();
return Err(err);
}
if let Err(err) = self.send_body(queued.body, &queued.meta).await {
self.delivery_counters.record_final_failure();
return Err(err);
}
Ok(())
}
}
async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
debug!(target_id = %self.id, ?key, "Attempting to send queued payload from Redis store");
if !self.is_enabled() {
return Err(TargetError::Disabled);
}
if let Err(err) = self.init_inner().await {
if is_connectivity_error(&err) {
warn!(target_id = %self.id, error = %err, "Redis target not ready; queued event remains in store");
}
return Err(err);
}
if let Err(err) = self.send_body(body, &meta).await {
if is_connectivity_error(&err) {
warn!(target_id = %self.id, error = %err, "Failed to send Redis event from store: target not connected. Event remains queued.");
}
return Err(err);
}
debug!(target_id = %self.id, ?key, "Queued Redis payload sent successfully");
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.invalidate_cached_publisher().await;
self.tls_state.lock().reset();
self.connected.store(false, Ordering::SeqCst);
info!(target_id = %self.id, "Redis target closed");
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
self.store.as_deref()
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
self.clone_box()
}
async fn init(&self) -> Result<(), TargetError> {
if !self.is_enabled() {
return Ok(());
}
self.init_inner().await
}
fn is_enabled(&self) -> bool {
self.args.enable
}
fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
self.delivery_counters.snapshot(
self.store.as_deref().map_or(0, |store| store.len() as u64),
// Redis targets record no terminal failures and keep no failed store.
0,
)
}
fn record_final_failure(&self) {
self.delivery_counters.record_final_failure();
}
}
pub(crate) fn build_redis_client(args: &RedisArgs) -> Result<Client, TargetError> {
let mut url = args.url.clone();
if args.tls.allow_insecure {
url.set_fragment(Some("insecure"));
// Mirror the webhook `skip_tls_verify` warning: this disables Redis
// server certificate verification and must only be used for testing
// (backlog#982).
warn!(
target = %redact_redis_url(&args.url),
"Redis tls_allow_insecure is enabled: server certificate verification is DISABLED (insecure). Use for testing only."
);
}
let mut connection_info: ConnectionInfo = url.into_connection_info().map_err(map_redis_error)?;
let base_redis = connection_info.redis_settings().clone();
let mut redis_settings = base_redis.clone().set_lib_name("rustfs-targets", env!("CARGO_PKG_VERSION"));
if let Some(username) = args.username.as_deref().filter(|value| !value.is_empty()) {
if base_redis.username().is_some_and(|base| base != username) {
warn!(url_username = ?base_redis.username(), arg_username = %username, "Redis target protocol username from URL is being overridden");
}
redis_settings = redis_settings.set_username(username);
}
if let Some(password) = args.password.as_deref().filter(|value| !value.is_empty()) {
if base_redis.password().is_some() {
warn!("RedisArgs.password overrides password from Redis URL");
}
redis_settings = redis_settings.set_password(password);
}
let mut tcp_settings = TcpSettings::default().set_nodelay(true);
#[cfg(not(target_family = "wasm"))]
{
if !args.keep_alive.is_zero() {
tcp_settings = tcp_settings.set_keepalive(socket2::TcpKeepalive::new().with_time(args.keep_alive));
}
}
connection_info = connection_info
.set_redis_settings(redis_settings)
.set_tcp_settings(tcp_settings);
let secure_scheme = matches!(args.url.scheme(), "rediss" | "valkeys");
if secure_scheme {
super::ensure_rustls_provider_installed();
let tls_certs = TlsCertificates {
client_tls: read_client_tls(&args.tls)?,
root_cert: read_root_cert(&args.tls)?,
};
Client::build_with_tls(connection_info, tls_certs).map_err(map_redis_error)
} else {
Client::open(connection_info).map_err(map_redis_error)
}
}
pub(crate) fn build_redis_connection_manager_config(args: &RedisArgs) -> ConnectionManagerConfig {
let mut config = ConnectionManagerConfig::new();
if let Some(reconnect_retry_attempts) = args.reconnect_retry_attempts {
config = config.set_number_of_retries(reconnect_retry_attempts);
}
if let Some(min_retry_delay) = args.min_retry_delay {
config = config.set_min_delay(min_retry_delay);
}
if let Some(max_retry_delay) = args.max_retry_delay {
config = config.set_max_delay(max_retry_delay);
}
if let Some(connection_timeout) = args.connection_timeout {
config = config.set_connection_timeout(Some(connection_timeout));
}
if let Some(response_timeout) = args.response_timeout {
config = config.set_response_timeout(Some(response_timeout));
}
if let Some(pipeline_buffer_size) = args.pipeline_buffer_size {
config = config.set_pipeline_buffer_size(pipeline_buffer_size);
}
config
}
pub(crate) async fn ping_redis_server(client: &Client, args: &RedisArgs) -> Result<(), TargetError> {
let config = build_redis_connection_manager_config(args);
let mut conn = client
.get_connection_manager_with_config(config)
.await
.map_err(map_redis_error)?;
cmd("PING").query_async::<String>(&mut conn).await.map_err(map_redis_error)?;
Ok(())
}
fn read_client_tls(tls: &RedisTlsConfig) -> Result<Option<ClientTlsConfig>, TargetError> {
if tls.client_cert_path.is_empty() {
return Ok(None);
}
let client_cert = std::fs::read(&tls.client_cert_path)
.map_err(|e| TargetError::Configuration(format!("Failed to read Redis client cert: {e}")))?;
let client_key = std::fs::read(&tls.client_key_path)
.map_err(|e| TargetError::Configuration(format!("Failed to read Redis client key: {e}")))?;
Ok(Some(ClientTlsConfig { client_cert, client_key }))
}
fn read_root_cert(tls: &RedisTlsConfig) -> Result<Option<Vec<u8>>, TargetError> {
if tls.ca_path.is_empty() {
return Ok(None);
}
let pem =
std::fs::read(&tls.ca_path).map_err(|e| TargetError::Configuration(format!("Failed to read Redis root CA cert: {e}")))?;
let mut reader = BufReader::new(pem.as_slice());
let certs_der = CertificateDer::pem_reader_iter(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TargetError::Configuration(format!("Failed to parse Redis root CA cert: {e}")))?;
if certs_der.is_empty() {
return Err(TargetError::Configuration(
"Redis root CA cert did not contain any parsable certificates".to_string(),
));
}
Ok(Some(pem))
}
fn map_redis_error(err: RedisError) -> TargetError {
use redis::ErrorKind;
match err.kind() {
ErrorKind::AuthenticationFailed => TargetError::Authentication(err.to_string()),
ErrorKind::RESP3NotSupported => TargetError::Initialization(err.to_string()),
ErrorKind::InvalidClientConfig => TargetError::Configuration(err.to_string()),
ErrorKind::Io if err.is_timeout() => TargetError::Timeout(err.to_string()),
ErrorKind::Io if err.is_connection_dropped() || err.is_connection_refusal() => TargetError::NotConnected,
ErrorKind::Io => TargetError::Network(err.to_string()),
_ if err.is_unrecoverable_error() => TargetError::NotConnected,
_ => TargetError::Request(err.to_string()),
}
}
fn compute_retry_delay(attempt: usize, min_delay: Duration, max_delay: Duration) -> Duration {
let shift = attempt.saturating_sub(1).min(16) as u32;
let factor = 1u32 << shift;
min_delay.saturating_mul(factor).min(max_delay)
}
/// Coordinated TLS hot-reload implementation for Redis targets.
///
/// The coordinator calls these methods on a background poll loop to detect
/// TLS file changes and rebuild the Redis client without restarting.
#[async_trait]
impl<E> ReloadableTargetTls for RedisTarget<E>
where
E: PluginEvent,
{
type Material = Client;
fn tls_input_set(&self) -> TargetTlsInputSet {
TargetTlsInputSet {
ca_path: self.args.tls.ca_path.clone(),
client_cert_path: self.args.tls.client_cert_path.clone(),
client_key_path: self.args.tls.client_key_path.clone(),
target_label: format!("redis:{}", self.id.id),
}
}
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
build_redis_client(&self.args)
}
async fn apply_tls_material(
&self,
_generation: TargetTlsGeneration,
material: Arc<Self::Material>,
_mode: ReloadApplyMode,
) -> Result<(), TargetError> {
*self.publisher_client.lock() = (*material).clone();
self.invalidate_cached_publisher().await;
Ok(())
}
async fn validate_tls_files(&self) -> Result<(), TargetError> {
validate_tls_material(&self.args.tls.ca_path, &self.args.tls.client_cert_path, &self.args.tls.client_key_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use redis::ProtocolVersion;
use std::sync::atomic::Ordering;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn absolute_test_path(path: &str) -> String {
std::env::temp_dir().join(path).to_string_lossy().into_owned()
}
fn base_args() -> RedisArgs {
RedisArgs {
enable: true,
url: Url::parse("redis://127.0.0.1:6379").unwrap(),
channel: "rustfs-events".to_string(),
username: None,
password: None,
tls: RedisTlsConfig::default(),
keep_alive: Duration::from_secs(15),
queue_dir: String::new(),
queue_limit: 0,
max_retry_attempts: 3,
reconnect_retry_attempts: None,
min_retry_delay: None,
max_retry_delay: None,
connection_timeout: None,
response_timeout: None,
pipeline_buffer_size: None,
target_type: TargetType::NotifyEvent,
}
}
#[test]
fn validate_rejects_empty_channel() {
let args = RedisArgs {
channel: String::new(),
..base_args()
};
assert!(args.validate().is_err());
}
#[test]
fn validate_accepts_embedded_credentials_in_url() {
let url = Url::parse("redis://user:pass@127.0.0.1:6379").unwrap();
assert!(validate_redis_url(&url).is_ok());
}
#[test]
fn validate_rejects_relative_queue_dir() {
let args = RedisArgs {
queue_dir: "relative/path".to_string(),
..base_args()
};
assert!(args.validate().is_err());
}
#[test]
fn validate_rejects_zero_connection_timeout() {
let args = RedisArgs {
connection_timeout: Some(Duration::ZERO),
..base_args()
};
assert!(args.validate().is_err());
}
#[test]
fn validate_rejects_zero_response_timeout() {
let args = RedisArgs {
response_timeout: Some(Duration::ZERO),
..base_args()
};
assert!(args.validate().is_err());
}
#[test]
fn validate_accepts_custom_ca_tls_policy() {
let args = RedisArgs {
url: Url::parse("rediss://127.0.0.1:6379").unwrap(),
tls: RedisTlsConfig {
policy: Some(RedisTlsPolicy::CustomCa),
ca_path: absolute_test_path("redis-ca.pem"),
..RedisTlsConfig::default()
},
..base_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn debug_redacts_passwords_from_url_and_args() {
let args = RedisArgs {
url: Url::parse("redis://user:secret@127.0.0.1:6379/0").unwrap(),
password: Some("override-secret".to_string()),
..base_args()
};
let rendered = format!("{args:?}");
assert!(!rendered.contains("secret"), "url password leaked: {rendered}");
assert!(!rendered.contains("override-secret"), "args password leaked: {rendered}");
assert!(rendered.contains("redis://user:***@127.0.0.1:6379/0"));
assert!(rendered.contains("\"***REDACTED***\""));
}
#[test]
fn validate_rejects_insecure_tls_for_non_secure_scheme() {
let args = RedisArgs {
tls: RedisTlsConfig {
allow_insecure: true,
..RedisTlsConfig::default()
},
..base_args()
};
assert!(args.validate().is_err());
}
#[test]
fn build_redis_client_preserves_url_auth_when_args_are_none() {
let args = RedisArgs {
url: Url::parse("redis://user:pass@127.0.0.1:6379/2").unwrap(),
..base_args()
};
let client = build_redis_client(&args).expect("client should build");
let info = client.get_connection_info();
let redis = info.redis_settings();
assert_eq!(redis.username(), Some("user"));
assert_eq!(redis.password(), Some("pass"));
assert_eq!(redis.db(), 2);
}
#[test]
fn build_redis_client_overrides_url_auth_when_args_are_set() {
let args = RedisArgs {
url: Url::parse("redis://user:pass@127.0.0.1:6379/2").unwrap(),
username: Some("override-user".to_string()),
password: Some("override-pass".to_string()),
..base_args()
};
let client = build_redis_client(&args).expect("client should build");
let redis = client.get_connection_info().redis_settings();
assert_eq!(redis.username(), Some("override-user"));
assert_eq!(redis.password(), Some("override-pass"));
assert_eq!(redis.db(), 2);
}
#[test]
fn build_redis_client_preserves_url_protocol_when_args_do_not_override_it() {
let args = RedisArgs {
url: Url::parse("redis://127.0.0.1:6379/?protocol=resp3").unwrap(),
..base_args()
};
let client = build_redis_client(&args).expect("client should build");
let redis = client.get_connection_info().redis_settings();
assert_eq!(redis.protocol(), ProtocolVersion::RESP3);
}
#[test]
fn build_redis_client_enables_insecure_tls_when_requested() {
let args = RedisArgs {
url: Url::parse("rediss://127.0.0.1:6379").unwrap(),
tls: RedisTlsConfig {
allow_insecure: true,
..RedisTlsConfig::default()
},
..base_args()
};
let client = build_redis_client(&args).expect("client should build");
match client.get_connection_info().addr() {
redis::ConnectionAddr::TcpTls { insecure, .. } => assert!(*insecure),
other => panic!("expected TLS address, got {other:?}"),
}
}
#[tokio::test]
async fn invalidate_cached_publisher_keeps_connected_state() {
let target = RedisTarget::<String>::new("redis:test".to_string(), base_args()).expect("target should build");
target.connected.store(true, Ordering::SeqCst);
target.invalidate_cached_publisher().await;
assert!(target.connected.load(Ordering::SeqCst));
assert!(target.publisher.lock().await.is_none());
}
#[tokio::test]
async fn is_active_succeeds_when_ping_returns_pong() {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("bind fake redis: {err}"),
};
let addr = listener.local_addr().expect("listener addr");
tokio::spawn(run_fake_redis_server(listener, false));
let mut args = base_args();
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
target.connected.store(false, Ordering::SeqCst);
assert!(target.is_active().await.expect("ping should succeed"));
assert!(target.connected.load(Ordering::SeqCst));
}
#[tokio::test]
async fn is_active_returns_error_when_ping_fails() {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("bind fake redis: {err}"),
};
let addr = listener.local_addr().expect("listener addr");
tokio::spawn(async move {
loop {
let Ok((socket, _)) = listener.accept().await else {
return;
};
drop(socket);
}
});
let mut args = base_args();
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
target.connected.store(true, Ordering::SeqCst);
let err = target.is_active().await.expect_err("ping should fail");
assert!(matches!(
err,
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
));
assert!(!target.connected.load(Ordering::SeqCst));
assert!(target.publisher.lock().await.is_none());
}
#[tokio::test]
async fn is_active_returns_false_when_disabled() {
let target = RedisTarget::<String>::new(
"redis:test".to_string(),
RedisArgs {
enable: false,
..base_args()
},
)
.expect("target should build");
assert!(!target.is_active().await.expect("disabled target should not probe"));
}
#[test]
fn compute_retry_delay_is_bounded() {
let min = Duration::from_millis(100);
let max = Duration::from_secs(2);
assert_eq!(compute_retry_delay(1, min, max), min);
assert!(compute_retry_delay(5, min, max) <= max);
assert_eq!(compute_retry_delay(50, min, max), max);
}
#[test]
fn queued_payload_uses_event_data_in_records() {
let payload = build_queued_payload(&EntityTarget {
object_name: "greeting+file+%282%29.csv".to_string(),
bucket_name: "bucket".to_string(),
event_name: rustfs_s3_types::EventName::ObjectCreatedPut,
data: "payload-data".to_string(),
})
.expect("payload should build");
let value: serde_json::Value = serde_json::from_slice(&payload.body).expect("payload JSON");
assert_eq!(value["Key"], "bucket/greeting file (2).csv");
assert_eq!(value["Records"][0], "payload-data");
}
fn parse_resp_array(input: &[u8]) -> Option<(Vec<String>, usize)> {
if input.first()? != &b'*' {
return None;
}
let mut index = 1;
let len_end = input[index..].windows(2).position(|w| w == b"\r\n")? + index;
let items: usize = std::str::from_utf8(&input[index..len_end]).ok()?.parse().ok()?;
index = len_end + 2;
let mut out = Vec::with_capacity(items);
for _ in 0..items {
if input.get(index)? != &b'$' {
return None;
}
index += 1;
let bulk_end = input[index..].windows(2).position(|w| w == b"\r\n")? + index;
let bulk_len: usize = std::str::from_utf8(&input[index..bulk_end]).ok()?.parse().ok()?;
index = bulk_end + 2;
let data_end = index.checked_add(bulk_len)?;
let data = std::str::from_utf8(input.get(index..data_end)?).ok()?.to_string();
out.push(data);
index = data_end + 2;
}
Some((out, index))
}
async fn run_fake_redis_server(listener: TcpListener, close_first_connection: bool) {
let mut first = close_first_connection;
loop {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
if first {
first = false;
drop(socket);
continue;
}
tokio::spawn(async move {
let mut buf = vec![0_u8; 4096];
let mut pending = Vec::new();
loop {
let Ok(read) = socket.read(&mut buf).await else {
return;
};
if read == 0 {
return;
}
pending.extend_from_slice(&buf[..read]);
while let Some((command, consumed)) = parse_resp_array(&pending) {
pending.drain(..consumed);
let response = match command.first().map(|s| s.as_str()) {
Some("PING") => b"+PONG\r\n".as_slice(),
Some("PUBLISH") => b":1\r\n".as_slice(),
Some("CLIENT") => b"+OK\r\n".as_slice(),
Some("AUTH") => b"+OK\r\n".as_slice(),
Some("SELECT") => b"+OK\r\n".as_slice(),
Some("HELLO") => b"%1\r\n+server\r\n+redis\r\n".as_slice(),
_ => b"+OK\r\n".as_slice(),
};
if socket.write_all(response).await.is_err() {
return;
}
}
}
});
}
}
#[tokio::test]
async fn send_body_keeps_connected_true_when_retryable_error_eventually_recovers() {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("bind fake redis: {err}"),
};
let addr = listener.local_addr().expect("listener addr");
tokio::spawn(run_fake_redis_server(listener, true));
let mut args = base_args();
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
args.max_retry_attempts = 3;
args.reconnect_retry_attempts = Some(0);
args.min_retry_delay = Some(Duration::from_millis(10));
args.max_retry_delay = Some(Duration::from_millis(20));
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
let meta = QueuedPayloadMeta::new(
rustfs_s3_types::EventName::ObjectCreatedPut,
"bucket".to_string(),
"object".to_string(),
"application/json",
2,
);
target.connected.store(true, Ordering::SeqCst);
target
.send_body(b"{}".to_vec(), &meta)
.await
.expect("eventual retry should succeed");
assert!(target.connected.load(Ordering::SeqCst));
assert_eq!(target.delivery_snapshot().total_messages, 1);
}
#[tokio::test(start_paused = true)]
async fn delivery_budget_respects_response_timeout_longer_than_sixty_seconds() {
let mut args = base_args();
args.max_retry_attempts = 1;
args.response_timeout = Some(Duration::from_secs(90));
let manager_config = build_redis_connection_manager_config(&args);
assert_eq!(manager_config.response_timeout(), Some(Duration::from_secs(90)));
assert_eq!(redis_total_delivery_timeout(&args), Duration::from_secs(97));
with_delivery_deadline(redis_total_delivery_timeout(&args), "Redis delivery", async {
tokio::time::sleep(Duration::from_secs(70)).await;
Ok::<_, TargetError>(())
})
.await
.expect("the configured delivery budget must not impose a fixed sixty-second cap");
}
#[tokio::test]
async fn send_body_sets_connected_false_after_retry_exhaustion() {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("bind fake redis: {err}"),
};
let addr = listener.local_addr().expect("listener addr");
tokio::spawn(async move {
loop {
let Ok((socket, _)) = listener.accept().await else {
return;
};
drop(socket);
}
});
let mut args = base_args();
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
args.max_retry_attempts = 2;
args.reconnect_retry_attempts = Some(0);
args.min_retry_delay = Some(Duration::from_millis(10));
args.max_retry_delay = Some(Duration::from_millis(20));
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
let meta = QueuedPayloadMeta::new(
rustfs_s3_types::EventName::ObjectCreatedPut,
"bucket".to_string(),
"object".to_string(),
"application/json",
2,
);
let err = target
.send_body(b"{}".to_vec(), &meta)
.await
.expect_err("all retries should fail");
assert!(matches!(
err,
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
));
assert!(!target.connected.load(Ordering::SeqCst));
assert_eq!(target.delivery_snapshot().total_messages, 0);
}
#[tokio::test]
async fn send_raw_from_store_failure_does_not_count_as_success() {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("bind fake redis: {err}"),
};
let addr = listener.local_addr().expect("listener addr");
tokio::spawn(async move {
loop {
let Ok((socket, _)) = listener.accept().await else {
return;
};
drop(socket);
}
});
let mut args = base_args();
args.url = Url::parse(&format!("redis://{}:{}/0", addr.ip(), addr.port())).unwrap();
args.max_retry_attempts = 1;
args.reconnect_retry_attempts = Some(0);
let target = RedisTarget::<String>::new("redis:test".to_string(), args).expect("target should build");
let meta = QueuedPayloadMeta::new(
rustfs_s3_types::EventName::ObjectCreatedPut,
"bucket".to_string(),
"object".to_string(),
"application/json",
2,
);
let err = target
.send_raw_from_store(
Key {
name: "key".to_string(),
extension: String::new(),
item_count: 1,
compress: false,
},
b"{}".to_vec(),
meta,
)
.await
.expect_err("send from store should fail");
assert!(matches!(
err,
TargetError::NotConnected | TargetError::Network(_) | TargetError::Timeout(_)
));
assert_eq!(target.delivery_snapshot().total_messages, 0);
}
}