mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 23:26:53 +00:00
1368 lines
51 KiB
Rust
1368 lines
51 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::{
|
||
StoreError, Target,
|
||
arn::TargetID,
|
||
error::TargetError,
|
||
runtime::tls::{
|
||
ReloadableTargetTls, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode, fingerprint::TargetTlsGeneration,
|
||
validate::validate_tls_material,
|
||
},
|
||
store::{Key, Store},
|
||
target::{
|
||
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
|
||
TargetType, build_queued_payload, delete_stored_payload, is_connectivity_error, open_target_queue_store,
|
||
persist_queued_payload_to_store, redacted_secret,
|
||
},
|
||
};
|
||
use async_trait::async_trait;
|
||
use mysql_async::{Conn, Opts, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts, prelude::Queryable};
|
||
use rustfs_config::{MYSQL_TLS_CA, MYSQL_TLS_CLIENT_CERT, MYSQL_TLS_CLIENT_KEY};
|
||
use rustfs_tls_runtime::{load_certs, load_private_key};
|
||
use serde::Serialize;
|
||
use serde::de::DeserializeOwned;
|
||
use std::fmt;
|
||
use std::marker::PhantomData;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use tokio::sync::Mutex;
|
||
use tracing::{debug, error, info, warn};
|
||
|
||
/// Arguments for configuring a MySQL notification target.
|
||
///
|
||
/// Contains all configuration values needed to connect to a MySQL/TiDB
|
||
/// database and write event notification records.
|
||
#[derive(Clone)]
|
||
pub struct MySqlArgs {
|
||
/// Whether the target is enabled
|
||
pub enable: bool,
|
||
/// MySQL data source name in format: `<user>:<password>@tcp(<host>:<port>)/<database>`
|
||
pub dsn_string: String,
|
||
/// Target table name, accepts `identifier` or `database.identifier`
|
||
pub table: String,
|
||
/// Write format (currently only `access` is supported)
|
||
pub format: String,
|
||
/// Optional custom CA certificate file for TLS server verification
|
||
pub tls_ca: String,
|
||
/// Optional client certificate chain file for mutual TLS
|
||
pub tls_client_cert: String,
|
||
/// Optional client private key file for mutual TLS
|
||
pub tls_client_key: String,
|
||
/// Directory for persistent queue storage; must be an absolute path if non-empty
|
||
pub queue_dir: String,
|
||
/// Maximum number of events stored in the local queue
|
||
pub queue_limit: u64,
|
||
/// Maximum number of open MySQL connections in the pool (0 relies on the underlying library default)
|
||
pub max_open_connections: usize,
|
||
/// The target type (notify or audit)
|
||
pub target_type: TargetType,
|
||
}
|
||
|
||
impl fmt::Debug for MySqlArgs {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("MySqlArgs")
|
||
.field("enable", &self.enable)
|
||
.field("dsn_string", &redact_mysql_dsn(&self.dsn_string))
|
||
.field("table", &self.table)
|
||
.field("format", &self.format)
|
||
.field("tls_ca", &self.tls_ca)
|
||
.field("tls_client_cert", &self.tls_client_cert)
|
||
.field("tls_client_key", &redacted_secret(&self.tls_client_key))
|
||
.field("queue_dir", &self.queue_dir)
|
||
.field("queue_limit", &self.queue_limit)
|
||
.field("max_open_connections", &self.max_open_connections)
|
||
.field("target_type", &self.target_type)
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl MySqlArgs {
|
||
/// Validates the MySQL target configuration.
|
||
pub fn validate(&self) -> Result<(), TargetError> {
|
||
// If the target is disabled, validation is skipped.
|
||
if !self.enable {
|
||
return Ok(());
|
||
}
|
||
|
||
if self.dsn_string.trim().is_empty() {
|
||
return Err(TargetError::Configuration("MySQL dsn_string cannot be empty".to_string()));
|
||
}
|
||
|
||
let _ = MySqlDsn::parse(&self.dsn_string)?;
|
||
|
||
validate_table_name(&self.table)?;
|
||
|
||
if self.format != "access" {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL format '{}' is not supported; only 'access' is available",
|
||
self.format
|
||
)));
|
||
}
|
||
|
||
if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL {MYSQL_TLS_CLIENT_CERT} and {MYSQL_TLS_CLIENT_KEY} must be specified together"
|
||
)));
|
||
}
|
||
if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
|
||
return Err(TargetError::Configuration(format!("{MYSQL_TLS_CA} must be an absolute path")));
|
||
}
|
||
if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
|
||
return Err(TargetError::Configuration(format!("{MYSQL_TLS_CLIENT_CERT} must be an absolute path")));
|
||
}
|
||
if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
|
||
return Err(TargetError::Configuration(format!("{MYSQL_TLS_CLIENT_KEY} must be an absolute path")));
|
||
}
|
||
|
||
if !self.queue_dir.is_empty() {
|
||
let path = Path::new(&self.queue_dir);
|
||
if !path.is_absolute() {
|
||
return Err(TargetError::Configuration("MySQL queue_dir must be an absolute path".to_string()));
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Parsed representation of a MySQL DSN string.
|
||
///
|
||
/// Produced by [`MySqlDsn::parse`] and consumed by the MySQL
|
||
/// target runtime to build connection options.
|
||
#[derive(Clone, PartialEq, Eq)]
|
||
pub struct MySqlDsn {
|
||
/// MySQL user name
|
||
pub user: String,
|
||
/// MySQL password (plaintext, must be redacted before logging)
|
||
pub password: String,
|
||
/// MySQL server hostname or IP address
|
||
pub host: String,
|
||
/// MySQL server TCP port
|
||
pub port: u16,
|
||
/// Target database name
|
||
pub database: String,
|
||
/// Whether TLS is enabled
|
||
pub tls: bool,
|
||
}
|
||
|
||
impl fmt::Debug for MySqlDsn {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("MySqlDsn")
|
||
.field("user", &self.user)
|
||
.field("password", &redacted_secret(&self.password))
|
||
.field("host", &self.host)
|
||
.field("port", &self.port)
|
||
.field("database", &self.database)
|
||
.field("tls", &self.tls)
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl MySqlDsn {
|
||
/// Parses a MySQL DSN string into its components.
|
||
///
|
||
/// Supported formats:
|
||
/// ```text
|
||
/// <user>:<password>@tcp(<host>:<port>)/<database>
|
||
/// mysql://<user>:<password>@tcp(<host>:<port>)/<database>
|
||
/// ```
|
||
///
|
||
/// Only `?tls=true`, `?tls=false`, and bare `?tls` are accepted;
|
||
/// other TLS query parameters (`verify_ca`, etc.) are rejected.
|
||
pub fn parse(dsn_string: &str) -> Result<MySqlDsn, TargetError> {
|
||
let input = dsn_string.trim();
|
||
if input.is_empty() {
|
||
return Err(TargetError::Configuration("MySQL dsn_string cannot be empty".to_string()));
|
||
}
|
||
|
||
let (_, remainder) = split_mysql_scheme(input);
|
||
|
||
let (body, query) = match remainder.split_once('?') {
|
||
Some((b, q)) => (b, Some(q)),
|
||
None => (remainder, None),
|
||
};
|
||
|
||
let mut tls = false;
|
||
if let Some(query) = query {
|
||
for param in query.split('&') {
|
||
let param = param.trim();
|
||
if param.is_empty() {
|
||
continue;
|
||
}
|
||
let (key, value) = param.split_once('=').unwrap_or((param, ""));
|
||
match key.trim().to_ascii_lowercase().as_str() {
|
||
"tls" => {
|
||
let val = value.trim().to_ascii_lowercase();
|
||
if val == "true" || val.is_empty() {
|
||
tls = true;
|
||
} else if val == "false" {
|
||
tls = false;
|
||
} else {
|
||
return Err(TargetError::Configuration(format!(
|
||
"unsupported value '{}' for TLS query parameter; use tls=true",
|
||
val
|
||
)));
|
||
}
|
||
}
|
||
_ => {
|
||
return Err(TargetError::Configuration(format!("unsupported MySQL DSN query parameter '{}'", key)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let Some((credentials, host_part)) = body.split_once('@') else {
|
||
return Err(TargetError::Configuration(
|
||
"MySQL dsn_string must contain user:password@tcp(host:port)/database".to_string(),
|
||
));
|
||
};
|
||
|
||
let Some((user, password)) = credentials.split_once(':') else {
|
||
return Err(TargetError::Configuration("MySQL dsn_string must contain user:password".to_string()));
|
||
};
|
||
|
||
let user = user.trim();
|
||
let password = password.trim();
|
||
|
||
if user.is_empty() {
|
||
return Err(TargetError::Configuration("MySQL dsn_string user is empty".to_string()));
|
||
}
|
||
|
||
let host_part = host_part.trim();
|
||
|
||
let Some(host_part_rest) = host_part.strip_prefix("tcp(") else {
|
||
return Err(TargetError::Configuration("MySQL dsn_string must use tcp(host:port) format".to_string()));
|
||
};
|
||
|
||
let Some((host_port, rest)) = host_part_rest.split_once(')') else {
|
||
return Err(TargetError::Configuration(
|
||
"MySQL dsn_string missing closing ')' after host:port".to_string(),
|
||
));
|
||
};
|
||
|
||
let (host, port_str) = host_port
|
||
.split_once(':')
|
||
.ok_or_else(|| TargetError::Configuration("MySQL dsn_string host:port is required".to_string()))?;
|
||
|
||
let host = host.trim();
|
||
let port_str = port_str.trim();
|
||
|
||
if host.is_empty() {
|
||
return Err(TargetError::Configuration("MySQL dsn_string host is empty".to_string()));
|
||
}
|
||
|
||
let port: u16 = port_str
|
||
.parse()
|
||
.map_err(|_| TargetError::Configuration(format!("MySQL dsn_string port '{}' is not a valid u16", port_str)))?;
|
||
|
||
let database = rest
|
||
.strip_prefix('/')
|
||
.ok_or_else(|| TargetError::Configuration("MySQL dsn_string must include /database after host:port".to_string()))?
|
||
.trim();
|
||
|
||
if database.is_empty() {
|
||
return Err(TargetError::Configuration("MySQL dsn_string database is empty".to_string()));
|
||
}
|
||
|
||
Ok(MySqlDsn {
|
||
user: user.to_string(),
|
||
password: password.to_string(),
|
||
host: host.to_string(),
|
||
port,
|
||
database: database.to_string(),
|
||
tls,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn split_mysql_scheme(input: &str) -> (&str, &str) {
|
||
const MYSQL_SCHEME: &str = "mysql://";
|
||
|
||
match input.get(..MYSQL_SCHEME.len()) {
|
||
Some(prefix) if prefix.eq_ignore_ascii_case(MYSQL_SCHEME) => input.split_at(MYSQL_SCHEME.len()),
|
||
_ => ("", input),
|
||
}
|
||
}
|
||
|
||
/// Returns a redacted version of the DSN string with the password replaced by `***`.
|
||
pub(crate) fn redact_mysql_dsn(dsn_string: &str) -> String {
|
||
let input = dsn_string.trim();
|
||
if input.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
let (prefix, remainder) = split_mysql_scheme(input);
|
||
|
||
match remainder.split_once('@') {
|
||
Some((credentials, host_part)) => match credentials.split_once(':') {
|
||
Some((user, _)) => format!("{}{}:***@{}", prefix, user.trim(), host_part.trim()),
|
||
None => format!("{prefix}***@{host_part}"),
|
||
},
|
||
None => format!("{prefix}***"),
|
||
}
|
||
}
|
||
|
||
fn is_valid_identifier_segment(segment: &str) -> bool {
|
||
if segment.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
let mut chars = segment.chars();
|
||
let Some(first) = chars.next() else {
|
||
return false;
|
||
};
|
||
if !first.is_ascii_alphabetic() && first != '_' {
|
||
return false;
|
||
}
|
||
|
||
for ch in chars {
|
||
if !ch.is_ascii_alphanumeric() && ch != '_' {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
pub(crate) fn validate_table_name(table: &str) -> Result<(), TargetError> {
|
||
let table = table.trim();
|
||
|
||
if table.is_empty() {
|
||
return Err(TargetError::Configuration("MySQL table name is empty".to_string()));
|
||
}
|
||
|
||
if table.contains('.') {
|
||
let parts: Vec<&str> = table.splitn(2, '.').collect();
|
||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL table name '{}' is invalid; use identifier or database.identifier",
|
||
table
|
||
)));
|
||
}
|
||
|
||
if !is_valid_identifier_segment(parts[0]) {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL database name '{}' in '{}' is not a valid identifier",
|
||
parts[0], table
|
||
)));
|
||
}
|
||
|
||
if !is_valid_identifier_segment(parts[1]) {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL table name '{}' in '{}' is not a valid identifier",
|
||
parts[1], table
|
||
)));
|
||
}
|
||
} else if !is_valid_identifier_segment(table) {
|
||
return Err(TargetError::Configuration(format!(
|
||
"MySQL table name '{}' is not a valid identifier",
|
||
table
|
||
)));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn quote_table_name(table: &str) -> Result<String, TargetError> {
|
||
let table = table.trim();
|
||
|
||
if table.contains('.') {
|
||
let parts: Vec<&str> = table.splitn(2, '.').collect();
|
||
Ok(format!("`{}`.`{}`", parts[0].trim(), parts[1].trim()))
|
||
} else {
|
||
Ok(format!("`{}`", table))
|
||
}
|
||
}
|
||
|
||
/// Extracts `event_time` from a serialized event JSON body.
|
||
///
|
||
/// Reads `Records[0].eventTime` from the JSON payload, parses it as an
|
||
/// RFC 3339 timestamp, and returns it formatted as a MySQL DATETIME(6)
|
||
/// string (`YYYY-MM-DD HH:MM:SS.ffffff`).
|
||
///
|
||
/// Returns an error if the field is missing, not a string, or cannot
|
||
/// be parsed; never falls back to the current time.
|
||
pub(crate) fn extract_event_time(body: &[u8]) -> Result<String, TargetError> {
|
||
let value: serde_json::Value =
|
||
serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse event_data JSON: {e}")))?;
|
||
|
||
let event_time = value
|
||
.get("Records")
|
||
.and_then(|r| r.get(0))
|
||
.and_then(|r| r.get("eventTime"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| TargetError::Serialization("event_data is missing Records[0].eventTime".to_string()))?;
|
||
|
||
let dt = chrono::DateTime::parse_from_rfc3339(event_time)
|
||
.map_err(|e| TargetError::Serialization(format!("Failed to parse eventTime '{}': {}", event_time, e)))?;
|
||
|
||
Ok(dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string())
|
||
}
|
||
|
||
async fn validate_existing_schema(conn: &mut Conn, table: &str) -> Result<(), TargetError> {
|
||
let quoted = quote_table_name(table)?;
|
||
let sql = format!("SHOW COLUMNS FROM {quoted}");
|
||
|
||
let columns: Vec<mysql_async::Row> = conn
|
||
.query(sql)
|
||
.await
|
||
.map_err(|e| TargetError::Initialization(format!("Failed to check MySQL table schema: {e}")))?;
|
||
|
||
let mut has_event_time = false;
|
||
let mut has_event_data = false;
|
||
|
||
for row in &columns {
|
||
let field: String = row.get(0).unwrap_or_default();
|
||
let col_type: String = row.get(1).unwrap_or_default();
|
||
let nullable: String = row.get(2).unwrap_or_default();
|
||
|
||
if field == "event_time" {
|
||
has_event_time = true;
|
||
if col_type.to_lowercase() != "datetime(6)" {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table column 'event_time' must be DATETIME(6) to match insert precision".to_string(),
|
||
));
|
||
}
|
||
if nullable.to_lowercase() != "no" {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table column 'event_time' must be NOT NULL".to_string(),
|
||
));
|
||
}
|
||
} else if field == "event_data" {
|
||
has_event_data = true;
|
||
if col_type.to_lowercase() != "json" {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table column 'event_data' must be JSON type".to_string(),
|
||
));
|
||
}
|
||
if nullable.to_lowercase() != "no" {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table column 'event_data' must be NOT NULL".to_string(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
if !has_event_time {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table is missing required column 'event_time'".to_string(),
|
||
));
|
||
}
|
||
if !has_event_data {
|
||
return Err(TargetError::Initialization(
|
||
"MySQL table is missing required column 'event_data'".to_string(),
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// A notification target that writes events to a MySQL/TiDB table.
|
||
///
|
||
/// Each event is appended as a new row with `event_time` and `event_data`
|
||
/// columns. The target supports at-least-once delivery semantics via a
|
||
/// local `QueueStore` that replays events after transient MySQL outages.
|
||
///
|
||
/// # Configuration example using `rc`
|
||
///
|
||
/// ```bash
|
||
/// rc admin config set ALIAS notify_mysql:primary \
|
||
/// enable=on \
|
||
/// dsn_string="rustfs:password@tcp(mysql.example.com:3306)/rustfs_events?tls=true" \
|
||
/// table="rustfs_events" \
|
||
/// tls_ca="/etc/ssl/mysql/ca.pem" \
|
||
/// tls_client_cert="/etc/ssl/mysql/client.pem" \
|
||
/// tls_client_key="/etc/ssl/mysql/client.key" \
|
||
/// queue_dir="/var/lib/rustfs/events" \
|
||
/// queue_limit="100000" \
|
||
/// max_open_connections="2"
|
||
/// ```
|
||
///
|
||
/// # Environment variables
|
||
///
|
||
/// ```bash
|
||
/// RUSTFS_NOTIFY_MYSQL_ENABLE=on
|
||
/// RUSTFS_NOTIFY_MYSQL_DSN_STRING=rustfs:password@tcp(127.0.0.1:3306)/rustfs_events
|
||
/// RUSTFS_NOTIFY_MYSQL_TABLE=rustfs_events
|
||
/// RUSTFS_NOTIFY_MYSQL_TLS_CA=/etc/ssl/mysql/ca.pem
|
||
/// RUSTFS_NOTIFY_MYSQL_TLS_CLIENT_CERT=/etc/ssl/mysql/client.pem
|
||
/// RUSTFS_NOTIFY_MYSQL_TLS_CLIENT_KEY=/etc/ssl/mysql/client.key
|
||
/// RUSTFS_NOTIFY_MYSQL_QUEUE_DIR=/opt/rustfs/events
|
||
/// RUSTFS_NOTIFY_MYSQL_QUEUE_LIMIT=100000
|
||
/// RUSTFS_NOTIFY_MYSQL_MAX_OPEN_CONNECTIONS=2
|
||
/// ```
|
||
pub struct MySqlTarget<E>
|
||
where
|
||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||
{
|
||
/// Unique target identifier (name + type)
|
||
id: TargetID,
|
||
/// Parsed configuration for this MySQL target
|
||
args: MySqlArgs,
|
||
/// Optional persistent queue store for at-least-once delivery
|
||
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
|
||
/// Lazily-initialized MySQL connection pool
|
||
pool: Arc<Mutex<Option<Pool>>>,
|
||
/// TLS fingerprint tracking for hot reload (inline fallback path)
|
||
tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
|
||
/// When present, the adapter provides coordinator-managed TLS material;
|
||
/// otherwise the inline fingerprint path is used as a fallback.
|
||
tls_adapter: Option<TlsReloadAdapter<Pool>>,
|
||
/// Success/failure counters exposed via `delivery_snapshot`
|
||
delivery_counters: Arc<TargetDeliveryCounters>,
|
||
/// Zero-sized marker for the event type `E`
|
||
_phantom: PhantomData<E>,
|
||
}
|
||
|
||
impl<E> MySqlTarget<E>
|
||
where
|
||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||
{
|
||
/// Creates a new MySqlTarget.
|
||
///
|
||
/// The target starts without a TLS reload coordinator. Use
|
||
/// `TlsReloadAdapter::try_register` to opt into coordinated TLS hot-reload.
|
||
pub fn new(id: String, args: MySqlArgs) -> Result<Self, TargetError> {
|
||
args.validate()?;
|
||
|
||
let target_id = TargetID::new(id, ChannelTargetType::MySql.as_str().to_string());
|
||
|
||
let queue_store = open_target_queue_store(
|
||
&args.queue_dir,
|
||
args.queue_limit,
|
||
args.target_type,
|
||
ChannelTargetType::MySql.as_str(),
|
||
&target_id,
|
||
"Failed to open MySQL queue store",
|
||
)?;
|
||
|
||
info!(target_id = %target_id.id, table = %args.table, "MySQL target created");
|
||
|
||
Ok(MySqlTarget {
|
||
id: target_id,
|
||
args,
|
||
store: queue_store,
|
||
// Pool is lazily initialized on first use to avoid unnecessary connections at startup and allow for better error handling
|
||
pool: Arc::new(Mutex::new(None)),
|
||
tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
|
||
tls_adapter: None,
|
||
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
|
||
_phantom: PhantomData,
|
||
})
|
||
}
|
||
|
||
/// Returns or lazily initializes the MySQL connection pool.
|
||
///
|
||
/// When `tls_adapter` is present (coordinator-managed), the pool
|
||
/// is sourced from the coordinator's published material.
|
||
/// Otherwise, the inline fingerprint-based path is used as a fallback.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// | Scenario | Error variant |
|
||
/// |---|---|
|
||
/// | Connection refused / host unreachable / TLS handshake failed | `NotConnected` |
|
||
/// | `SELECT 1` health check failed | `NotConnected` |
|
||
/// | DDL permission denied / `CREATE TABLE` failed | `Initialization` |
|
||
/// | Existing table has incompatible schema | `Initialization` |
|
||
/// | DSN parse failure / invalid config | `Configuration` |
|
||
async fn get_or_init_pool(&self) -> Result<Pool, TargetError> {
|
||
// Adapter-managed path: use the material directly from the coordinator.
|
||
if let Some(adapter) = &self.tls_adapter {
|
||
let pool: Pool = (*adapter.current_material()).clone();
|
||
|
||
// Ensure the pool is also stored locally so that close() can drain it.
|
||
{
|
||
let mut guard = self.pool.lock().await;
|
||
*guard = Some(pool.clone());
|
||
}
|
||
return Ok(pool);
|
||
}
|
||
|
||
// Inline fingerprint fallback path (no coordinator).
|
||
let next_fingerprint =
|
||
super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key).await?;
|
||
let tls_changed = {
|
||
let tls_state_guard = self.tls_state.lock();
|
||
tls_state_guard.needs_update(&next_fingerprint)
|
||
};
|
||
if tls_changed {
|
||
let mut guard = self.pool.lock().await;
|
||
*guard = None;
|
||
self.tls_state.lock().refresh(next_fingerprint);
|
||
}
|
||
|
||
{
|
||
let guard = self.pool.lock().await;
|
||
if let Some(pool) = guard.as_ref() {
|
||
return Ok(pool.clone());
|
||
}
|
||
}
|
||
|
||
let pool = build_mysql_pool_from_args(&self.args).await?;
|
||
|
||
// Double-check: another caller may have initialized the pool
|
||
// while we were doing I/O.
|
||
let mut guard = self.pool.lock().await;
|
||
if let Some(existing) = guard.as_ref() {
|
||
debug!(
|
||
"MySQL pool for target '{}' was initialized by another task during setup; using existing pool",
|
||
self.id
|
||
);
|
||
return Ok(existing.clone());
|
||
}
|
||
*guard = Some(pool.clone());
|
||
Ok(pool)
|
||
}
|
||
|
||
/// Inserts an event directly into the MySQL table.
|
||
async fn insert_event(&self, body: &[u8], meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
|
||
debug!(
|
||
target_id = %self.id,
|
||
bucket = %meta.bucket_name,
|
||
object = %meta.object_name,
|
||
event = %meta.event_name,
|
||
payload_len = body.len(),
|
||
"Inserting MySQL event"
|
||
);
|
||
|
||
let pool = self.get_or_init_pool().await?;
|
||
// At this point the pool has already been initialized (get_or_init_pool
|
||
// succeeded above), so get_conn() failures are always transient: the
|
||
// connection was lost or the pool is temporarily exhausted.
|
||
let mut conn = pool.get_conn().await.map_err(|_| TargetError::NotConnected)?;
|
||
|
||
let event_time = extract_event_time(body)?;
|
||
let event_data =
|
||
std::str::from_utf8(body).map_err(|e| TargetError::Serialization(format!("Event body is not valid UTF-8: {e}")))?;
|
||
|
||
let sql = format!(
|
||
"INSERT INTO {} (event_time, event_data) VALUES (?, CAST(? AS JSON))",
|
||
quote_table_name(&self.args.table)?
|
||
);
|
||
|
||
conn.exec_drop(sql, (event_time.as_str(), event_data))
|
||
.await
|
||
.map_err(|err| map_mysql_error(err, "Failed to insert event"))?;
|
||
|
||
self.delivery_counters.record_success();
|
||
debug!(target_id = %self.id, "MySQL event inserted");
|
||
Ok(())
|
||
}
|
||
|
||
fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||
Box::new(MySqlTarget::<E> {
|
||
id: self.id.clone(),
|
||
args: self.args.clone(),
|
||
store: self.store.as_ref().map(|s| s.boxed_clone()),
|
||
pool: Arc::clone(&self.pool),
|
||
tls_state: Arc::clone(&self.tls_state),
|
||
tls_adapter: self.tls_adapter.clone(),
|
||
delivery_counters: Arc::clone(&self.delivery_counters),
|
||
_phantom: PhantomData,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Builds a MySQL connection pool from the given args, including TLS setup,
|
||
/// DDL table creation, and schema validation.
|
||
///
|
||
/// This is a standalone function so it can be called both from
|
||
/// `get_or_init_pool` (inline fallback) and from `build_tls_material`
|
||
/// (coordinator path).
|
||
async fn build_mysql_pool_from_args(args: &MySqlArgs) -> Result<Pool, TargetError> {
|
||
let dsn = MySqlDsn::parse(&args.dsn_string)?;
|
||
|
||
let mut builder = OptsBuilder::default()
|
||
.user(Some(dsn.user.clone()))
|
||
.pass(Some(dsn.password.clone()))
|
||
.ip_or_hostname(dsn.host.clone())
|
||
.tcp_port(dsn.port)
|
||
.db_name(Some(dsn.database.clone()));
|
||
|
||
if dsn.tls {
|
||
super::ensure_rustls_provider_installed();
|
||
let mut ssl_opts = SslOpts::default();
|
||
if !args.tls_ca.is_empty() {
|
||
let _ =
|
||
load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_ca: {e}")))?;
|
||
ssl_opts = ssl_opts.with_root_certs(vec![PathBuf::from(args.tls_ca.clone()).into()]);
|
||
}
|
||
if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
|
||
let _ = load_certs(&args.tls_client_cert)
|
||
.map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_client_cert: {e}")))?;
|
||
let _ = load_private_key(&args.tls_client_key)
|
||
.map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_client_key: {e}")))?;
|
||
let identity = mysql_async::ClientIdentity::new(
|
||
PathBuf::from(args.tls_client_cert.clone()).into(),
|
||
PathBuf::from(args.tls_client_key.clone()).into(),
|
||
);
|
||
ssl_opts = ssl_opts.with_client_identity(Some(identity));
|
||
}
|
||
builder = builder.ssl_opts(Some(ssl_opts));
|
||
} else {
|
||
warn!("MySQL target is configured without TLS. This is insecure and should not be used in production.");
|
||
}
|
||
|
||
// When max_open_connections is 0, no explicit upper bound is set —
|
||
// mysql_async uses its default pool constraints (10–100).
|
||
if args.max_open_connections > 0 {
|
||
let constraints = PoolConstraints::new(1, args.max_open_connections).ok_or_else(|| {
|
||
TargetError::Configuration(format!("MySQL max_open_connections must be >= 1, got {}", args.max_open_connections))
|
||
})?;
|
||
builder = builder.pool_opts(PoolOpts::default().with_constraints(constraints));
|
||
}
|
||
|
||
let opts = Opts::from(builder);
|
||
let pool = Pool::new(opts);
|
||
|
||
// Uses a double-check pattern: the mutex guard is only held for
|
||
// short reads/writes to the pool cache. All I/O (connecting,
|
||
// DDL, schema validation) happens outside the lock so that
|
||
// concurrent callers are not blocked by a slow MySQL server.
|
||
let mut conn = pool.get_conn().await.map_err(|_| TargetError::NotConnected)?;
|
||
|
||
conn.query_drop("SELECT 1").await.map_err(|_| TargetError::NotConnected)?;
|
||
|
||
let ddl = format!(
|
||
"CREATE TABLE IF NOT EXISTS {} (event_time DATETIME(6) NOT NULL, event_data JSON NOT NULL)",
|
||
quote_table_name(&args.table)?
|
||
);
|
||
conn.query_drop(ddl)
|
||
.await
|
||
.map_err(|e| TargetError::Initialization(format!("Failed to create MySQL table: {e}")))?;
|
||
|
||
validate_existing_schema(&mut conn, &args.table).await?;
|
||
|
||
Ok(pool)
|
||
}
|
||
|
||
/// Maps a mysql_async error to `TargetError`:
|
||
/// - `Io`/`Driver` → `NotConnected` (connection lost, fixed-delay retry)
|
||
/// - `Server(1213|1205|1040)` → `Timeout` (deadlock/lock timeout/too
|
||
/// many connections, exponential-backoff retry)
|
||
/// - everything else → `Request` (permanent failure)
|
||
pub(crate) fn map_mysql_error(err: mysql_async::Error, operation: &str) -> TargetError {
|
||
match &err {
|
||
mysql_async::Error::Io(_) | mysql_async::Error::Driver(_) => TargetError::NotConnected,
|
||
mysql_async::Error::Server(server_err) => match server_err.code {
|
||
1213 | 1205 | 1040 => {
|
||
TargetError::Timeout(format!("MySQL transient server error {}: {}", server_err.code, server_err.message))
|
||
}
|
||
_ => TargetError::Request(format!("{operation}: {err}")),
|
||
},
|
||
_ => TargetError::Request(format!("{operation}: {err}")),
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl<E> Target<E> for MySqlTarget<E>
|
||
where
|
||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||
{
|
||
fn id(&self) -> TargetID {
|
||
self.id.clone()
|
||
}
|
||
|
||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||
if !self.args.enable {
|
||
return Ok(false);
|
||
}
|
||
|
||
let pool = self.get_or_init_pool().await?;
|
||
|
||
let health_result = tokio::time::timeout(tokio::time::Duration::from_secs(10), async {
|
||
let mut conn = pool.get_conn().await?;
|
||
conn.query_drop("SELECT 1").await
|
||
})
|
||
.await;
|
||
|
||
match health_result {
|
||
Ok(Ok(())) => {
|
||
debug!("MySQL target '{}' is reachable", self.id);
|
||
Ok(true)
|
||
}
|
||
// get_or_init_pool has already verified connectivity, DDL, and
|
||
// schema, so a SELECT 1 failure here is always transient
|
||
// (connection lost). No need to classify error codes.
|
||
Ok(Err(_)) => Err(TargetError::NotConnected),
|
||
Err(_elapsed) => Err(TargetError::Timeout("MySQL is_active health check timed out after 10s".to_string())),
|
||
}
|
||
}
|
||
|
||
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!("Event saved to queue store for MySQL target: {}", self.id);
|
||
Ok(())
|
||
} else {
|
||
if let Err(err) = self.insert_event(&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 = %key, payload_len = body.len(), "Sending queued payload from store to MySQL target");
|
||
|
||
match extract_event_time(&body) {
|
||
Ok(_) => {}
|
||
Err(_) => {
|
||
// If the payload is missing the required eventTime field or it
|
||
// cannot be parsed, we consider it corrupted and drop it to
|
||
// avoid blocking the queue with undeliverable entries.
|
||
error!(
|
||
target_id = %self.id,
|
||
key = %key,
|
||
"Corrupted queued MySQL payload: missing or invalid Records[0].eventTime; dropping entry"
|
||
);
|
||
|
||
// attempt to delete the corrupted entry from the store if possible
|
||
if let Some(store) = &self.store
|
||
&& let Err(e) = delete_stored_payload(store.as_ref(), &key)
|
||
{
|
||
error!(target_id = %self.id, key=%key, error = %e, "Failed to delete corrupted queue entry");
|
||
}
|
||
|
||
self.delivery_counters.record_final_failure();
|
||
return Err(TargetError::Dropped(format!(
|
||
"Dropped corrupted queued MySQL payload {key}: missing or invalid Records[0].eventTime"
|
||
)));
|
||
}
|
||
}
|
||
|
||
if let Err(e) = self.insert_event(&body, &meta).await {
|
||
if is_connectivity_error(&e) {
|
||
warn!(target_id = %self.id, "MySQL not reachable, event remains in queue store");
|
||
return Err(e);
|
||
}
|
||
error!(target_id = %self.id, error = %e, "Failed to send event from store");
|
||
return Err(e);
|
||
}
|
||
|
||
debug!(target_id = %self.id, key = %key, "MySQL event replayed from store");
|
||
Ok(())
|
||
}
|
||
|
||
async fn close(&self) -> Result<(), TargetError> {
|
||
let pool = {
|
||
let mut guard = self.pool.lock().await;
|
||
guard.take()
|
||
};
|
||
|
||
if let Some(pool) = pool {
|
||
pool.disconnect()
|
||
.await
|
||
.map_err(|err| TargetError::Network(format!("Failed to disconnect MySQL pool: {err}")))?;
|
||
}
|
||
|
||
// Adapter cleanup is done by the coordinator; no local state to reset.
|
||
|
||
info!("MySQL target closed: {}", self.id);
|
||
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.args.enable {
|
||
debug!("MySQL target '{}' is disabled, skipping initialization", self.id);
|
||
return Ok(());
|
||
}
|
||
self.get_or_init_pool().await?;
|
||
Ok(())
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
fn record_final_failure(&self) {
|
||
self.delivery_counters.record_final_failure();
|
||
}
|
||
}
|
||
|
||
/// Coordinated TLS hot-reload implementation for MySQL targets.
|
||
///
|
||
/// The coordinator calls these methods on a background poll loop to detect
|
||
/// TLS file changes and rebuild the connection pool without restarting.
|
||
#[async_trait]
|
||
impl<E> ReloadableTargetTls for MySqlTarget<E>
|
||
where
|
||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||
{
|
||
type Material = Pool;
|
||
|
||
fn tls_input_set(&self) -> TargetTlsInputSet {
|
||
TargetTlsInputSet {
|
||
ca_path: self.args.tls_ca.clone(),
|
||
client_cert_path: self.args.tls_client_cert.clone(),
|
||
client_key_path: self.args.tls_client_key.clone(),
|
||
target_label: format!("mysql:{}", self.id.id),
|
||
}
|
||
}
|
||
|
||
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
|
||
build_mysql_pool_from_args(&self.args).await
|
||
}
|
||
|
||
async fn apply_tls_material(
|
||
&self,
|
||
_generation: TargetTlsGeneration,
|
||
material: Arc<Self::Material>,
|
||
_mode: ReloadApplyMode,
|
||
) -> Result<(), TargetError> {
|
||
let mut guard = self.pool.lock().await;
|
||
*guard = Some((*material).clone());
|
||
Ok(())
|
||
}
|
||
|
||
async fn validate_tls_files(&self) -> Result<(), TargetError> {
|
||
validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::target::REDACTED_SECRET;
|
||
|
||
fn absolute_test_path(path: &str) -> String {
|
||
std::env::temp_dir().join(path).to_string_lossy().into_owned()
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_format() {
|
||
let dsn = MySqlDsn::parse("rustfs:secret123@tcp(mysql.example.com:3306)/rustfs_events").expect("valid DSN");
|
||
assert_eq!(dsn.user, "rustfs");
|
||
assert_eq!(dsn.password, "secret123");
|
||
assert_eq!(dsn.host, "mysql.example.com");
|
||
assert_eq!(dsn.port, 3306);
|
||
assert_eq!(dsn.database, "rustfs_events");
|
||
assert!(!dsn.tls);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_with_mysql_prefix() {
|
||
let dsn = MySqlDsn::parse("mysql://rustfs:password@tcp(127.0.0.1:3306)/mydb").expect("valid DSN with prefix");
|
||
assert_eq!(dsn.user, "rustfs");
|
||
assert_eq!(dsn.password, "password");
|
||
assert_eq!(dsn.host, "127.0.0.1");
|
||
assert_eq!(dsn.port, 3306);
|
||
assert_eq!(dsn.database, "mydb");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_with_mixed_case_mysql_prefix() {
|
||
let dsn = MySqlDsn::parse("MySQL://rustfs:password@tcp(127.0.0.1:3306)/mydb").expect("valid DSN with mixed-case prefix");
|
||
assert_eq!(dsn.user, "rustfs");
|
||
assert_eq!(dsn.password, "password");
|
||
assert_eq!(dsn.host, "127.0.0.1");
|
||
assert_eq!(dsn.port, 3306);
|
||
assert_eq!(dsn.database, "mydb");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_with_tls_true() {
|
||
let dsn = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?tls=true").expect("valid DSN with TLS");
|
||
assert!(dsn.tls);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_with_tls_bare() {
|
||
let dsn = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?tls").expect("bare tls param");
|
||
assert!(dsn.tls);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_rejects_unsupported_tls_params() {
|
||
let err =
|
||
MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?verify_ca=true").expect_err("verify_ca should be rejected");
|
||
assert!(err.to_string().contains("verify_ca"));
|
||
|
||
let err = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?verify_identity=true")
|
||
.expect_err("verify_identity should be rejected");
|
||
assert!(err.to_string().contains("verify_identity"));
|
||
|
||
let err = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?built_in_roots=true")
|
||
.expect_err("built_in_roots should be rejected");
|
||
assert!(err.to_string().contains("built_in_roots"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_rejects_empty() {
|
||
let err = MySqlDsn::parse("").expect_err("empty DSN");
|
||
assert!(err.to_string().contains("empty"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_rejects_missing_at() {
|
||
let err = MySqlDsn::parse("rustfs:password").expect_err("missing @");
|
||
assert!(err.to_string().contains("must contain user:password@"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_dsn_rejects_non_tcp() {
|
||
let err = MySqlDsn::parse("rustfs:password@unix(/tmp/mysql.sock)/mydb").expect_err("non-tcp should be rejected");
|
||
assert!(err.to_string().contains("tcp("));
|
||
}
|
||
|
||
#[test]
|
||
fn redact_dsn_masks_password() {
|
||
let redacted = redact_mysql_dsn("rustfs:secret123@tcp(mysql.example.com:3306)/rustfs_events");
|
||
assert_eq!(redacted, "rustfs:***@tcp(mysql.example.com:3306)/rustfs_events");
|
||
}
|
||
|
||
#[test]
|
||
fn redact_dsn_with_mysql_prefix() {
|
||
let redacted = redact_mysql_dsn("mysql://rustfs:secret123@tcp(127.0.0.1:3306)/mydb");
|
||
assert_eq!(redacted, "mysql://rustfs:***@tcp(127.0.0.1:3306)/mydb");
|
||
}
|
||
|
||
#[test]
|
||
fn redact_dsn_with_mixed_case_mysql_prefix() {
|
||
let redacted = redact_mysql_dsn("MySQL://rustfs:secret123@tcp(127.0.0.1:3306)/mydb");
|
||
assert_eq!(redacted, "MySQL://rustfs:***@tcp(127.0.0.1:3306)/mydb");
|
||
}
|
||
|
||
#[test]
|
||
fn redact_dsn_empty_password() {
|
||
let redacted = redact_mysql_dsn("root:@tcp(127.0.0.1:4000)/testdb");
|
||
assert_eq!(redacted, "root:***@tcp(127.0.0.1:4000)/testdb");
|
||
}
|
||
|
||
#[test]
|
||
fn debug_redacts_mysql_secret_fields() {
|
||
let args = MySqlArgs {
|
||
enable: true,
|
||
dsn_string: "rustfs:mysql-password@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: String::new(),
|
||
tls_client_cert: String::new(),
|
||
tls_client_key: "/etc/rustfs/mysql.key".to_string(),
|
||
queue_dir: String::new(),
|
||
queue_limit: 0,
|
||
max_open_connections: 0,
|
||
target_type: TargetType::NotifyEvent,
|
||
};
|
||
let dsn = MySqlDsn::parse(&args.dsn_string).expect("valid DSN");
|
||
|
||
let rendered_args = format!("{args:?}");
|
||
let rendered_dsn = format!("{dsn:?}");
|
||
|
||
assert!(!rendered_args.contains("mysql-password"));
|
||
assert!(!rendered_args.contains("/etc/rustfs/mysql.key"));
|
||
assert!(!rendered_dsn.contains("mysql-password"));
|
||
assert!(rendered_args.contains("rustfs:***@"));
|
||
assert!(rendered_dsn.contains(REDACTED_SECRET));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_table_name_accepts_valid_identifier() {
|
||
validate_table_name("rustfs_events").expect("valid table name");
|
||
validate_table_name("my_db.events").expect("valid db.table");
|
||
validate_table_name("_events").expect("valid starting underscore");
|
||
validate_table_name("table_2").expect("valid with numbers");
|
||
}
|
||
|
||
#[test]
|
||
fn validate_table_name_rejects_invalid() {
|
||
let err = validate_table_name("").expect_err("empty");
|
||
assert!(err.to_string().contains("empty"));
|
||
|
||
let err = validate_table_name("1table").expect_err("starts with digit");
|
||
assert!(err.to_string().contains("not a valid identifier"));
|
||
|
||
let err = validate_table_name("my-table").expect_err("contains dash");
|
||
assert!(err.to_string().contains("not a valid identifier"));
|
||
|
||
let err = validate_table_name(".table").expect_err("empty db part");
|
||
assert!(err.to_string().contains("invalid"));
|
||
|
||
let err = validate_table_name("db.").expect_err("empty table part");
|
||
assert!(err.to_string().contains("invalid"));
|
||
}
|
||
|
||
#[test]
|
||
fn quote_table_name_quotes_simple() {
|
||
let quoted = quote_table_name("rustfs_events").expect("valid");
|
||
assert_eq!(quoted, "`rustfs_events`");
|
||
}
|
||
|
||
#[test]
|
||
fn quote_table_name_quotes_database_table() {
|
||
let quoted = quote_table_name("my_db.events").expect("valid");
|
||
assert_eq!(quoted, "`my_db`.`events`");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_event_time_parses_valid_rfc3339() {
|
||
let body =
|
||
br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[{"eventTime":"2026-05-03T10:00:00Z"}]}"#;
|
||
let result = extract_event_time(body).expect("valid event_time");
|
||
assert!(result.starts_with("2026-05-03 10:00:00"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_event_time_missing_field_errors() {
|
||
let body = br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[]}"#;
|
||
let err = extract_event_time(body).expect_err("missing eventTime should fail");
|
||
assert!(err.to_string().contains("missing Records[0].eventTime"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_event_time_non_string_errors() {
|
||
let body = br#"{"EventName":"s3:ObjectCreated:Put","Records":[{"eventTime":123}]}"#;
|
||
let err = extract_event_time(body).expect_err("non-string eventTime should fail");
|
||
assert!(err.to_string().contains("missing Records[0].eventTime"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_event_time_malformed_rfc3339_errors() {
|
||
let body = br#"{"Records":[{"eventTime":"not-a-date"}]}"#;
|
||
let err = extract_event_time(body).expect_err("malformed date should fail");
|
||
assert!(err.to_string().contains("Failed to parse eventTime"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_event_time_missing_records_errors() {
|
||
let body = br#"{"EventName":"s3:ObjectCreated:Put"}"#;
|
||
let err = extract_event_time(body).expect_err("missing Records should fail");
|
||
assert!(err.to_string().contains("missing Records[0].eventTime"));
|
||
}
|
||
|
||
#[test]
|
||
fn queued_payload_round_trip_preserves_event_data() {
|
||
let entity = EntityTarget {
|
||
object_name: "bucket%2Fobj.txt".to_string(),
|
||
bucket_name: "testbucket".to_string(),
|
||
event_name: rustfs_s3_types::EventName::ObjectCreatedPut,
|
||
data: serde_json::json!({"eventTime": "2026-05-03T10:00:00Z"}),
|
||
};
|
||
|
||
let payload = build_queued_payload(&entity).expect("build payload");
|
||
let encoded = payload.encode().expect("encode");
|
||
let decoded = QueuedPayload::decode(&encoded).expect("decode");
|
||
|
||
assert_eq!(decoded.meta.event_name, payload.meta.event_name);
|
||
assert_eq!(decoded.meta.bucket_name, "testbucket");
|
||
assert_eq!(decoded.meta.object_name, "bucket%2Fobj.txt");
|
||
assert_eq!(decoded.meta.content_type, "application/json");
|
||
|
||
let body_str = std::str::from_utf8(&decoded.body).expect("utf8 body");
|
||
assert!(body_str.contains("\"EventName\""));
|
||
assert!(body_str.contains("\"Key\""));
|
||
assert!(body_str.contains("testbucket"));
|
||
assert!(body_str.contains("\"Records\""));
|
||
assert!(body_str.contains("\"eventTime\""));
|
||
}
|
||
|
||
#[test]
|
||
fn send_raw_from_store_drops_corrupted_payload() {
|
||
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
let queue_dir = tmpdir.path().to_str().expect("valid path").to_string();
|
||
|
||
let target: MySqlTarget<serde_json::Value> = MySqlTarget::new(
|
||
"test-corrupted".to_string(),
|
||
MySqlArgs {
|
||
enable: false,
|
||
dsn_string: "rustfs:pass@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: String::new(),
|
||
tls_client_cert: String::new(),
|
||
tls_client_key: String::new(),
|
||
queue_dir,
|
||
queue_limit: 10,
|
||
max_open_connections: 2,
|
||
target_type: TargetType::NotifyEvent,
|
||
},
|
||
)
|
||
.expect("valid args");
|
||
|
||
let body = br#"{"Records":[]}"#.to_vec();
|
||
let meta = QueuedPayloadMeta::new(
|
||
rustfs_s3_types::EventName::ObjectCreatedPut,
|
||
"testbucket".to_string(),
|
||
"obj.txt".to_string(),
|
||
"application/json",
|
||
body.len(),
|
||
);
|
||
|
||
let encoded = QueuedPayload::new(meta.clone(), body.clone())
|
||
.encode()
|
||
.expect("encode queued payload");
|
||
|
||
let stored_key = target.store().unwrap().put_raw(&encoded).expect("put raw");
|
||
|
||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||
let result = rt.block_on(target.send_raw_from_store(stored_key.clone(), body, meta));
|
||
|
||
match result {
|
||
Err(TargetError::Dropped(msg)) => {
|
||
assert!(msg.contains("Dropped"));
|
||
assert!(msg.contains("eventTime"));
|
||
}
|
||
other => panic!("expected TargetError::Dropped, got {:?}", other),
|
||
}
|
||
|
||
assert!(
|
||
target.store().unwrap().get_raw(&stored_key).is_err(),
|
||
"corrupted entry should have been deleted from store"
|
||
);
|
||
|
||
assert_eq!(target.delivery_snapshot().failed_messages, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn send_raw_from_store_replays_valid_payload() {
|
||
let tmpdir = tempfile::TempDir::new().expect("temp dir");
|
||
let queue_dir = tmpdir.path().to_str().expect("valid path").to_string();
|
||
|
||
let target: MySqlTarget<serde_json::Value> = MySqlTarget::new(
|
||
"test-valid-replay".to_string(),
|
||
MySqlArgs {
|
||
enable: false,
|
||
dsn_string: "rustfs:pass@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: String::new(),
|
||
tls_client_cert: String::new(),
|
||
tls_client_key: String::new(),
|
||
queue_dir,
|
||
queue_limit: 10,
|
||
max_open_connections: 2,
|
||
target_type: TargetType::NotifyEvent,
|
||
},
|
||
)
|
||
.expect("valid args");
|
||
|
||
let body =
|
||
br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[{"eventTime":"2026-05-03T10:00:00Z"}]}"#
|
||
.to_vec();
|
||
let meta = QueuedPayloadMeta::new(
|
||
rustfs_s3_types::EventName::ObjectCreatedPut,
|
||
"testbucket".to_string(),
|
||
"obj.txt".to_string(),
|
||
"application/json",
|
||
body.len(),
|
||
);
|
||
|
||
let encoded = QueuedPayload::new(meta.clone(), body.clone())
|
||
.encode()
|
||
.expect("encode queued payload");
|
||
|
||
let stored_key = target.store().unwrap().put_raw(&encoded).expect("put raw");
|
||
|
||
// With enable=false and no real MySQL, the insert will fail at
|
||
// pool init. But send_raw_from_store validates event_time before
|
||
// insert, so valid payloads pass the time check. We verify the
|
||
// payload is NOT treated as corrupted.
|
||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||
let result = rt.block_on(target.send_raw_from_store(stored_key.clone(), body, meta));
|
||
|
||
assert!(!matches!(result, Err(TargetError::Dropped(_))), "valid payload should not return Dropped");
|
||
|
||
// Verify entry is NOT deleted on non-Dropped errors
|
||
assert!(target.store().unwrap().get_raw(&stored_key).is_ok(), "valid entry should remain in store");
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_unpaired_tls_client_fields() {
|
||
let args = MySqlArgs {
|
||
enable: true,
|
||
dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: String::new(),
|
||
tls_client_cert: "/etc/ssl/mysql/client.pem".to_string(),
|
||
tls_client_key: String::new(),
|
||
queue_dir: "/tmp".to_string(),
|
||
queue_limit: 100,
|
||
max_open_connections: 2,
|
||
target_type: TargetType::NotifyEvent,
|
||
};
|
||
|
||
let err = args.validate().expect_err("unpaired tls client fields should fail");
|
||
assert!(err.to_string().contains("must be specified together"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_relative_tls_paths() {
|
||
let args = MySqlArgs {
|
||
enable: true,
|
||
dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: "ca.pem".to_string(),
|
||
tls_client_cert: String::new(),
|
||
tls_client_key: String::new(),
|
||
queue_dir: "/tmp".to_string(),
|
||
queue_limit: 100,
|
||
max_open_connections: 2,
|
||
target_type: TargetType::NotifyEvent,
|
||
};
|
||
|
||
let err = args.validate().expect_err("relative tls_ca should fail");
|
||
assert!(err.to_string().contains("absolute path"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_accepts_absolute_tls_paths() {
|
||
let args = MySqlArgs {
|
||
enable: true,
|
||
dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
|
||
table: "events".to_string(),
|
||
format: "access".to_string(),
|
||
tls_ca: absolute_test_path("mysql-ca.pem"),
|
||
tls_client_cert: absolute_test_path("mysql-client.pem"),
|
||
tls_client_key: absolute_test_path("mysql-client.key"),
|
||
queue_dir: absolute_test_path("mysql-queue"),
|
||
queue_limit: 100,
|
||
max_open_connections: 2,
|
||
target_type: TargetType::NotifyEvent,
|
||
};
|
||
|
||
args.validate().expect("absolute tls paths should pass");
|
||
}
|
||
}
|