Compare commits

...

9 Commits

Author SHA1 Message Date
weisd d4dcb2ac9d fix(scanner): avoid stalls after abandoned child listings (#2632) 2026-04-21 09:06:26 +00:00
majinghe 41d2812861 feat: add support for external/existing certificate issuer (#2631) 2026-04-21 07:21:43 +00:00
houseme 960c13a34b feat(storage): wire capacity/object perf tuning and add batch benchmark runners (#2628) 2026-04-21 07:20:57 +00:00
Andy Teijelo Pérez 989827e3b5 feat: add OTHER_AUDIENCES config (#2605)
Co-authored-by: GatewayJ <835269233@qq.com>
2026-04-21 03:48:13 +00:00
likewu a77be8f89b fix: some expect error (#2622)
Signed-off-by: likewu <likewu@126.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-21 03:27:50 +00:00
安正超 1525143a04 test: cover NATS and Pulsar config validation (#2623) 2026-04-21 01:59:05 +00:00
houseme b7a945e453 fix(madmin): restore server_info msgpack compatibility across mixed nodes (#2621) 2026-04-21 01:09:25 +00:00
houseme 3796b684f0 feat(targets): add NATS and Pulsar target support (#2618) 2026-04-21 00:28:19 +00:00
cxymds 1511f9eacb fix(lifecycle): correct delete replication fanout (#2609)
Co-authored-by: loverustfs <hello@rustfs.com>
2026-04-20 10:02:59 +00:00
85 changed files with 5994 additions and 1660 deletions
Generated
+254 -19
View File
@@ -578,6 +578,42 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-nats"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-util",
"memchr",
"nkeys",
"nuid",
"pin-project",
"portable-atomic",
"rand 0.8.6",
"regex",
"ring",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki",
"serde",
"serde_json",
"serde_nanos",
"serde_repr",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"tokio-websockets",
"tracing",
"tryhard",
"url",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -2760,7 +2796,7 @@ dependencies = [
"itertools 0.14.0",
"parking_lot 0.12.5",
"paste",
"petgraph",
"petgraph 0.8.3",
"recursive",
"tokio",
]
@@ -3302,6 +3338,7 @@ dependencies = [
"ed25519",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"subtle",
"zeroize",
]
@@ -3906,8 +3943,8 @@ dependencies = [
"opentelemetry_sdk",
"percent-encoding",
"pin-project",
"prost",
"prost-types",
"prost 0.14.3",
"prost-types 0.14.3",
"reqwest 0.13.2",
"rustc_version",
"serde",
@@ -4013,8 +4050,8 @@ dependencies = [
"md5",
"percent-encoding",
"pin-project",
"prost",
"prost-types",
"prost 0.14.3",
"prost-types 0.14.3",
"serde",
"serde_json",
"serde_with",
@@ -4410,7 +4447,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -5605,6 +5642,12 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "murmur3"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b"
[[package]]
name = "names"
version = "0.14.0"
@@ -5698,6 +5741,21 @@ dependencies = [
"libc",
]
[[package]]
name = "nkeys"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
dependencies = [
"data-encoding",
"ed25519",
"ed25519-dalek",
"getrandom 0.2.17",
"log",
"rand 0.8.6",
"signatory",
]
[[package]]
name = "no_std_io2"
version = "0.9.3"
@@ -5744,6 +5802,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nuid"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
dependencies = [
"rand 0.8.6",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -6098,7 +6165,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"prost 0.14.3",
"reqwest 0.12.28",
"thiserror 2.0.18",
"tracing",
@@ -6112,7 +6179,7 @@ checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
"prost 0.14.3",
"tonic",
"tonic-prost",
]
@@ -6423,6 +6490,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -6689,7 +6766,7 @@ dependencies = [
"inferno 0.12.6",
"num",
"paste",
"prost",
"prost 0.14.3",
]
[[package]]
@@ -6775,6 +6852,16 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.3"
@@ -6782,7 +6869,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
"prost-derive 0.14.3",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.14.0",
"log",
"multimap",
"once_cell",
"petgraph 0.7.1",
"prettyplease",
"prost 0.13.5",
"prost-types 0.13.5",
"regex",
"syn 2.0.117",
"tempfile",
]
[[package]]
@@ -6795,10 +6902,10 @@ dependencies = [
"itertools 0.14.0",
"log",
"multimap",
"petgraph",
"petgraph 0.8.3",
"prettyplease",
"prost",
"prost-types",
"prost 0.14.3",
"prost-types 0.14.3",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"regex",
@@ -6806,6 +6913,19 @@ dependencies = [
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
@@ -6819,13 +6939,22 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost",
"prost 0.14.3",
]
[[package]]
@@ -6919,6 +7048,36 @@ dependencies = [
"pulldown-cmark",
]
[[package]]
name = "pulsar"
version = "6.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2367cb38f1b65857bc11dd13b2adf13b7a1d991ef1cd43572f1420958c56cc2"
dependencies = [
"async-channel",
"async-trait",
"bytes",
"chrono",
"crc",
"futures",
"log",
"murmur3",
"nom 7.1.3",
"pem",
"prost 0.13.5",
"prost-build 0.13.5",
"prost-derive 0.13.5",
"rand 0.8.6",
"regex",
"rustls",
"tokio",
"tokio-rustls",
"tokio-util",
"url",
"uuid",
"webpki-roots 1.0.7",
]
[[package]]
name = "pyroscope"
version = "2.0.0"
@@ -6931,7 +7090,7 @@ dependencies = [
"log",
"names",
"pprof-pyroscope-fork",
"prost",
"prost 0.14.3",
"reqwest 0.13.2",
"serde_json",
"thiserror 2.0.18",
@@ -8185,6 +8344,7 @@ dependencies = [
"chrono",
"humantime",
"hyper",
"rmp-serde",
"serde",
"serde_json",
"time",
@@ -8366,7 +8526,7 @@ name = "rustfs-protos"
version = "0.0.5"
dependencies = [
"flatbuffers",
"prost",
"prost 0.14.3",
"rustfs-common",
"tonic",
"tonic-prost",
@@ -8510,12 +8670,15 @@ dependencies = [
name = "rustfs-targets"
version = "0.0.5"
dependencies = [
"async-nats",
"async-trait",
"criterion",
"hyper-rustls",
"pulsar",
"reqwest 0.13.2",
"rumqttc-next",
"rustfs-config",
"rustfs-ecstore",
"rustfs-s3-common",
"rustfs-utils",
"rustls",
@@ -9009,6 +9172,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_nanos"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985"
dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
@@ -9029,6 +9201,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -9200,6 +9383,18 @@ dependencies = [
"libc",
]
[[package]]
name = "signatory"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
dependencies = [
"pkcs8 0.10.2",
"rand_core 0.6.4",
"signature 2.2.0",
"zeroize",
]
[[package]]
name = "signature"
version = "1.6.4"
@@ -10000,6 +10195,27 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-websockets"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
"http 1.4.0",
"httparse",
"rand 0.8.6",
"ring",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
]
[[package]]
name = "tonic"
version = "0.14.5"
@@ -10051,7 +10267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
dependencies = [
"bytes",
"prost",
"prost 0.14.3",
"tonic",
]
@@ -10063,8 +10279,8 @@ checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"prost-build 0.14.3",
"prost-types 0.14.3",
"quote",
"syn 2.0.117",
"tempfile",
@@ -10257,6 +10473,16 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tryhard"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5"
dependencies = [
"pin-project-lite",
"tokio",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -10642,6 +10868,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
+3 -1
View File
@@ -38,7 +38,7 @@ members = [
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
"crates/protos", # Protocol buffer definitions
"crates/rio", # Rust I/O utilities and abstractions
"crates/concurrency", # Rust I/O utilities and abstractions
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
"crates/s3-common", # Common utilities and data structures for S3 compatibility
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
@@ -118,11 +118,13 @@ async-channel = "2.5.0"
async-compression = { version = "0.4.41" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-nats = "0.47.0"
axum = "0.8.9"
futures = "0.3.32"
futures-core = "0.3.32"
futures-util = "0.3.32"
pollster = "0.4.0"
pulsar = { version = "6.7.2", default-features = false, features = ["tokio-rustls-runtime"] }
hyper = { version = "1.9.0", features = ["http2", "http1", "server"] }
hyper-rustls = { version = "0.27.9", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] }
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] }
+1
View File
@@ -39,6 +39,7 @@ abd = "abd"
mak = "mak"
gae = "gae"
GAE = "GAE"
thr = "thr"
# s3-tests original test names (cannot be changed)
nonexisted = "nonexisted"
consts = "consts"
+48 -176
View File
@@ -14,28 +14,19 @@
use crate::AuditEntry;
use async_trait::async_trait;
use hashbrown::HashSet;
use rumqttc::QoS;
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_WEBHOOK_KEYS, ENV_AUDIT_MQTT_KEYS, ENV_AUDIT_WEBHOOK_KEYS};
use rustfs_config::{
AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR,
MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY,
MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT,
WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR,
WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
};
use rustfs_config::AUDIT_DEFAULT_DIR;
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_NATS_KEYS, AUDIT_PULSAR_KEYS, AUDIT_WEBHOOK_KEYS};
use rustfs_ecstore::config::KVS;
use rustfs_targets::{
Target,
error::TargetError,
target::{
mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url},
webhook::WebhookArgs,
config::{
build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config,
validate_pulsar_config, validate_webhook_config,
},
error::TargetError,
target::TargetType,
};
use std::time::Duration;
use tracing::{debug, warn};
use url::Url;
use std::collections::HashSet;
/// Trait for creating targets from configuration
#[async_trait]
@@ -49,10 +40,6 @@ pub trait TargetFactory: Send + Sync {
/// Returns a set of valid configuration field names for this target type.
/// This is used to filter environment variables.
fn get_valid_fields(&self) -> HashSet<String>;
/// Returns a set of valid configuration env field names for this target type.
/// This is used to filter environment variables.
fn get_valid_env_fields(&self) -> HashSet<String>;
}
/// Factory for creating Webhook targets
@@ -61,75 +48,18 @@ pub struct WebhookTargetFactory;
#[async_trait]
impl TargetFactory for WebhookTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
// All config values are now read directly from the merged `config` KVS.
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
let parsed_endpoint = endpoint.trim();
let endpoint_url = Url::parse(parsed_endpoint)
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
let args = WebhookArgs {
enable: true, // If we are here, it's already enabled.
endpoint: endpoint_url,
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
queue_dir: config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()),
queue_limit: config
.lookup(WEBHOOK_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(),
skip_tls_verify: config
.lookup(WEBHOOK_SKIP_TLS_VERIFY)
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT),
target_type: rustfs_targets::target::TargetType::AuditLog,
};
let args = build_webhook_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
let target = rustfs_targets::target::webhook::WebhookTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
// Validation also uses the merged `config` KVS directly.
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
debug!("endpoint: {}", endpoint);
let parsed_endpoint = endpoint.trim();
Url::parse(parsed_endpoint)
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default();
let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default();
if client_cert.is_empty() != client_key.is_empty() {
return Err(TargetError::Configuration(
"Both client_cert and client_key must be specified together".to_string(),
));
}
let queue_dir = config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string());
if !queue_dir.is_empty() && !std::path::Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
}
Ok(())
validate_webhook_config(config, AUDIT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
}
fn get_valid_env_fields(&self) -> HashSet<String> {
ENV_AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
}
}
/// Factory for creating MQTT targets
@@ -138,112 +68,54 @@ pub struct MQTTTargetFactory;
#[async_trait]
impl TargetFactory for MQTTTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let broker_url = Url::parse(&broker)
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
let topic = config
.lookup(MQTT_TOPIC)
.ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?;
let args = MQTTArgs {
enable: true, // Assumed enabled.
broker: broker_url,
topic,
qos: config
.lookup(MQTT_QOS)
.and_then(|v| v.parse::<u8>().ok())
.map(|q| match q {
0 => QoS::AtMostOnce,
1 => QoS::AtLeastOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtLeastOnce,
})
.unwrap_or(QoS::AtLeastOnce),
username: config.lookup(MQTT_USERNAME).unwrap_or_default(),
password: config.lookup(MQTT_PASSWORD).unwrap_or_default(),
max_reconnect_interval: config
.lookup(MQTT_RECONNECT_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(5)),
keep_alive: config
.lookup(MQTT_KEEP_ALIVE_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(30)),
tls: MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?,
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()),
queue_limit: config
.lookup(MQTT_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
target_type: rustfs_targets::target::TargetType::AuditLog,
};
let args = build_mqtt_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
let target = rustfs_targets::target::mqtt::MQTTTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let url = Url::parse(&broker)
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
let tls = MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?;
validate_mqtt_broker_url(&url, &tls)?;
if config.lookup(MQTT_TOPIC).is_none() {
return Err(TargetError::Configuration("Missing MQTT topic".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS) {
let qos = qos_str
.parse::<u8>()
.map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?;
if qos > 2 {
return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string()));
}
}
let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default();
if !queue_dir.is_empty() {
if !std::path::Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS)
&& qos_str == "0"
{
warn!("Using queue_dir with QoS 0 may result in event loss");
}
}
Ok(())
validate_mqtt_config(config)
}
fn get_valid_fields(&self) -> HashSet<String> {
AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
}
}
fn get_valid_env_fields(&self) -> HashSet<String> {
ENV_AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
pub struct NATSTargetFactory;
#[async_trait]
impl TargetFactory for NATSTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
let args = build_nats_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
let target = rustfs_targets::target::nats::NATSTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
validate_nats_config(config, AUDIT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
AUDIT_NATS_KEYS.iter().map(|s| s.to_string()).collect()
}
}
pub struct PulsarTargetFactory;
#[async_trait]
impl TargetFactory for PulsarTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
let args = build_pulsar_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
let target = rustfs_targets::target::pulsar::PulsarTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
validate_pulsar_config(config, AUDIT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
AUDIT_PULSAR_KEYS.iter().map(|s| s.to_string()).collect()
}
}
+15 -135
View File
@@ -14,18 +14,17 @@
use crate::{
AuditEntry, AuditError, AuditResult,
factory::{MQTTTargetFactory, TargetFactory, WebhookTargetFactory},
factory::{MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory},
};
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use hashbrown::{HashMap, HashSet};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, audit::AUDIT_ROUTE_PREFIX};
use hashbrown::HashMap;
use rustfs_config::audit::AUDIT_ROUTE_PREFIX;
use rustfs_ecstore::config::{Config, KVS};
use rustfs_targets::arn::TargetID;
use rustfs_targets::{Target, TargetError, target::ChannelTargetType};
use std::str::FromStr;
use rustfs_targets::{Target, TargetError, config::collect_target_configs, target::ChannelTargetType};
use std::sync::Arc;
use tracing::{debug, error, info, warn};
use tracing::{error, info};
/// Registry for managing audit targets
pub struct AuditRegistry {
@@ -52,6 +51,8 @@ impl AuditRegistry {
// Register built-in factories
registry.register(ChannelTargetType::Webhook.as_str(), Box::new(WebhookTargetFactory));
registry.register(ChannelTargetType::Mqtt.as_str(), Box::new(MQTTTargetFactory));
registry.register(ChannelTargetType::Nats.as_str(), Box::new(NATSTargetFactory));
registry.register(ChannelTargetType::Pulsar.as_str(), Box::new(PulsarTargetFactory));
registry
}
@@ -105,143 +106,22 @@ impl AuditRegistry {
&self,
config: &Config,
) -> AuditResult<Vec<Box<dyn Target<AuditEntry> + Send + Sync>>> {
// Collect only environment variables with the relevant prefix to reduce memory usage
let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect();
// A collection of asynchronous tasks for concurrently executing target creation
let mut tasks = FuturesUnordered::new();
// 1. Traverse all registered plants and process them by target type
for (target_type, factory) in &self.factories {
tracing::Span::current().record("target_type", target_type.as_str());
info!("Start working on target types...");
// 2. Prepare the configuration source
// 2.1. Get the configuration segment in the file, e.g. 'audit_webhook'
let section_name = format!("{AUDIT_ROUTE_PREFIX}{target_type}").to_lowercase();
let file_configs = config.0.get(&section_name).cloned().unwrap_or_default();
// 2.2. Get the default configuration for that type
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
debug!(?default_cfg, "Get the default configuration");
// *** Optimization point 1: Get all legitimate fields of the current target type ***
let valid_fields = factory.get_valid_fields();
debug!(?valid_fields, "Get the legitimate configuration fields");
// 3. Resolve instance IDs and configuration overrides from environment variables
let mut instance_ids_from_env = HashSet::new();
// 3.1. Instance discovery: Based on the '..._ENABLE_INSTANCEID' format
let enable_prefix =
format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}")
.to_uppercase();
for (key, value) in &all_env {
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false)
&& let Some(id) = key.strip_prefix(&enable_prefix)
&& !id.is_empty()
{
instance_ids_from_env.insert(id.to_lowercase());
}
}
// 3.2. Parse all relevant environment variable configurations
// 3.2.1. Build environment variable prefixes such as 'RUSTFS_AUDIT_WEBHOOK_'
let env_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}").to_uppercase();
// 3.2.2. 'env_overrides' is used to store configurations parsed from environment variables in the format: {instance id -> {field -> value}}
let mut env_overrides: HashMap<String, HashMap<String, String>> = HashMap::new();
for (key, value) in &all_env {
if let Some(rest) = key.strip_prefix(&env_prefix) {
// Use rsplitn to split from the right side to properly extract the INSTANCE_ID at the end
// Format: <FIELD_NAME>_<INSTANCE_ID> or <FIELD_NAME>
let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER);
// The first part from the right is INSTANCE_ID
let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER);
// The remaining part is FIELD_NAME
let field_name_part = parts.next();
let (field_name, instance_id) = match field_name_part {
// Case 1: The format is <FIELD_NAME>_<INSTANCE_ID>
// e.g., rest = "ENDPOINT_PRIMARY" -> field_name="ENDPOINT", instance_id="PRIMARY"
Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()),
// Case 2: The format is <FIELD_NAME> (without INSTANCE_ID)
// e.g., rest = "ENABLE" -> field_name="ENABLE", instance_id="" (Universal configuration `_ DEFAULT_DELIMITER`)
None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()),
};
// *** Optimization point 2: Verify whether the parsed field_name is legal ***
if !field_name.is_empty() && valid_fields.contains(&field_name) {
debug!(
instance_id = %if instance_id.is_empty() { DEFAULT_DELIMITER } else { &instance_id },
%field_name,
%value,
"Parsing to environment variables"
);
env_overrides
.entry(instance_id)
.or_default()
.insert(field_name, value.clone());
} else {
// Ignore illegal field names
warn!(
field_name = %field_name,
"Ignore environment variable fields, not found in the list of valid fields for target type {}",
target_type
);
}
}
}
debug!(?env_overrides, "Complete the environment variable analysis");
// 4. Determine all instance IDs that need to be processed
let mut all_instance_ids: HashSet<String> =
file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect();
all_instance_ids.extend(instance_ids_from_env);
debug!(?all_instance_ids, "Determine all instance IDs");
// 5. Merge configurations and create tasks for each instance
for id in all_instance_ids {
// 5.1. Merge configuration, priority: Environment variables > File instance configuration > File default configuration
let mut merged_config = default_cfg.clone();
// Instance-specific configuration in application files
if let Some(file_instance_cfg) = file_configs.get(&id) {
merged_config.extend(file_instance_cfg.clone());
}
// Application instance-specific environment variable configuration
if let Some(env_instance_cfg) = env_overrides.get(&id) {
// Convert HashMap<String, String> to KVS
let mut kvs_from_env = KVS::new();
for (k, v) in env_instance_cfg {
kvs_from_env.insert(k.clone(), v.clone());
}
merged_config.extend(kvs_from_env);
}
debug!(instance_id = %id, ?merged_config, "Complete configuration merge");
// 5.2. Check if the instance is enabled
let enabled = merged_config
.lookup(ENABLE_KEY)
.map(|v| {
EnableState::from_str(v.as_str())
.ok()
.map(|s| s.is_enabled())
.unwrap_or(false)
})
.unwrap_or(false);
if enabled {
info!(instance_id = %id, "Target is enabled, ready to create a task");
// 5.3. Create asynchronous tasks for enabled instances
let tid = id.clone();
let merged_config_arc = Arc::new(merged_config);
tasks.push(async move {
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
(tid, result)
});
} else {
info!(instance_id = %id, "Skip disabled target");
}
for (id, merged_config) in collect_target_configs(config, AUDIT_ROUTE_PREFIX, target_type, &valid_fields) {
info!(instance_id = %id, "Target is enabled, ready to create a task");
let tid = id.clone();
let merged_config_arc = Arc::new(merged_config);
tasks.push(async move {
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
(tid, result)
});
}
}
// 6. Concurrently execute all creation tasks and collect results
let mut successful_targets = Vec::new();
while let Some((id, result)) = tasks.next().await {
match result {
+12 -1
View File
@@ -17,9 +17,13 @@
//! webhook and MQTT audit-related settings.
mod mqtt;
mod nats;
mod pulsar;
mod webhook;
pub use mqtt::*;
pub use nats::*;
pub use pulsar::*;
pub use webhook::*;
use crate::DEFAULT_DELIMITER;
@@ -30,7 +34,14 @@ pub const AUDIT_ROUTE_PREFIX: &str = const_str::concat!(AUDIT_PREFIX, DEFAULT_DE
pub const AUDIT_WEBHOOK_SUB_SYS: &str = "audit_webhook";
pub const AUDIT_MQTT_SUB_SYS: &str = "audit_mqtt";
pub const AUDIT_NATS_SUB_SYS: &str = "audit_nats";
pub const AUDIT_PULSAR_SUB_SYS: &str = "audit_pulsar";
pub const AUDIT_STORE_EXTENSION: &str = ".audit";
#[allow(dead_code)]
pub const AUDIT_SUB_SYSTEMS: &[&str] = &[AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS];
pub const AUDIT_SUB_SYSTEMS: &[&str] = &[
AUDIT_MQTT_SUB_SYS,
AUDIT_NATS_SUB_SYS,
AUDIT_PULSAR_SUB_SYS,
AUDIT_WEBHOOK_SUB_SYS,
];
+60
View File
@@ -0,0 +1,60 @@
// 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.
pub const ENV_AUDIT_NATS_ENABLE: &str = "RUSTFS_AUDIT_NATS_ENABLE";
pub const ENV_AUDIT_NATS_ADDRESS: &str = "RUSTFS_AUDIT_NATS_ADDRESS";
pub const ENV_AUDIT_NATS_SUBJECT: &str = "RUSTFS_AUDIT_NATS_SUBJECT";
pub const ENV_AUDIT_NATS_USERNAME: &str = "RUSTFS_AUDIT_NATS_USERNAME";
pub const ENV_AUDIT_NATS_PASSWORD: &str = "RUSTFS_AUDIT_NATS_PASSWORD";
pub const ENV_AUDIT_NATS_TOKEN: &str = "RUSTFS_AUDIT_NATS_TOKEN";
pub const ENV_AUDIT_NATS_CREDENTIALS_FILE: &str = "RUSTFS_AUDIT_NATS_CREDENTIALS_FILE";
pub const ENV_AUDIT_NATS_TLS_CA: &str = "RUSTFS_AUDIT_NATS_TLS_CA";
pub const ENV_AUDIT_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_CERT";
pub const ENV_AUDIT_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_KEY";
pub const ENV_AUDIT_NATS_TLS_REQUIRED: &str = "RUSTFS_AUDIT_NATS_TLS_REQUIRED";
pub const ENV_AUDIT_NATS_QUEUE_DIR: &str = "RUSTFS_AUDIT_NATS_QUEUE_DIR";
pub const ENV_AUDIT_NATS_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_NATS_QUEUE_LIMIT";
pub const ENV_AUDIT_NATS_KEYS: &[&str; 13] = &[
ENV_AUDIT_NATS_ENABLE,
ENV_AUDIT_NATS_ADDRESS,
ENV_AUDIT_NATS_SUBJECT,
ENV_AUDIT_NATS_USERNAME,
ENV_AUDIT_NATS_PASSWORD,
ENV_AUDIT_NATS_TOKEN,
ENV_AUDIT_NATS_CREDENTIALS_FILE,
ENV_AUDIT_NATS_TLS_CA,
ENV_AUDIT_NATS_TLS_CLIENT_CERT,
ENV_AUDIT_NATS_TLS_CLIENT_KEY,
ENV_AUDIT_NATS_TLS_REQUIRED,
ENV_AUDIT_NATS_QUEUE_DIR,
ENV_AUDIT_NATS_QUEUE_LIMIT,
];
pub const AUDIT_NATS_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::NATS_ADDRESS,
crate::NATS_SUBJECT,
crate::NATS_USERNAME,
crate::NATS_PASSWORD,
crate::NATS_TOKEN,
crate::NATS_CREDENTIALS_FILE,
crate::NATS_TLS_CA,
crate::NATS_TLS_CLIENT_CERT,
crate::NATS_TLS_CLIENT_KEY,
crate::NATS_TLS_REQUIRED,
crate::NATS_QUEUE_DIR,
crate::NATS_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
+54
View File
@@ -0,0 +1,54 @@
// 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.
pub const ENV_AUDIT_PULSAR_ENABLE: &str = "RUSTFS_AUDIT_PULSAR_ENABLE";
pub const ENV_AUDIT_PULSAR_BROKER: &str = "RUSTFS_AUDIT_PULSAR_BROKER";
pub const ENV_AUDIT_PULSAR_TOPIC: &str = "RUSTFS_AUDIT_PULSAR_TOPIC";
pub const ENV_AUDIT_PULSAR_AUTH_TOKEN: &str = "RUSTFS_AUDIT_PULSAR_AUTH_TOKEN";
pub const ENV_AUDIT_PULSAR_USERNAME: &str = "RUSTFS_AUDIT_PULSAR_USERNAME";
pub const ENV_AUDIT_PULSAR_PASSWORD: &str = "RUSTFS_AUDIT_PULSAR_PASSWORD";
pub const ENV_AUDIT_PULSAR_TLS_CA: &str = "RUSTFS_AUDIT_PULSAR_TLS_CA";
pub const ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_AUDIT_PULSAR_TLS_ALLOW_INSECURE";
pub const ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION";
pub const ENV_AUDIT_PULSAR_QUEUE_DIR: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_DIR";
pub const ENV_AUDIT_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_LIMIT";
pub const ENV_AUDIT_PULSAR_KEYS: &[&str; 11] = &[
ENV_AUDIT_PULSAR_ENABLE,
ENV_AUDIT_PULSAR_BROKER,
ENV_AUDIT_PULSAR_TOPIC,
ENV_AUDIT_PULSAR_AUTH_TOKEN,
ENV_AUDIT_PULSAR_USERNAME,
ENV_AUDIT_PULSAR_PASSWORD,
ENV_AUDIT_PULSAR_TLS_CA,
ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE,
ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION,
ENV_AUDIT_PULSAR_QUEUE_DIR,
ENV_AUDIT_PULSAR_QUEUE_LIMIT,
];
pub const AUDIT_PULSAR_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::PULSAR_BROKER,
crate::PULSAR_TOPIC,
crate::PULSAR_AUTH_TOKEN,
crate::PULSAR_USERNAME,
crate::PULSAR_PASSWORD,
crate::PULSAR_TLS_CA,
crate::PULSAR_TLS_ALLOW_INSECURE,
crate::PULSAR_TLS_HOSTNAME_VERIFICATION,
crate::PULSAR_QUEUE_DIR,
crate::PULSAR_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
+9
View File
@@ -39,6 +39,9 @@ pub const ENV_CAPACITY_STAT_TIMEOUT: &str = "RUSTFS_CAPACITY_STAT_TIMEOUT";
/// Environment variable for sample rate
pub const ENV_CAPACITY_SAMPLE_RATE: &str = "RUSTFS_CAPACITY_SAMPLE_RATE";
/// Environment variable for metrics logging interval
pub const ENV_CAPACITY_METRICS_INTERVAL: &str = "RUSTFS_CAPACITY_METRICS_INTERVAL";
/// Environment variable for following symbolic links during capacity calculation
pub const ENV_CAPACITY_FOLLOW_SYMLINKS: &str = "RUSTFS_CAPACITY_FOLLOW_SYMLINKS";
@@ -89,6 +92,10 @@ pub const DEFAULT_STAT_TIMEOUT_SECS: u64 = 3;
/// Default: 200
pub const DEFAULT_SAMPLE_RATE: usize = 200;
/// Capacity metrics logging interval in seconds
/// Default: 600 seconds (10 minutes)
pub const DEFAULT_CAPACITY_METRICS_INTERVAL_SECS: u64 = 600;
/// Follow symbolic links during capacity calculation
/// Default: false (disabled for safety)
pub const DEFAULT_CAPACITY_FOLLOW_SYMLINKS: bool = false;
@@ -130,6 +137,7 @@ mod tests {
assert_eq!(ENV_CAPACITY_MAX_FILES_THRESHOLD, "RUSTFS_CAPACITY_MAX_FILES_THRESHOLD");
assert_eq!(ENV_CAPACITY_STAT_TIMEOUT, "RUSTFS_CAPACITY_STAT_TIMEOUT");
assert_eq!(ENV_CAPACITY_SAMPLE_RATE, "RUSTFS_CAPACITY_SAMPLE_RATE");
assert_eq!(ENV_CAPACITY_METRICS_INTERVAL, "RUSTFS_CAPACITY_METRICS_INTERVAL");
assert_eq!(ENV_CAPACITY_FOLLOW_SYMLINKS, "RUSTFS_CAPACITY_FOLLOW_SYMLINKS");
assert_eq!(ENV_CAPACITY_MAX_SYMLINK_DEPTH, "RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH");
assert_eq!(ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, "RUSTFS_CAPACITY_ENABLE_DYNAMIC_TIMEOUT");
@@ -147,6 +155,7 @@ mod tests {
assert_eq!(DEFAULT_MAX_FILES_THRESHOLD, 200_000);
assert_eq!(DEFAULT_STAT_TIMEOUT_SECS, 3);
assert_eq!(DEFAULT_SAMPLE_RATE, 200);
assert_eq!(DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, 600);
assert_eq!(DEFAULT_CAPACITY_MAX_SYMLINK_DEPTH, 3);
assert_eq!(DEFAULT_CAPACITY_MIN_TIMEOUT_SECS, 2);
assert_eq!(DEFAULT_CAPACITY_MAX_TIMEOUT_SECS, 15);
+5 -1
View File
@@ -17,6 +17,7 @@ pub const OIDC_CONFIG_URL: &str = "config_url";
pub const OIDC_CLIENT_ID: &str = "client_id";
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
pub const OIDC_SCOPES: &str = "scopes";
pub const OIDC_OTHER_AUDIENCES: &str = "other_audiences";
pub const OIDC_REDIRECT_URI: &str = "redirect_uri";
pub const OIDC_REDIRECT_URI_DYNAMIC: &str = "redirect_uri_dynamic";
pub const OIDC_CLAIM_NAME: &str = "claim_name";
@@ -34,6 +35,7 @@ pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
pub const ENV_IDENTITY_OPENID_OTHER_AUDIENCES: &str = "RUSTFS_IDENTITY_OPENID_OTHER_AUDIENCES";
pub const ENV_IDENTITY_OPENID_REDIRECT_URI: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI";
pub const ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC";
pub const ENV_IDENTITY_OPENID_CLAIM_NAME: &str = "RUSTFS_IDENTITY_OPENID_CLAIM_NAME";
@@ -46,12 +48,13 @@ pub const ENV_IDENTITY_OPENID_EMAIL_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_EMAIL_
pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM";
/// List of all environment variable keys for an OIDC provider.
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 15] = &[
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 16] = &[
ENV_IDENTITY_OPENID_ENABLE,
ENV_IDENTITY_OPENID_CONFIG_URL,
ENV_IDENTITY_OPENID_CLIENT_ID,
ENV_IDENTITY_OPENID_CLIENT_SECRET,
ENV_IDENTITY_OPENID_SCOPES,
ENV_IDENTITY_OPENID_OTHER_AUDIENCES,
ENV_IDENTITY_OPENID_REDIRECT_URI,
ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC,
ENV_IDENTITY_OPENID_CLAIM_NAME,
@@ -71,6 +74,7 @@ pub const IDENTITY_OPENID_KEYS: &[&str] = &[
OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET,
OIDC_SCOPES,
OIDC_OTHER_AUDIENCES,
OIDC_REDIRECT_URI,
OIDC_REDIRECT_URI_DYNAMIC,
OIDC_CLAIM_NAME,
+24
View File
@@ -41,6 +41,30 @@ pub const MQTT_TLS_CLIENT_KEY: &str = "tls_client_key";
pub const MQTT_TLS_TRUST_LEAF_AS_CA: &str = "tls_trust_leaf_as_ca";
pub const MQTT_WS_PATH_ALLOWLIST: &str = "ws_path_allowlist";
pub const NATS_ADDRESS: &str = "address";
pub const NATS_SUBJECT: &str = "subject";
pub const NATS_USERNAME: &str = "username";
pub const NATS_PASSWORD: &str = "password";
pub const NATS_TOKEN: &str = "token";
pub const NATS_CREDENTIALS_FILE: &str = "credentials_file";
pub const NATS_TLS_CA: &str = "tls_ca";
pub const NATS_TLS_CLIENT_CERT: &str = "tls_client_cert";
pub const NATS_TLS_CLIENT_KEY: &str = "tls_client_key";
pub const NATS_TLS_REQUIRED: &str = "tls_required";
pub const NATS_QUEUE_DIR: &str = "queue_dir";
pub const NATS_QUEUE_LIMIT: &str = "queue_limit";
pub const PULSAR_BROKER: &str = "broker";
pub const PULSAR_TOPIC: &str = "topic";
pub const PULSAR_AUTH_TOKEN: &str = "auth_token";
pub const PULSAR_USERNAME: &str = "username";
pub const PULSAR_PASSWORD: &str = "password";
pub const PULSAR_TLS_CA: &str = "tls_ca";
pub const PULSAR_TLS_ALLOW_INSECURE: &str = "tls_allow_insecure";
pub const PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "tls_hostname_verification";
pub const PULSAR_QUEUE_DIR: &str = "queue_dir";
pub const PULSAR_QUEUE_LIMIT: &str = "queue_limit";
/// Environment variable controlling whether target queue files are Snappy-compressed.
/// Applies to both notify and audit target queue stores.
pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
+11 -1
View File
@@ -14,11 +14,15 @@
mod arn;
mod mqtt;
mod nats;
mod pulsar;
mod store;
mod webhook;
pub use arn::*;
pub use mqtt::*;
pub use nats::*;
pub use pulsar::*;
pub use store::*;
pub use webhook::*;
@@ -64,7 +68,12 @@ pub const ENV_NOTIFY_SEND_CONCURRENCY: &str = "RUSTFS_NOTIFY_SEND_CONCURRENCY";
pub const DEFAULT_NOTIFY_SEND_CONCURRENCY: usize = 64;
#[allow(dead_code)]
pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS];
pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[
NOTIFY_MQTT_SUB_SYS,
NOTIFY_NATS_SUB_SYS,
NOTIFY_PULSAR_SUB_SYS,
NOTIFY_WEBHOOK_SUB_SYS,
];
#[allow(dead_code)]
pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka";
@@ -83,4 +92,5 @@ pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp";
pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres";
#[allow(dead_code)]
pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis";
pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar";
pub const NOTIFY_WEBHOOK_SUB_SYS: &str = "notify_webhook";
+60
View File
@@ -0,0 +1,60 @@
// 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.
pub const NOTIFY_NATS_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::NATS_ADDRESS,
crate::NATS_SUBJECT,
crate::NATS_USERNAME,
crate::NATS_PASSWORD,
crate::NATS_TOKEN,
crate::NATS_CREDENTIALS_FILE,
crate::NATS_TLS_CA,
crate::NATS_TLS_CLIENT_CERT,
crate::NATS_TLS_CLIENT_KEY,
crate::NATS_TLS_REQUIRED,
crate::NATS_QUEUE_DIR,
crate::NATS_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
pub const ENV_NOTIFY_NATS_ENABLE: &str = "RUSTFS_NOTIFY_NATS_ENABLE";
pub const ENV_NOTIFY_NATS_ADDRESS: &str = "RUSTFS_NOTIFY_NATS_ADDRESS";
pub const ENV_NOTIFY_NATS_SUBJECT: &str = "RUSTFS_NOTIFY_NATS_SUBJECT";
pub const ENV_NOTIFY_NATS_USERNAME: &str = "RUSTFS_NOTIFY_NATS_USERNAME";
pub const ENV_NOTIFY_NATS_PASSWORD: &str = "RUSTFS_NOTIFY_NATS_PASSWORD";
pub const ENV_NOTIFY_NATS_TOKEN: &str = "RUSTFS_NOTIFY_NATS_TOKEN";
pub const ENV_NOTIFY_NATS_CREDENTIALS_FILE: &str = "RUSTFS_NOTIFY_NATS_CREDENTIALS_FILE";
pub const ENV_NOTIFY_NATS_TLS_CA: &str = "RUSTFS_NOTIFY_NATS_TLS_CA";
pub const ENV_NOTIFY_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_CERT";
pub const ENV_NOTIFY_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_KEY";
pub const ENV_NOTIFY_NATS_TLS_REQUIRED: &str = "RUSTFS_NOTIFY_NATS_TLS_REQUIRED";
pub const ENV_NOTIFY_NATS_QUEUE_DIR: &str = "RUSTFS_NOTIFY_NATS_QUEUE_DIR";
pub const ENV_NOTIFY_NATS_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_NATS_QUEUE_LIMIT";
pub const ENV_NOTIFY_NATS_KEYS: &[&str; 13] = &[
ENV_NOTIFY_NATS_ENABLE,
ENV_NOTIFY_NATS_ADDRESS,
ENV_NOTIFY_NATS_SUBJECT,
ENV_NOTIFY_NATS_USERNAME,
ENV_NOTIFY_NATS_PASSWORD,
ENV_NOTIFY_NATS_TOKEN,
ENV_NOTIFY_NATS_CREDENTIALS_FILE,
ENV_NOTIFY_NATS_TLS_CA,
ENV_NOTIFY_NATS_TLS_CLIENT_CERT,
ENV_NOTIFY_NATS_TLS_CLIENT_KEY,
ENV_NOTIFY_NATS_TLS_REQUIRED,
ENV_NOTIFY_NATS_QUEUE_DIR,
ENV_NOTIFY_NATS_QUEUE_LIMIT,
];
+54
View File
@@ -0,0 +1,54 @@
// 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.
pub const NOTIFY_PULSAR_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::PULSAR_BROKER,
crate::PULSAR_TOPIC,
crate::PULSAR_AUTH_TOKEN,
crate::PULSAR_USERNAME,
crate::PULSAR_PASSWORD,
crate::PULSAR_TLS_CA,
crate::PULSAR_TLS_ALLOW_INSECURE,
crate::PULSAR_TLS_HOSTNAME_VERIFICATION,
crate::PULSAR_QUEUE_DIR,
crate::PULSAR_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
pub const ENV_NOTIFY_PULSAR_ENABLE: &str = "RUSTFS_NOTIFY_PULSAR_ENABLE";
pub const ENV_NOTIFY_PULSAR_BROKER: &str = "RUSTFS_NOTIFY_PULSAR_BROKER";
pub const ENV_NOTIFY_PULSAR_TOPIC: &str = "RUSTFS_NOTIFY_PULSAR_TOPIC";
pub const ENV_NOTIFY_PULSAR_AUTH_TOKEN: &str = "RUSTFS_NOTIFY_PULSAR_AUTH_TOKEN";
pub const ENV_NOTIFY_PULSAR_USERNAME: &str = "RUSTFS_NOTIFY_PULSAR_USERNAME";
pub const ENV_NOTIFY_PULSAR_PASSWORD: &str = "RUSTFS_NOTIFY_PULSAR_PASSWORD";
pub const ENV_NOTIFY_PULSAR_TLS_CA: &str = "RUSTFS_NOTIFY_PULSAR_TLS_CA";
pub const ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_NOTIFY_PULSAR_TLS_ALLOW_INSECURE";
pub const ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION";
pub const ENV_NOTIFY_PULSAR_QUEUE_DIR: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_DIR";
pub const ENV_NOTIFY_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_LIMIT";
pub const ENV_NOTIFY_PULSAR_KEYS: &[&str; 11] = &[
ENV_NOTIFY_PULSAR_ENABLE,
ENV_NOTIFY_PULSAR_BROKER,
ENV_NOTIFY_PULSAR_TOPIC,
ENV_NOTIFY_PULSAR_AUTH_TOKEN,
ENV_NOTIFY_PULSAR_USERNAME,
ENV_NOTIFY_PULSAR_PASSWORD,
ENV_NOTIFY_PULSAR_TLS_CA,
ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE,
ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION,
ENV_NOTIFY_PULSAR_QUEUE_DIR,
ENV_NOTIFY_PULSAR_QUEUE_LIMIT,
];
@@ -70,7 +70,7 @@ use time::OffsetDateTime;
use tokio::select;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, mpsc};
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use xxhash_rust::xxh64;
@@ -413,6 +413,7 @@ impl ExpiryState {
let v = v.expect("received None after None check");
if v.as_any().is::<ExpiryTask>() {
let v = v.as_any().downcast_ref::<ExpiryTask>().expect("ExpiryTask downcast failed");
//debug!("lifecycle expiry worker received task: {:?}", v.obj_info);
if !v.obj_info.transitioned_object.status.is_empty() {
apply_expiry_on_transitioned_object(api.clone(), &v.obj_info, &v.event, &v.src).await;
} else {
@@ -1340,8 +1341,8 @@ pub async fn expire_transitioned_object(
&oi.transitioned_object.tier,
)
.await;
if ret.is_err() {
//transitionLogIf(ctx, err);
if let Err(e) = &ret {
error!("Failed to delete remote transitioned object {}: {:?}", oi.transitioned_object.name, e);
}
mark_delete_opts_skip_decommissioned_on_remote_success(&mut opts, ret.is_ok());
@@ -1354,9 +1355,9 @@ pub async fn expire_transitioned_object(
}
};
schedule_lifecycle_replication_delete_if_needed(oi).await;
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
//defer auditLogLifecycle(ctx, *oi, ILMExpiry, tags, traceFn)
//audit_log_lifecycle(oi, ILMExpiry, tags);
let event_name = if oi.delete_marker {
EventName::LifecycleExpirationDelete
@@ -1770,7 +1771,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
let time_ilm = Metrics::time_ilm(lc_event.action);
//debug!("lc_event.action: {:?}", lc_event.action);
//debug!("opts: {:?}", opts);
debug!("expiry_on_non_transitioned_objects opts: {:?}", opts);
let mut dobj = match api.delete_object(&oi.bucket, &encode_dir_object(&oi.name), opts).await {
Ok(dobj) => dobj,
Err(e) => {
@@ -1778,7 +1779,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
return false;
}
};
schedule_lifecycle_replication_delete_if_needed(oi).await;
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
//debug!("dobj: {:?}", dobj);
if dobj.name.is_empty() {
dobj = oi.clone();
@@ -1819,25 +1820,55 @@ pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &
true
}
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo) {
if !oi.delete_marker || oi.version_id.is_none() {
return;
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> crate::store_api::DeletedObject {
if dobj.delete_marker {
return crate::store_api::DeletedObject {
object_name: oi.name.clone(),
delete_marker: true,
delete_marker_version_id: dobj.version_id,
delete_marker_mtime: dobj.mod_time.or(oi.mod_time),
..Default::default()
};
}
let replication_state = lifecycle_delete_replication_state(oi).await;
if oi.delete_marker && oi.version_id.is_some() {
return crate::store_api::DeletedObject {
object_name: oi.name.clone(),
delete_marker: false,
delete_marker_version_id: oi.version_id,
delete_marker_mtime: oi.mod_time,
..Default::default()
};
}
crate::store_api::DeletedObject {
object_name: oi.name.clone(),
delete_marker: false,
version_id: oi.version_id,
delete_marker_mtime: oi.mod_time,
..Default::default()
}
}
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo, dobj: &ObjectInfo) {
let mut delete_object = lifecycle_deleted_object(oi, dobj);
let version_id = if delete_object.delete_marker {
None
} else if delete_object.delete_marker_version_id.is_some() {
delete_object.delete_marker_version_id
} else {
delete_object.version_id
};
let replication_state = lifecycle_delete_replication_state(oi, version_id).await;
if replication_state.is_none() {
return;
}
delete_object.replication_state = replication_state;
schedule_replication_delete(DeletedObjectReplicationInfo {
delete_object: crate::store_api::DeletedObject {
object_name: oi.name.clone(),
delete_marker_version_id: oi.version_id,
delete_marker: false,
delete_marker_mtime: oi.mod_time,
replication_state,
..Default::default()
},
delete_object,
bucket: oi.bucket.clone(),
event_type: REPLICATE_INCOMING_DELETE.to_string(),
..Default::default()
@@ -1845,21 +1876,56 @@ async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo) {
.await;
}
async fn lifecycle_delete_replication_state(oi: &ObjectInfo) -> Option<ReplicationState> {
if !oi.replication_decision.is_empty() || oi.version_purge_status == VersionPurgeStatusType::Pending {
fn should_reuse_lifecycle_delete_replication_state(oi: &ObjectInfo, version_delete: bool) -> bool {
let state = oi.replication_state();
if version_delete {
oi.version_purge_status == VersionPurgeStatusType::Pending && !state.purge_targets.is_empty()
} else {
oi.replication_status == rustfs_filemeta::ReplicationStatusType::Pending && !state.targets.is_empty()
}
}
fn lifecycle_version_purge_state_from_completed_targets(oi: &ObjectInfo) -> Option<ReplicationState> {
if oi.replication_status != rustfs_filemeta::ReplicationStatusType::Completed {
return None;
}
let targets = oi.replication_state().targets;
if targets.is_empty() {
return None;
}
let pending_status = targets.keys().map(|arn| format!("{arn}=PENDING;")).collect::<String>();
Some(ReplicationState {
replicate_decision_str: oi.replication_decision.clone(),
version_purge_status_internal: Some(pending_status.clone()),
purge_targets: rustfs_filemeta::version_purge_statuses_map(&pending_status),
..Default::default()
})
}
async fn lifecycle_delete_replication_state(oi: &ObjectInfo, version_id: Option<Uuid>) -> Option<ReplicationState> {
if should_reuse_lifecycle_delete_replication_state(oi, version_id.is_some()) {
return Some(oi.replication_state());
}
if version_id.is_some()
&& let Some(state) = lifecycle_version_purge_state_from_completed_targets(oi)
{
return Some(state);
}
let dsc = check_replicate_delete(
&oi.bucket,
&ObjectToDelete {
object_name: oi.name.clone(),
version_id: oi.version_id,
version_id,
..Default::default()
},
oi,
&ObjectOptions {
version_id: oi.version_id.map(|v| v.to_string()),
version_id: version_id.map(|v| v.to_string()),
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
..Default::default()
},
@@ -1870,17 +1936,23 @@ async fn lifecycle_delete_replication_state(oi: &ObjectInfo) -> Option<Replicati
return None;
}
Some(replication_state_for_version_delete(dsc))
Some(replication_state_for_delete(dsc, version_id.is_some()))
}
fn replication_state_for_version_delete(dsc: ReplicateDecision) -> ReplicationState {
fn replication_state_for_delete(dsc: ReplicateDecision, version_delete: bool) -> ReplicationState {
let pending_status = dsc.pending_status();
ReplicationState {
let mut state = ReplicationState {
replicate_decision_str: dsc.to_string(),
version_purge_status_internal: pending_status.clone(),
purge_targets: rustfs_filemeta::version_purge_statuses_map(pending_status.as_deref().unwrap_or_default()),
..Default::default()
};
if version_delete {
state.version_purge_status_internal = pending_status.clone();
state.purge_targets = rustfs_filemeta::version_purge_statuses_map(pending_status.as_deref().unwrap_or_default());
} else {
state.replication_status_internal = pending_status.clone();
state.targets = rustfs_filemeta::replication_statuses_map(pending_status.as_deref().unwrap_or_default());
}
state
}
pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
@@ -1906,7 +1978,9 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
mod tests {
use super::{
StaleMultipartUploadCandidate, cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
lifecycle_deleted_object, lifecycle_version_purge_state_from_completed_targets,
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
should_reuse_lifecycle_delete_replication_state,
};
use crate::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
use crate::bucket::metadata_sys;
@@ -1917,8 +1991,9 @@ mod tests {
use crate::set_disk::{RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY};
use crate::store::ECStore;
use crate::store_api::{
BucketOperations, BucketOptions, MakeBucketOptions, MultipartOperations, ObjectOptions, PutObjReader,
BucketOperations, BucketOptions, MakeBucketOptions, MultipartOperations, ObjectInfo, ObjectOptions, PutObjReader,
};
use rustfs_filemeta::{ReplicateDecision, VersionPurgeStatusType};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -1960,6 +2035,136 @@ mod tests {
assert!(opts.skip_decommissioned);
}
#[test]
fn lifecycle_deleted_object_uses_delete_marker_created_by_expiry() {
let source = ObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
..Default::default()
};
let delete_result = ObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
delete_marker: true,
version_id: Some(Uuid::new_v4()),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
let deleted = lifecycle_deleted_object(&source, &delete_result);
assert!(deleted.delete_marker);
assert_eq!(deleted.delete_marker_version_id, delete_result.version_id);
assert_eq!(deleted.version_id, None);
assert_eq!(deleted.object_name, "key");
}
#[test]
fn lifecycle_deleted_object_uses_version_id_for_noncurrent_version_purge() {
let version_id = Uuid::new_v4();
let source = ObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
version_id: Some(version_id),
..Default::default()
};
let deleted = lifecycle_deleted_object(&source, &ObjectInfo::default());
assert!(!deleted.delete_marker);
assert_eq!(deleted.version_id, Some(version_id));
assert_eq!(deleted.delete_marker_version_id, None);
}
#[test]
fn lifecycle_deleted_object_uses_delete_marker_version_for_marker_purge() {
let version_id = Uuid::new_v4();
let source = ObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
delete_marker: true,
version_id: Some(version_id),
..Default::default()
};
let deleted = lifecycle_deleted_object(&source, &ObjectInfo::default());
assert!(!deleted.delete_marker);
assert_eq!(deleted.delete_marker_version_id, Some(version_id));
assert_eq!(deleted.version_id, None);
}
#[test]
fn replication_state_for_delete_uses_replication_targets_for_current_delete() {
let arn = "arn:aws:s3:::target-bucket";
let mut dsc = ReplicateDecision::default();
dsc.set(rustfs_filemeta::ReplicateTargetDecision::new(arn.to_string(), true, false));
let state = replication_state_for_delete(dsc, false);
assert_eq!(state.replication_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
assert!(state.version_purge_status_internal.is_none());
assert!(state.targets.contains_key(arn));
}
#[test]
fn replication_state_for_delete_uses_purge_targets_for_version_delete() {
let arn = "arn:aws:s3:::target-bucket";
let mut dsc = ReplicateDecision::default();
dsc.set(rustfs_filemeta::ReplicateTargetDecision::new(arn.to_string(), true, false));
let state = replication_state_for_delete(dsc, true);
assert_eq!(state.version_purge_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
assert!(state.replication_status_internal.is_none());
assert!(state.purge_targets.contains_key(arn));
}
#[test]
fn lifecycle_delete_replication_state_reuses_only_pending_version_purge_state() {
let oi = ObjectInfo {
version_purge_status: VersionPurgeStatusType::Pending,
version_purge_status_internal: Some("arn:aws:s3:::target=PENDING;".to_string()),
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
..Default::default()
};
assert!(should_reuse_lifecycle_delete_replication_state(&oi, true));
assert!(!should_reuse_lifecycle_delete_replication_state(&oi, false));
}
#[test]
fn lifecycle_delete_replication_state_does_not_reuse_put_replication_for_version_delete() {
let oi = ObjectInfo {
replication_status: rustfs_filemeta::ReplicationStatusType::Completed,
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
..Default::default()
};
assert!(
!should_reuse_lifecycle_delete_replication_state(&oi, true),
"version purges must not reuse plain object replication state from prior PUT/delete-marker replication"
);
}
#[test]
fn lifecycle_version_purge_state_from_completed_targets_derives_pending_purge_targets() {
let oi = ObjectInfo {
replication_status: rustfs_filemeta::ReplicationStatusType::Completed,
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
..Default::default()
};
let state = lifecycle_version_purge_state_from_completed_targets(&oi)
.expect("completed replication targets should be convertible into version-purge targets");
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:aws:s3:::target=PENDING;"));
assert!(state.purge_targets.contains_key("arn:aws:s3:::target"));
assert_eq!(state.replicate_decision_str, oi.replication_decision);
}
static STALE_MULTIPART_TEST_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
+2 -2
View File
@@ -439,8 +439,8 @@ impl Lifecycle for BucketLifecycleConfiguration {
async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, _newer_noncurrent_versions: usize) -> Event {
let mut events = Vec::<Event>::new();
debug!(
"eval_inner: object={}, mod_time={:?}, now={:?}, is_latest={}, delete_marker={}",
obj.name, obj.mod_time, now, obj.is_latest, obj.delete_marker
"eval_inner: object={}, mod_time={:?}, successor_mod_time={:?}, now={:?}, is_latest={}, delete_marker={}",
obj.name, obj.mod_time, obj.successor_mod_time, now, obj.is_latest, obj.delete_marker
);
// Gracefully handle missing mod_time instead of panicking
@@ -1,4 +1,3 @@
#![allow(unused_imports)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,6 +11,7 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
@@ -152,6 +152,10 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
}
if obj.op_type == ReplicationType::Delete {
if !rule.metadata_replicate(obj) {
return false;
}
if obj.version_id.is_some() {
if obj.delete_marker {
return rule.delete_marker_replication.clone().is_some_and(|d| {
@@ -85,7 +85,7 @@ use tokio::task::JoinSet;
use tokio::time::Duration as TokioDuration;
use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, instrument, warn};
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
pub(crate) const REPLICATION_DIR: &str = ".replication";
@@ -1272,15 +1272,7 @@ pub async fn check_replicate_delete(
return ReplicateDecision::default();
}
let opts = ObjectOpts {
name: dobj.object_name.clone(),
ssec: is_ssec_encrypted(&oi.user_defined),
user_tags: oi.user_tags.clone(),
delete_marker: oi.delete_marker,
version_id: dobj.version_id,
op_type: ReplicationType::Delete,
..Default::default()
};
let opts = delete_replication_object_opts(dobj, oi);
let tgt_arns = rcfg.filter_target_arns(&opts);
let mut dsc = ReplicateDecision::new();
@@ -1332,6 +1324,19 @@ pub async fn check_replicate_delete(
dsc
}
fn delete_replication_object_opts(dobj: &ObjectToDelete, oi: &ObjectInfo) -> ObjectOpts {
ObjectOpts {
name: dobj.object_name.clone(),
ssec: is_ssec_encrypted(&oi.user_defined),
user_tags: oi.user_tags.clone(),
delete_marker: oi.delete_marker,
version_id: dobj.version_id,
op_type: ReplicationType::Delete,
replica: oi.replication_status == ReplicationStatusType::Replica,
..Default::default()
}
}
/// Check if the user-defined metadata contains SSEC encryption headers
fn is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
user_defined.contains_key(SSEC_ALGORITHM_HEADER)
@@ -1703,7 +1708,7 @@ pub async fn replicate_delete<S: StorageAPI>(dobj: DeletedObjectReplicationInfo,
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
if !is_version_purge && dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
if should_retry_delete_marker_purge(&dobj.delete_object) {
let bucket_clone = bucket.clone();
let dobj_clone = dobj.clone();
let dsc_clone = dsc.clone();
@@ -2061,6 +2066,10 @@ fn is_version_delete_replication(dobj: &DeletedObject) -> bool {
dobj.version_id.is_some() || (dobj.delete_marker_version_id.is_some() && !dobj.delete_marker)
}
fn should_retry_delete_marker_purge(dobj: &DeletedObject) -> bool {
dobj.delete_marker_version_id.is_some()
}
fn is_retryable_delete_replication_head_error(is_not_found: bool, code: Option<&str>) -> bool {
!is_not_found && !matches!(code, Some("MethodNotAllowed" | "405"))
}
@@ -2152,6 +2161,14 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
.await
{
Ok(_) => {
debug!(
bucket = tgt_client.bucket,
object = dobj.delete_object.object_name,
version_id = ?version_id,
delete_marker = dobj.delete_object.delete_marker,
is_version_purge,
"replicate_delete_to_target succeeded"
);
if !is_version_purge {
rinfo.replication_status = ReplicationStatusType::Completed;
} else {
@@ -2159,6 +2176,15 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
}
}
Err(e) => {
warn!(
bucket = tgt_client.bucket,
object = dobj.delete_object.object_name,
version_id = ?version_id,
delete_marker = dobj.delete_object.delete_marker,
is_version_purge,
error = %e,
"replicate_delete_to_target failed"
);
rinfo.error = Some(e.to_string());
if !is_version_purge {
rinfo.replication_status = ReplicationStatusType::Failed;
@@ -3603,6 +3629,49 @@ mod tests {
);
}
#[test]
fn test_delete_replication_object_opts_marks_replica_deletes() {
let dobj = ObjectToDelete {
object_name: "obj".to_string(),
version_id: Some(Uuid::new_v4()),
..Default::default()
};
let oi = ObjectInfo {
bucket: "b".to_string(),
name: "obj".to_string(),
replication_status: ReplicationStatusType::Replica,
..Default::default()
};
let opts = delete_replication_object_opts(&dobj, &oi);
assert!(
opts.replica,
"replica deletes must preserve replica status for downstream ReplicaModifications rules"
);
assert_eq!(opts.version_id, dobj.version_id);
assert_eq!(opts.name, dobj.object_name);
assert_eq!(opts.op_type, ReplicationType::Delete);
}
#[test]
fn test_delete_replication_object_opts_keeps_non_replica_deletes_local() {
let dobj = ObjectToDelete {
object_name: "obj".to_string(),
..Default::default()
};
let oi = ObjectInfo {
bucket: "b".to_string(),
name: "obj".to_string(),
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
let opts = delete_replication_object_opts(&dobj, &oi);
assert!(!opts.replica, "source-originated deletes should not be treated as replica modifications");
}
#[test]
fn test_is_version_delete_replication_for_delete_marker_version_purge() {
let dobj = DeletedObject {
@@ -3631,6 +3700,34 @@ mod tests {
);
}
#[test]
fn test_should_retry_delete_marker_purge_for_version_purge() {
let dobj = DeletedObject {
delete_marker: false,
delete_marker_version_id: Some(Uuid::new_v4()),
..Default::default()
};
assert!(
should_retry_delete_marker_purge(&dobj),
"delete-marker version purge should schedule delayed target cleanup in case the target marker arrives late"
);
}
#[test]
fn test_should_retry_delete_marker_purge_for_delete_marker_creation() {
let dobj = DeletedObject {
delete_marker: true,
delete_marker_version_id: Some(Uuid::new_v4()),
..Default::default()
};
assert!(
should_retry_delete_marker_purge(&dobj),
"delete-marker creation should keep the late-arrival cleanup path so downstream purges can catch up"
);
}
#[test]
fn test_is_retryable_delete_replication_head_error_allows_delete_marker_head_responses() {
assert!(
+23 -13
View File
@@ -100,7 +100,7 @@ pub fn http_resp_to_error_response(
bucket_name: &str,
object_name: &str,
) -> ErrorResponse {
let err_body = String::from_utf8(b).unwrap();
let err_body = String::from_utf8_lossy(&b).to_string();
if h.is_empty() || resp_status.is_client_error() || resp_status.is_server_error() {
return ErrorResponse {
status_code: resp_status,
@@ -178,36 +178,46 @@ pub fn http_resp_to_error_response(
};
}
}
} else {
err_resp = err_resp_.unwrap();
} else if let Ok(parsed_resp) = err_resp_ {
err_resp = parsed_resp;
}
err_resp.status_code = resp_status;
if let Some(server_name) = h.get("Server") {
err_resp.server = server_name.to_str().expect("err").to_string();
if let Ok(server_str) = server_name.to_str() {
err_resp.server = server_str.to_string();
}
}
let code = h.get("x-minio-error-code");
if code.is_some() {
err_resp.code = S3ErrorCode::Custom(code.expect("err").to_str().expect("err").into());
if let Some(code) = h.get("x-minio-error-code") {
if let Ok(code_str) = code.to_str() {
err_resp.code = S3ErrorCode::Custom(code_str.into());
}
}
let desc = h.get("x-minio-error-desc");
if desc.is_some() {
err_resp.message = desc.expect("err").to_str().expect("err").trim_matches('"').to_string();
if let Some(desc) = h.get("x-minio-error-desc") {
if let Ok(desc_str) = desc.to_str() {
err_resp.message = desc_str.trim_matches('"').to_string();
}
}
if err_resp.request_id == "" {
if let Some(x_amz_request_id) = h.get("x-amz-request-id") {
err_resp.request_id = x_amz_request_id.to_str().expect("err").to_string();
if let Ok(request_id_str) = x_amz_request_id.to_str() {
err_resp.request_id = request_id_str.to_string();
}
}
}
if err_resp.host_id == "" {
if let Some(x_amz_id_2) = h.get("x-amz-id-2") {
err_resp.host_id = x_amz_id_2.to_str().expect("err").to_string();
if let Ok(host_id_str) = x_amz_id_2.to_str() {
err_resp.host_id = host_id_str.to_string();
}
}
}
if err_resp.region == "" {
if let Some(x_amz_bucket_region) = h.get("x-amz-bucket-region") {
err_resp.region = x_amz_bucket_region.to_str().expect("err").to_string();
if let Ok(region_str) = x_amz_bucket_region.to_str() {
err_resp.region = region_str.to_string();
}
}
}
if err_resp.code == S3ErrorCode::InvalidLocationConstraint/*InvalidRegion*/ && err_resp.region != "" {
+1 -1
View File
@@ -1,4 +1,3 @@
#![allow(clippy::map_entry)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,6 +11,7 @@
// 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.
#![allow(clippy::map_entry)]
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
+57 -37
View File
@@ -137,21 +137,21 @@ impl Default for PutObjectOptions {
impl PutObjectOptions {
fn set_match_etag(&mut self, etag: &str) {
if etag == "*" {
self.custom_header
.insert("If-Match", HeaderValue::from_str("*").expect("err"));
self.custom_header.insert("If-Match", HeaderValue::from_static("*"));
} else {
self.custom_header
.insert("If-Match", HeaderValue::from_str(&format!("\"{}\"", etag)).expect("err"));
if let Ok(etag_value) = HeaderValue::from_str(&format!("\"{}\"", etag)) {
self.custom_header.insert("If-Match", etag_value);
}
}
}
fn set_match_etag_except(&mut self, etag: &str) {
if etag == "*" {
self.custom_header
.insert("If-None-Match", HeaderValue::from_str("*").expect("err"));
self.custom_header.insert("If-None-Match", HeaderValue::from_static("*"));
} else {
self.custom_header
.insert("If-None-Match", HeaderValue::from_str(&format!("\"{etag}\"")).expect("err"));
if let Ok(etag_value) = HeaderValue::from_str(&format!("\"{etag}\"")) {
self.custom_header.insert("If-None-Match", etag_value);
}
}
}
@@ -162,59 +162,75 @@ impl PutObjectOptions {
if content_type == "" {
content_type = "application/octet-stream".to_string();
}
header.insert("Content-Type", HeaderValue::from_str(&content_type).expect("err"));
if let Ok(content_type_value) = HeaderValue::from_str(&content_type) {
header.insert("Content-Type", content_type_value);
}
if self.content_encoding != "" {
header.insert("Content-Encoding", HeaderValue::from_str(&self.content_encoding).expect("err"));
if let Ok(encoding_value) = HeaderValue::from_str(&self.content_encoding) {
header.insert("Content-Encoding", encoding_value);
}
}
if self.content_disposition != "" {
header.insert("Content-Disposition", HeaderValue::from_str(&self.content_disposition).expect("err"));
if let Ok(disposition_value) = HeaderValue::from_str(&self.content_disposition) {
header.insert("Content-Disposition", disposition_value);
}
}
if self.content_language != "" {
header.insert("Content-Language", HeaderValue::from_str(&self.content_language).expect("err"));
if let Ok(language_value) = HeaderValue::from_str(&self.content_language) {
header.insert("Content-Language", language_value);
}
}
if self.cache_control != "" {
header.insert("Cache-Control", HeaderValue::from_str(&self.cache_control).expect("err"));
if let Ok(cache_value) = HeaderValue::from_str(&self.cache_control) {
header.insert("Cache-Control", cache_value);
}
}
if self.expires.unix_timestamp() != 0 {
header.insert(
"Expires",
HeaderValue::from_str(&self.expires.format(ISO8601_DATEFORMAT).unwrap()).expect("err"),
); //rustfs invalid header
if let Ok(expires_str) = self.expires.format(ISO8601_DATEFORMAT) {
if let Ok(expires_value) = HeaderValue::from_str(&expires_str) {
header.insert("Expires", expires_value);
}
}
}
if self.mode.as_str() != "" {
header.insert(X_AMZ_OBJECT_LOCK_MODE, HeaderValue::from_str(self.mode.as_str()).expect("err"));
if let Ok(mode_value) = HeaderValue::from_str(self.mode.as_str()) {
header.insert(X_AMZ_OBJECT_LOCK_MODE, mode_value);
}
}
if self.retain_until_date.unix_timestamp() != 0 {
header.insert(
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
HeaderValue::from_str(&self.retain_until_date.format(ISO8601_DATEFORMAT).unwrap()).expect("err"),
);
if let Ok(retain_str) = self.retain_until_date.format(ISO8601_DATEFORMAT) {
if let Ok(retain_value) = HeaderValue::from_str(&retain_str) {
header.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, retain_value);
}
}
}
if self.legalhold.as_str() != "" {
header.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD, HeaderValue::from_str(self.legalhold.as_str()).expect("err"));
if let Ok(legalhold_value) = HeaderValue::from_str(self.legalhold.as_str()) {
header.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD, legalhold_value);
}
}
if self.storage_class != "" {
header.insert(X_AMZ_STORAGE_CLASS, HeaderValue::from_str(&self.storage_class).expect("err"));
if let Ok(storage_class_value) = HeaderValue::from_str(&self.storage_class) {
header.insert(X_AMZ_STORAGE_CLASS, storage_class_value);
}
}
if self.website_redirect_location != "" {
header.insert(
X_AMZ_WEBSITE_REDIRECT_LOCATION,
HeaderValue::from_str(&self.website_redirect_location).expect("err"),
);
if let Ok(redirect_value) = HeaderValue::from_str(&self.website_redirect_location) {
header.insert(X_AMZ_WEBSITE_REDIRECT_LOCATION, redirect_value);
}
}
if !self.internal.replication_status.as_str().is_empty() {
header.insert(
X_AMZ_REPLICATION_STATUS,
HeaderValue::from_str(self.internal.replication_status.as_str()).expect("err"),
);
if let Ok(replication_status_value) = HeaderValue::from_str(self.internal.replication_status.as_str()) {
header.insert(X_AMZ_REPLICATION_STATUS, replication_status_value);
}
}
for (k, v) in &self.user_metadata {
@@ -360,17 +376,21 @@ impl TransitionClient {
let mut md5_base64: String = "".to_string();
if opts.send_content_md5 {
let mut md5_hasher = self.md5_hasher.lock().unwrap();
let hash = md5_hasher.as_mut().expect("err");
let hash = hash.hash_encode(&buf[..length]);
md5_base64 = base64_encode(hash.as_ref());
if let Some(mut md5_hasher) = self.md5_hasher.lock().unwrap().as_mut() {
let hash = md5_hasher.hash_encode(&buf[..length]);
md5_base64 = base64_encode(hash.as_ref());
}
} else {
let mut crc = opts.auto_checksum.hasher()?;
crc.update(&buf[..length]);
let csum = crc.finalize();
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
custom_header.insert(header_name, header_value);
} else {
warn!("Failed to parse checksum value");
}
} else {
warn!("Invalid header name: {}", opts.auto_checksum.key());
}
@@ -127,7 +127,11 @@ impl TransitionClient {
let csum = crc.finalize();
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
custom_header.insert(header_name, header_value);
} else {
warn!("Failed to parse checksum value");
}
} else {
warn!("Invalid header name: {}", opts.auto_checksum.key());
}
@@ -309,27 +313,27 @@ impl TransitionClient {
let h = resp.headers();
let mut obj_part = ObjectPart {
checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) {
h_checksum_crc32.to_str().expect("err").to_string()
h_checksum_crc32.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) {
h_checksum_crc32c.to_str().expect("err").to_string()
h_checksum_crc32c.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) {
h_checksum_sha1.to_str().expect("err").to_string()
h_checksum_sha1.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) {
h_checksum_sha256.to_str().expect("err").to_string()
h_checksum_sha256.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) {
h_checksum_crc64nvme.to_str().expect("err").to_string()
h_checksum_crc64nvme.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
@@ -338,7 +342,7 @@ impl TransitionClient {
obj_part.size = p.size;
obj_part.part_num = p.part_number;
obj_part.etag = if let Some(h_etag) = h.get("ETag") {
h_etag.to_str().expect("err").trim_matches('"').to_string()
h_etag.to_str().unwrap_or("").trim_matches('"').to_string()
} else {
"".to_string()
};
@@ -398,7 +402,7 @@ impl TransitionClient {
key: complete_multipart_upload_result.key,
etag: trim_etag(&complete_multipart_upload_result.etag),
version_id: if let Some(h_x_amz_version_id) = h.get(X_AMZ_VERSION_ID) {
h_x_amz_version_id.to_str().expect("err").to_string()
h_x_amz_version_id.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
@@ -21,6 +21,7 @@
use bytes::Bytes;
use futures::future::join_all;
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use std::io::Error;
use std::sync::RwLock;
use std::{collections::HashMap, sync::Arc};
use time::{OffsetDateTime, format_description};
@@ -152,7 +153,10 @@ impl TransitionClient {
if opts.send_content_md5 {
let mut md5_hasher = self.md5_hasher.lock().unwrap();
let md5_hash = md5_hasher.as_mut().expect("err");
let md5_hash = match md5_hasher.as_mut() {
Some(hasher) => hasher,
None => return Err(std::io::Error::other("MD5 hasher not initialized")),
};
let hash = md5_hash.hash_encode(&buf[..length]);
md5_base64 = base64_encode(hash.as_ref());
} else {
@@ -161,7 +165,11 @@ impl TransitionClient {
let csum = crc.finalize();
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
custom_header.insert(header_name, header_value);
} else {
warn!("Failed to parse checksum value");
}
} else {
warn!("Invalid header name: {}", opts.auto_checksum.key());
}
@@ -275,11 +283,14 @@ impl TransitionClient {
for part_number in 1..=total_parts_count {
let mut buf = Vec::<u8>::new();
select! {
buf = bufs_rx.recv() => {}
buf1 = bufs_rx.recv() => {
if let Some(buf1) = buf1 {
buf = buf1;
}
}
err = err_rx.recv() => {
//cancel_token.cancel();
//wg.Wait()
return Err(err.expect("err"));
return Err(err.unwrap_or_else(|| std::io::Error::other("Unknown error received from channel")));
}
else => (),
}
@@ -309,7 +320,11 @@ impl TransitionClient {
let csum = crc.finalize();
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
custom_header.insert(header_name, header_value);
} else {
warn!("Failed to parse checksum value");
}
} else {
warn!("Invalid header name: {}", opts.auto_checksum.key());
}
@@ -319,12 +334,19 @@ impl TransitionClient {
let clone_parts_info = parts_info.clone();
let clone_upload_id = upload_id.clone();
let clone_self = self.clone();
let err_tx_clone = err_tx.clone();
futures.push(async move {
let mut md5_base64: String = "".to_string();
if opts.send_content_md5 {
let mut md5_hasher = clone_self.md5_hasher.lock().unwrap();
let md5_hash = md5_hasher.as_mut().expect("err");
let md5_hash = match md5_hasher.as_mut() {
Some(hasher) => hasher,
None => {
//let _ = err_tx_clone.send(std::io::Error::other("MD5 hasher not initialized")).await;
return Ok::<(), Error>(());
}
};
let hash = md5_hash.hash_encode(&buf[..length]);
md5_base64 = base64_encode(hash.as_ref());
}
@@ -344,12 +366,21 @@ impl TransitionClient {
sha256_hex: "".to_string(),
trailer: HeaderMap::new(),
};
let obj_part = clone_self.upload_part(&mut p).await.expect("err");
let obj_part = match clone_self.upload_part(&mut p).await {
Ok(part) => part,
Err(err) => {
let _ = err_tx_clone.send(std::io::Error::other(err.to_string())).await;
return Err::<(), Error>(err);
}
};
let mut clone_parts_info = clone_parts_info.write().unwrap();
clone_parts_info.entry(part_number).or_insert(obj_part);
{
let mut clone_parts_info = clone_parts_info.write().unwrap();
clone_parts_info.entry(part_number).or_insert(obj_part);
}
clone_bufs_tx.send(buf);
let _ = clone_bufs_tx.send(buf).await;
Ok::<(), Error>(())
});
total_uploaded_size += length as i64;
@@ -359,7 +390,7 @@ impl TransitionClient {
select! {
err = err_rx.recv() => {
return Err(err.expect("err"));
return Err(err.unwrap_or_else(|| std::io::Error::other("Unknown error received from channel")));
}
else => (),
}
@@ -504,9 +535,10 @@ impl TransitionClient {
Ok(UploadInfo {
bucket: bucket_name.to_string(),
key: object_name.to_string(),
etag: trim_etag(h.get("ETag").expect("err").to_str().expect("err")),
etag: trim_etag(h.get("ETag").and_then(|v| v.to_str().ok()).unwrap_or("")),
version_id: if let Some(h_x_amz_version_id) = h.get(X_AMZ_VERSION_ID) {
h_x_amz_version_id.to_str().expect("err").to_string()
h_x_amz_version_id.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
@@ -514,27 +546,27 @@ impl TransitionClient {
expiration: exp_time,
expiration_rule_id: rule_id,
checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) {
h_checksum_crc32.to_str().expect("err").to_string()
h_checksum_crc32.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) {
h_checksum_crc32c.to_str().expect("err").to_string()
h_checksum_crc32c.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) {
h_checksum_sha1.to_str().expect("err").to_string()
h_checksum_sha1.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) {
h_checksum_sha256.to_str().expect("err").to_string()
h_checksum_sha256.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) {
h_checksum_crc64nvme.to_str().expect("err").to_string()
h_checksum_crc64nvme.to_str().unwrap_or("").to_string()
} else {
"".to_string()
},
+26 -27
View File
@@ -25,7 +25,7 @@ use hyper::body::Bytes;
use rustfs_utils::HashAlgorithm;
use s3s::S3ErrorCode;
use s3s::dto::ReplicationStatus;
use s3s::header::X_AMZ_BYPASS_GOVERNANCE_RETENTION;
use s3s::header::{X_AMZ_BYPASS_GOVERNANCE_RETENTION, X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID};
use serde::Deserialize;
use std::fmt::Display;
use std::{
@@ -111,8 +111,9 @@ impl TransitionClient {
.await?;
{
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
bucket_loc_cache.delete(bucket_name);
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
bucket_loc_cache.delete(bucket_name);
}
}
Ok(())
}
@@ -142,8 +143,9 @@ impl TransitionClient {
.await?;
{
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
bucket_loc_cache.delete(bucket_name);
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
bucket_loc_cache.delete(bucket_name);
}
}
Ok(())
@@ -168,7 +170,7 @@ impl TransitionClient {
let mut headers = HeaderMap::new();
if opts.governance_bypass {
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, "true".parse().expect("err")); //amzBypassGovernance
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, HeaderValue::from_static("true")); //amzBypassGovernance
}
let resp = self
@@ -197,13 +199,12 @@ impl TransitionClient {
Ok(RemoveObjectResult {
object_name: object_name.to_string(),
object_version_id: opts.version_id,
delete_marker: resp.headers().get("x-amz-delete-marker").expect("err") == "true",
delete_marker: resp.headers().get(X_AMZ_DELETE_MARKER).map_or(false, |v| v == "true"),
delete_marker_version_id: resp
.headers()
.get("x-amz-version-id")
.expect("err")
.to_str()
.expect("err")
.get(X_AMZ_VERSION_ID)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string(),
..Default::default()
})
@@ -290,15 +291,15 @@ impl TransitionClient {
bucket_name,
&object.name,
RemoveObjectOptions {
version_id: object.version_id.expect("err").to_string(),
version_id: object.version_id.map(|id| id.to_string()).unwrap_or_default(),
governance_bypass: opts.governance_bypass,
..Default::default()
},
)
.await?;
let remove_result_clone = remove_result.clone();
if !remove_result.err.is_none() {
match to_error_response(&remove_result.err.expect("err")).code {
if let Some(err) = &remove_result.err {
match to_error_response(err).code {
S3ErrorCode::InvalidArgument | S3ErrorCode::NoSuchVersion => {
continue;
}
@@ -326,7 +327,7 @@ impl TransitionClient {
let mut headers = HeaderMap::new();
if opts.governance_bypass {
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, "true".parse().expect("err"));
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, HeaderValue::from_static("true"));
}
let remove_bytes = generate_remove_multi_objects_request(&batch);
@@ -423,23 +424,20 @@ impl TransitionClient {
request_id: resp
.headers()
.get("x-amz-request-id")
.expect("err")
.to_str()
.expect("err")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string(),
host_id: resp
.headers()
.get("x-amz-id-2")
.expect("err")
.to_str()
.expect("err")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string(),
region: resp
.headers()
.get("x-amz-bucket-region")
.expect("err")
.to_str()
.expect("err")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string(),
..Default::default()
};
@@ -472,10 +470,11 @@ pub struct RemoveObjectError {
impl Display for RemoveObjectError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.err.is_none() {
return write!(f, "unexpected remove object error result");
if let Some(err) = &self.err {
write!(f, "{}", err.to_string())
} else {
write!(f, "unexpected remove object error result")
}
write!(f, "{}", self.err.as_ref().expect("err").to_string())
}
}
+34 -22
View File
@@ -70,10 +70,10 @@ impl TransitionClient {
let mut location;
{
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
let ret = bucket_loc_cache.get(bucket_name);
if let Some(location) = ret {
return Ok(location);
if let Ok(bucket_loc_cache) = self.bucket_loc_cache.lock() {
if let Some(location) = bucket_loc_cache.get(bucket_name) {
return Ok(location);
}
}
//location = ret?;
}
@@ -83,8 +83,9 @@ impl TransitionClient {
let mut resp = self.doit(req).await?;
location = process_bucket_location_response(resp, bucket_name, &self.tier_type).await?;
{
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
bucket_loc_cache.set(bucket_name, &location);
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
bucket_loc_cache.set(bucket_name, &location);
}
}
Ok(location)
}
@@ -108,7 +109,11 @@ impl TransitionClient {
url_str.push_str("://");
url_str.push_str(bucket_name);
url_str.push_str(".");
url_str.push_str(target_url.host_str().expect("err"));
url_str.push_str(
target_url
.host_str()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "host is none"))?,
);
url_str.push_str("/?location");
} else {
let mut path = bucket_name.to_string();
@@ -135,13 +140,16 @@ impl TransitionClient {
let value;
{
let mut creds_provider = self.creds_provider.lock().unwrap();
value = match creds_provider.get_with_context(Some(self.cred_context())) {
Ok(v) => v,
Err(err) => {
return Err(std::io::Error::other(err));
}
};
if let Ok(mut creds_provider) = self.creds_provider.lock() {
value = match creds_provider.get_with_context(Some(self.cred_context())) {
Ok(v) => v,
Err(err) => {
return Err(std::io::Error::other(err));
}
};
} else {
return Err(std::io::Error::other("Failed to acquire credentials provider lock"));
}
}
let mut signer_type = value.signer_type.clone();
@@ -171,8 +179,9 @@ impl TransitionClient {
content_sha256 = UNSIGNED_PAYLOAD.to_string();
}
req.headers_mut()
.insert("X-Amz-Content-Sha256", content_sha256.parse().unwrap());
if let Ok(content_sha256_value) = content_sha256.parse() {
req.headers_mut().insert("X-Amz-Content-Sha256", content_sha256_value);
}
let req = rustfs_signer::sign_v4(req, 0, &access_key_id, &secret_access_key, &session_token, "us-east-1");
Ok(req)
}
@@ -228,13 +237,16 @@ async fn process_bucket_location_response(
}
let mut location = "".to_string();
if tier_type == "huaweicloud" {
let d = quick_xml::de::from_str::<CreateBucketConfiguration>(&String::from_utf8(body_vec).unwrap()).unwrap();
location = d.location_constraint;
if let Ok(body_str) = String::from_utf8(body_vec) {
if let Ok(d) = quick_xml::de::from_str::<CreateBucketConfiguration>(&body_str) {
location = d.location_constraint;
}
}
} else {
if let Ok(LocationConstraint { field }) =
quick_xml::de::from_str::<LocationConstraint>(&String::from_utf8(body_vec).unwrap())
{
location = field;
if let Ok(body_str) = String::from_utf8(body_vec) {
if let Ok(LocationConstraint { field }) = quick_xml::de::from_str::<LocationConstraint>(&body_str) {
location = field;
}
}
}
//debug!("location: {}", location);
+4 -2
View File
@@ -1,4 +1,3 @@
#![allow(unused_imports)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,6 +11,7 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
@@ -57,7 +57,9 @@ impl<P: Provider + Default> Credentials<P> {
pub fn get_with_context(&mut self, mut cc: Option<CredContext>) -> Result<Value, std::io::Error> {
if self.is_expired() {
let creds = self.provider.retrieve_with_cred_context(cc.expect("err"));
let creds = self.provider.retrieve_with_cred_context(cc.unwrap_or(CredContext {
endpoint: "".to_string(),
}));
self.creds = creds;
self.force_refresh = false;
}
@@ -16,12 +16,26 @@ use std::sync::Arc;
use tracing::warn;
use crate::bucket::lifecycle::lifecycle;
use crate::bucket::replication::{DeletedObjectReplicationInfo, check_replicate_delete, schedule_replication_delete};
use crate::bucket::versioning::VersioningApi;
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::store::ECStore;
use crate::store_api::{ObjectOperations, ObjectOptions, ObjectToDelete};
use rustfs_filemeta::{REPLICATE_INCOMING_DELETE, ReplicationState, version_purge_statuses_map};
use rustfs_lock::MAX_DELETE_LIST;
fn lifecycle_version_delete_replication_state(
replicate_decision_str: String,
pending_status: Option<String>,
) -> ReplicationState {
ReplicationState {
replicate_decision_str,
version_purge_status_internal: pending_status.clone(),
purge_targets: version_purge_statuses_map(pending_status.as_deref().unwrap_or_default()),
..Default::default()
}
}
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
let version_suspended = match BucketVersioningSys::get(bucket).await {
Ok(vc) => vc.suspended(),
@@ -39,7 +53,37 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
} else {
remaining = &[];
}
let (_deleted_objs, errors) = api
let mut replication_candidates: Vec<Option<ReplicationState>> = Vec::with_capacity(to_del.len());
for object in to_del.iter() {
let version_id = object.version_id.map(|vid| vid.to_string());
let opts = ObjectOptions {
version_id: version_id.clone(),
versioned: true,
version_suspended,
..Default::default()
};
let candidate = match api.get_object_info(bucket, &object.object_name, &opts).await {
Ok(info) => {
let dsc = check_replicate_delete(bucket, object, &info, &opts, None).await;
dsc.replicate_any()
.then(|| lifecycle_version_delete_replication_state(dsc.to_string(), dsc.pending_status()))
}
Err(err) => {
warn!(
bucket,
object = %object.object_name,
version_id = ?version_id,
error = ?err,
"failed to get object info during lifecycle noncurrent version cleanup; skipping delete replication scheduling"
);
None
}
};
replication_candidates.push(candidate);
}
let (mut deleted_objs, errors) = api
.delete_objects(
bucket,
to_del.to_vec(),
@@ -49,6 +93,24 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
},
)
.await;
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
continue;
}
let Some(replication_state) = replication_candidates.get(i).and_then(|c| c.clone()) else {
continue;
};
deleted_obj.replication_state = Some(replication_state);
schedule_replication_delete(DeletedObjectReplicationInfo {
delete_object: deleted_obj.clone(),
bucket: bucket.to_string(),
event_type: REPLICATE_INCOMING_DELETE.to_string(),
..Default::default()
})
.await;
}
for (i, err) in errors.iter().enumerate() {
if let Some(e) = err {
let obj_name = to_del.get(i).map(|o| o.object_name.as_str()).unwrap_or("<unknown>");
@@ -65,3 +127,20 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
}
}
}
#[cfg(test)]
mod tests {
use super::lifecycle_version_delete_replication_state;
#[test]
fn lifecycle_version_delete_replication_state_tracks_pending_purge_targets() {
let state = lifecycle_version_delete_replication_state(
"arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
Some("arn:aws:s3:::target=PENDING;".to_string()),
);
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:aws:s3:::target=PENDING;"));
assert!(state.purge_targets.contains_key("arn:aws:s3:::target"));
assert_eq!(state.replicate_decision_str, "arn:aws:s3:::target=true;false;arn:aws:s3:::target;");
}
}
+64 -36
View File
@@ -251,9 +251,10 @@ impl TransitionClient {
};
{
let mut md5_hasher = client.md5_hasher.lock().unwrap();
if md5_hasher.is_none() {
*md5_hasher = Some(HashAlgorithm::Md5);
if let Ok(mut md5_hasher) = client.md5_hasher.lock() {
if md5_hasher.is_none() {
*md5_hasher = Some(HashAlgorithm::Md5);
}
}
}
if client.sha256_hasher.is_none() {
@@ -275,25 +276,30 @@ impl TransitionClient {
}
fn trace_errors_only_off(&self) {
let mut trace_errors_only = self.trace_errors_only.lock().unwrap();
*trace_errors_only = false;
if let Ok(mut trace_errors_only) = self.trace_errors_only.lock() {
*trace_errors_only = false;
}
}
fn trace_off(&self) {
let mut is_trace_enabled = self.is_trace_enabled.lock().unwrap();
*is_trace_enabled = false;
let mut trace_errors_only = self.trace_errors_only.lock().unwrap();
*trace_errors_only = false;
if let Ok(mut is_trace_enabled) = self.is_trace_enabled.lock() {
*is_trace_enabled = false;
}
if let Ok(mut trace_errors_only) = self.trace_errors_only.lock() {
*trace_errors_only = false;
}
}
fn set_s3_transfer_accelerate(&self, accelerate_endpoint: &str) {
let mut endpoint = self.s3_accelerate_endpoint.lock().unwrap();
*endpoint = accelerate_endpoint.to_string();
if let Ok(mut endpoint) = self.s3_accelerate_endpoint.lock() {
*endpoint = accelerate_endpoint.to_string();
}
}
fn set_s3_enable_dual_stack(&self, enabled: bool) {
let mut dual_stack = self.s3_dual_stack_enabled.lock().unwrap();
*dual_stack = enabled;
if let Ok(mut dual_stack) = self.s3_dual_stack_enabled.lock() {
*dual_stack = enabled;
}
}
pub fn hash_materials(
@@ -352,7 +358,6 @@ impl TransitionClient {
let resp;
let http_client = self.http_client.clone();
{
//let mut http_client = http_client.lock().unwrap();
req_method = req.method().clone();
req_uri = req.uri().clone();
req_headers = req.headers().clone();
@@ -368,7 +373,10 @@ impl TransitionClient {
return Err(std::io::Error::other(err));
}
let resp = resp.unwrap();
let resp = match resp {
Ok(r) => r,
Err(_) => return Err(std::io::Error::other("Unexpected error in response")),
};
debug!("http_resp: {:?}", resp);
//let b = resp.body_mut().store_all_unlimited().await.unwrap().to_vec();
@@ -455,11 +463,13 @@ impl TransitionClient {
return Err(std::io::Error::other(err_response));
}
if metadata.bucket_name != "" {
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
let location = bucket_loc_cache.get(&metadata.bucket_name);
if location.is_some() && location.unwrap() != err_response.region {
bucket_loc_cache.set(&metadata.bucket_name, &err_response.region);
//continue;
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
if let Some(location) = bucket_loc_cache.get(&metadata.bucket_name) {
if location != err_response.region {
bucket_loc_cache.set(&metadata.bucket_name, &err_response.region);
//continue;
}
}
}
} else if err_response.region != metadata.bucket_location {
metadata.bucket_location = err_response.region.clone();
@@ -518,8 +528,11 @@ impl TransitionClient {
let value;
{
let mut creds_provider = self.creds_provider.lock().unwrap();
value = creds_provider.get_with_context(Some(self.cred_context()))?;
if let Ok(mut creds_provider) = self.creds_provider.lock() {
value = creds_provider.get_with_context(Some(self.cred_context()))?;
} else {
return Err(std::io::Error::other("Failed to acquire credentials provider lock"));
}
}
let mut signer_type = value.signer_type.clone();
@@ -548,8 +561,10 @@ impl TransitionClient {
)));
}
let headers = req.headers_mut();
for (k, v) in metadata.extra_pre_sign_header.as_ref().unwrap() {
headers.insert(k, v.clone());
if let Some(extra_headers) = metadata.extra_pre_sign_header.as_ref() {
for (k, v) in extra_headers {
headers.insert(k, v.clone());
}
}
}
if signer_type == SignatureType::SignatureV2 {
@@ -571,18 +586,22 @@ impl TransitionClient {
self.set_user_agent(&mut req);
for (k, v) in metadata.custom_header.clone() {
req.headers_mut().insert(k.expect("err"), v);
if let Some(key) = k {
req.headers_mut().insert(key, v);
}
}
//req.content_length = metadata.content_length;
if metadata.content_length <= -1 {
let chunked_value = HeaderValue::from_str(&vec!["chunked"].join(",")).expect("err");
req.headers_mut().insert(http::header::TRANSFER_ENCODING, chunked_value);
if let Ok(chunked_value) = HeaderValue::from_str(&vec!["chunked"].join(",")) {
req.headers_mut().insert(http::header::TRANSFER_ENCODING, chunked_value);
}
}
if metadata.content_md5_base64.len() > 0 {
let md5_value = HeaderValue::from_str(&metadata.content_md5_base64).expect("err");
req.headers_mut().insert("Content-Md5", md5_value);
if let Ok(md5_value) = HeaderValue::from_str(&metadata.content_md5_base64) {
req.headers_mut().insert("Content-Md5", md5_value);
}
}
if signer_type == SignatureType::SignatureAnonymous {
@@ -607,8 +626,13 @@ impl TransitionClient {
} else if metadata.trailer.len() > 0 {
sha_header = UNSIGNED_PAYLOAD_TRAILER.to_string();
}
req.headers_mut()
.insert("X-Amz-Content-Sha256".parse::<HeaderName>().unwrap(), sha_header.parse().expect("err"));
let header_name = "X-Amz-Content-Sha256"
.parse::<HeaderName>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let header_value = sha_header
.parse()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
req.headers_mut().insert(header_name, header_value);
req = rustfs_signer::sign_v4_trailer(
req,
@@ -636,7 +660,7 @@ impl TransitionClient {
pub fn set_user_agent(&self, req: &mut Request<s3s::Body>) {
let headers = req.headers_mut();
headers.insert("User-Agent", C_USER_AGENT.parse().expect("err"));
headers.insert("User-Agent", HeaderValue::from_static(C_USER_AGENT));
}
fn make_target_url(
@@ -648,7 +672,10 @@ impl TransitionClient {
query_values: &HashMap<String, String>,
) -> Result<Url, std::io::Error> {
let scheme = self.endpoint_url.scheme();
let host = self.endpoint_url.host().unwrap();
let host = self
.endpoint_url
.host()
.ok_or_else(|| std::io::Error::other("Endpoint URL has no host"))?;
let default_port = if scheme == "https" { 443 } else { 80 };
let port = self.endpoint_url.port().unwrap_or(default_port);
@@ -1155,9 +1182,10 @@ pub fn to_object_info(bucket_name: &str, object_name: &str, h: &HeaderMap) -> Re
for (name, value) in h.iter() {
let header_name = name.as_str().to_lowercase();
if header_name.starts_with("x-amz-meta-") {
let key = header_name.strip_prefix("x-amz-meta-").unwrap().to_string();
if let Ok(value_str) = value.to_str() {
meta.insert(key, value_str.to_string());
if let Some(key) = header_name.strip_prefix("x-amz-meta-") {
if let Ok(value_str) = value.to_str() {
meta.insert(key.to_string(), value_str.to_string());
}
}
}
}
+148 -3
View File
@@ -16,9 +16,13 @@ use crate::config::{KV, KVS};
use rustfs_config::{
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD,
MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, WEBHOOK_AUTH_TOKEN,
WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT,
WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS,
NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT,
NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD,
PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION,
PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT,
WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT,
WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY,
};
use std::sync::LazyLock;
@@ -192,3 +196,144 @@ pub static DEFAULT_AUDIT_MQTT_KVS: LazyLock<KVS> = LazyLock::new(|| {
},
])
});
pub static DEFAULT_AUDIT_NATS_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: NATS_ADDRESS.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_SUBJECT.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_USERNAME.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_PASSWORD.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TOKEN.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_CREDENTIALS_FILE.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CLIENT_CERT.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CLIENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_REQUIRED.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: NATS_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
#[allow(dead_code)]
pub static DEFAULT_AUDIT_PULSAR_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_BROKER.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_TOPIC.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_AUTH_TOKEN.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_USERNAME.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_PASSWORD.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_TLS_ALLOW_INSECURE.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_TLS_HOSTNAME_VERIFICATION.to_owned(),
value: EnableState::On.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
+164 -118
View File
@@ -18,8 +18,14 @@ use crate::error::{Error, Result};
use crate::global::is_first_cluster_node_local;
use crate::store_api::{ObjectInfo, ObjectOptions, PutObjReader, StorageAPI};
use http::HeaderMap;
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::audit::{
AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS,
AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS,
};
use rustfs_config::notify::{
NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS,
NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_config::oidc::{IDENTITY_OPENID_KEYS, IDENTITY_OPENID_SUB_SYS, OIDC_REDIRECT_URI_DYNAMIC};
use rustfs_config::{COMMENT_KEY, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, RUSTFS_REGION};
use rustfs_utils::path::SLASH_SEPARATOR;
@@ -34,6 +40,8 @@ const CONFIG_FILE: &str = "config.json";
pub const STORAGE_CLASS_SUB_SYS: &str = "storage_class";
pub const COMMA_SEPARATED_LISTS: &[&str] = &[rustfs_config::oidc::OIDC_SCOPES, rustfs_config::oidc::OIDC_OTHER_AUDIENCES];
static CONFIG_BUCKET: LazyLock<String> = LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{CONFIG_PREFIX}"));
static SUB_SYSTEMS_DYNAMIC: LazyLock<HashSet<String>> = LazyLock::new(|| {
@@ -42,6 +50,72 @@ static SUB_SYSTEMS_DYNAMIC: LazyLock<HashSet<String>> = LazyLock::new(|| {
h
});
#[derive(Clone, Copy)]
struct TargetConfigDescriptor {
external_key: &'static str,
subsystem_key: &'static str,
default_kvs: &'static LazyLock<KVS>,
valid_keys: &'static [&'static str],
}
fn notify_target_descriptors() -> [TargetConfigDescriptor; 4] {
[
TargetConfigDescriptor {
external_key: "webhook",
subsystem_key: NOTIFY_WEBHOOK_SUB_SYS,
default_kvs: &notify::DEFAULT_NOTIFY_WEBHOOK_KVS,
valid_keys: NOTIFY_WEBHOOK_KEYS,
},
TargetConfigDescriptor {
external_key: "mqtt",
subsystem_key: NOTIFY_MQTT_SUB_SYS,
default_kvs: &notify::DEFAULT_NOTIFY_MQTT_KVS,
valid_keys: NOTIFY_MQTT_KEYS,
},
TargetConfigDescriptor {
external_key: "nats",
subsystem_key: NOTIFY_NATS_SUB_SYS,
default_kvs: &notify::DEFAULT_NOTIFY_NATS_KVS,
valid_keys: NOTIFY_NATS_KEYS,
},
TargetConfigDescriptor {
external_key: "pulsar",
subsystem_key: NOTIFY_PULSAR_SUB_SYS,
default_kvs: &notify::DEFAULT_NOTIFY_PULSAR_KVS,
valid_keys: NOTIFY_PULSAR_KEYS,
},
]
}
fn audit_target_descriptors() -> [TargetConfigDescriptor; 4] {
[
TargetConfigDescriptor {
external_key: "webhook",
subsystem_key: AUDIT_WEBHOOK_SUB_SYS,
default_kvs: &audit::DEFAULT_AUDIT_WEBHOOK_KVS,
valid_keys: AUDIT_WEBHOOK_KEYS,
},
TargetConfigDescriptor {
external_key: "mqtt",
subsystem_key: AUDIT_MQTT_SUB_SYS,
default_kvs: &audit::DEFAULT_AUDIT_MQTT_KVS,
valid_keys: AUDIT_MQTT_KEYS,
},
TargetConfigDescriptor {
external_key: "nats",
subsystem_key: AUDIT_NATS_SUB_SYS,
default_kvs: &audit::DEFAULT_AUDIT_NATS_KVS,
valid_keys: AUDIT_NATS_KEYS,
},
TargetConfigDescriptor {
external_key: "pulsar",
subsystem_key: AUDIT_PULSAR_SUB_SYS,
default_kvs: &audit::DEFAULT_AUDIT_PULSAR_KVS,
valid_keys: AUDIT_PULSAR_KEYS,
},
]
}
#[instrument(skip(api))]
pub async fn read_config<S: StorageAPI>(api: Arc<S>, file: &str) -> Result<Vec<u8>> {
let (data, _obj) = read_config_with_metadata(api, file, &ObjectOptions::default()).await?;
@@ -192,15 +266,15 @@ fn parse_oidc_scalar_value(key: &str, value: &Value) -> Option<String> {
}),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
Value::Array(values) if key == rustfs_config::oidc::OIDC_SCOPES => {
let scopes = values
Value::Array(values) if COMMA_SEPARATED_LISTS.contains(&key) => {
let values_str = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|scope| !scope.is_empty())
.filter(|val| !val.is_empty())
.collect::<Vec<_>>()
.join(",");
Some(scopes)
Some(values_str)
}
Value::Null => None,
_ => None,
@@ -363,29 +437,31 @@ fn apply_external_notify_section(
applied
}
fn apply_external_target_descriptors(
cfg: &mut Config,
section_obj: &Map<String, Value>,
descriptors: &[TargetConfigDescriptor],
) -> bool {
let mut applied = false;
for descriptor in descriptors {
applied |= apply_external_notify_section(
cfg,
section_obj,
descriptor.external_key,
descriptor.subsystem_key,
descriptor.default_kvs,
descriptor.valid_keys,
);
}
applied
}
fn apply_external_notify_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
let Some(Value::Object(notify_obj)) = root.get("notify") else {
return false;
};
let mut applied = false;
applied |= apply_external_notify_section(
cfg,
notify_obj,
"webhook",
NOTIFY_WEBHOOK_SUB_SYS,
&notify::DEFAULT_NOTIFY_WEBHOOK_KVS,
NOTIFY_WEBHOOK_KEYS,
);
applied |= apply_external_notify_section(
cfg,
notify_obj,
"mqtt",
NOTIFY_MQTT_SUB_SYS,
&notify::DEFAULT_NOTIFY_MQTT_KVS,
NOTIFY_MQTT_KEYS,
);
applied
apply_external_target_descriptors(cfg, notify_obj, &notify_target_descriptors())
}
fn apply_external_audit_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
@@ -394,24 +470,7 @@ fn apply_external_audit_map(cfg: &mut Config, root: &Map<String, Value>) -> bool
return false;
};
let mut applied = false;
applied |= apply_external_notify_section(
cfg,
audit_obj,
"webhook",
AUDIT_WEBHOOK_SUB_SYS,
&audit::DEFAULT_AUDIT_WEBHOOK_KVS,
AUDIT_WEBHOOK_KEYS,
);
applied |= apply_external_notify_section(
cfg,
audit_obj,
"mqtt",
AUDIT_MQTT_SUB_SYS,
&audit::DEFAULT_AUDIT_MQTT_KVS,
AUDIT_MQTT_KEYS,
);
applied
apply_external_target_descriptors(cfg, audit_obj, &audit_target_descriptors())
}
fn apply_external_storage_class_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
@@ -513,15 +572,15 @@ fn build_oidc_provider_object(kvs: &KVS) -> Map<String, Value> {
continue;
}
if kv.key == rustfs_config::oidc::OIDC_SCOPES {
let scopes = kv
if COMMA_SEPARATED_LISTS.contains(&kv.key.as_str()) {
let values = kv
.value
.split(',')
.map(str::trim)
.filter(|scope| !scope.is_empty())
.map(|scope| Value::String(scope.to_string()))
.filter(|val| !val.is_empty())
.map(|val| Value::String(val.to_string()))
.collect::<Vec<_>>();
provider.insert(kv.key.clone(), Value::Array(scopes));
provider.insert(kv.key.clone(), Value::Array(values));
continue;
}
@@ -605,7 +664,15 @@ fn build_semantic_oidc_object(cfg: &Config) -> Map<String, Value> {
}
fn is_notify_bool_key(key: &str) -> bool {
key == ENABLE_KEY || key == rustfs_config::WEBHOOK_SKIP_TLS_VERIFY
matches!(
key,
ENABLE_KEY
| rustfs_config::WEBHOOK_SKIP_TLS_VERIFY
| rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA
| rustfs_config::NATS_TLS_REQUIRED
| rustfs_config::PULSAR_TLS_ALLOW_INSECURE
| rustfs_config::PULSAR_TLS_HOSTNAME_VERIFICATION
)
}
fn encode_notify_scalar_value(key: &str, value: &str) -> Value {
@@ -703,38 +770,43 @@ fn build_notify_subsystem_object(
subsystem_obj
}
fn build_target_object(cfg: &Config, descriptors: &[TargetConfigDescriptor]) -> Map<String, Value> {
let mut target_obj = Map::new();
for descriptor in descriptors {
let subsystem_obj =
build_notify_subsystem_object(cfg, descriptor.subsystem_key, descriptor.default_kvs, descriptor.valid_keys);
if !subsystem_obj.is_empty() {
target_obj.insert(descriptor.external_key.to_string(), Value::Object(subsystem_obj));
}
}
target_obj
}
fn build_notify_object(cfg: &Config) -> Map<String, Value> {
let mut notify_obj = Map::new();
let webhook_obj =
build_notify_subsystem_object(cfg, NOTIFY_WEBHOOK_SUB_SYS, &notify::DEFAULT_NOTIFY_WEBHOOK_KVS, NOTIFY_WEBHOOK_KEYS);
if !webhook_obj.is_empty() {
notify_obj.insert("webhook".to_string(), Value::Object(webhook_obj));
}
let mqtt_obj = build_notify_subsystem_object(cfg, NOTIFY_MQTT_SUB_SYS, &notify::DEFAULT_NOTIFY_MQTT_KVS, NOTIFY_MQTT_KEYS);
if !mqtt_obj.is_empty() {
notify_obj.insert("mqtt".to_string(), Value::Object(mqtt_obj));
}
notify_obj
build_target_object(cfg, &notify_target_descriptors())
}
fn build_audit_object(cfg: &Config) -> Map<String, Value> {
let mut audit_obj = Map::new();
build_target_object(cfg, &audit_target_descriptors())
}
let webhook_obj =
build_notify_subsystem_object(cfg, AUDIT_WEBHOOK_SUB_SYS, &audit::DEFAULT_AUDIT_WEBHOOK_KVS, AUDIT_WEBHOOK_KEYS);
if !webhook_obj.is_empty() {
audit_obj.insert("webhook".to_string(), Value::Object(webhook_obj));
fn sync_rendered_target_object(
target_obj: &mut Map<String, Value>,
rendered_target: &Map<String, Value>,
descriptors: &[TargetConfigDescriptor],
) {
for descriptor in descriptors {
match rendered_target.get(descriptor.external_key) {
Some(Value::Object(v)) => {
target_obj.insert(descriptor.external_key.to_string(), Value::Object(v.clone()));
target_obj.remove(descriptor.subsystem_key);
}
_ => {
target_obj.remove(descriptor.external_key);
target_obj.remove(descriptor.subsystem_key);
}
}
}
let mqtt_obj = build_notify_subsystem_object(cfg, AUDIT_MQTT_SUB_SYS, &audit::DEFAULT_AUDIT_MQTT_KVS, AUDIT_MQTT_KEYS);
if !mqtt_obj.is_empty() {
audit_obj.insert("mqtt".to_string(), Value::Object(mqtt_obj));
}
audit_obj
}
fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8>> {
@@ -771,67 +843,31 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
_ => Map::new(),
};
let rendered_notify = build_notify_object(cfg);
match rendered_notify.get("webhook") {
Some(Value::Object(v)) => {
notify_obj.insert("webhook".to_string(), Value::Object(v.clone()));
notify_obj.remove(NOTIFY_WEBHOOK_SUB_SYS);
}
_ => {
notify_obj.remove("webhook");
notify_obj.remove(NOTIFY_WEBHOOK_SUB_SYS);
}
}
match rendered_notify.get("mqtt") {
Some(Value::Object(v)) => {
notify_obj.insert("mqtt".to_string(), Value::Object(v.clone()));
notify_obj.remove(NOTIFY_MQTT_SUB_SYS);
}
_ => {
notify_obj.remove("mqtt");
notify_obj.remove(NOTIFY_MQTT_SUB_SYS);
}
}
sync_rendered_target_object(&mut notify_obj, &rendered_notify, &notify_target_descriptors());
if notify_obj.is_empty() {
root.remove("notify");
} else {
root.insert("notify".to_string(), Value::Object(notify_obj));
}
root.remove(NOTIFY_WEBHOOK_SUB_SYS);
root.remove(NOTIFY_MQTT_SUB_SYS);
for descriptor in notify_target_descriptors() {
root.remove(descriptor.subsystem_key);
}
let mut logger_obj = match root.remove("logger") {
Some(Value::Object(v)) => v,
_ => Map::new(),
};
let rendered_audit = build_audit_object(cfg);
match rendered_audit.get("webhook") {
Some(Value::Object(v)) => {
logger_obj.insert("webhook".to_string(), Value::Object(v.clone()));
logger_obj.remove(AUDIT_WEBHOOK_SUB_SYS);
}
_ => {
logger_obj.remove("webhook");
logger_obj.remove(AUDIT_WEBHOOK_SUB_SYS);
}
}
match rendered_audit.get("mqtt") {
Some(Value::Object(v)) => {
logger_obj.insert("mqtt".to_string(), Value::Object(v.clone()));
logger_obj.remove(AUDIT_MQTT_SUB_SYS);
}
_ => {
logger_obj.remove("mqtt");
logger_obj.remove(AUDIT_MQTT_SUB_SYS);
}
}
sync_rendered_target_object(&mut logger_obj, &rendered_audit, &audit_target_descriptors());
if logger_obj.is_empty() {
root.remove("logger");
} else {
root.insert("logger".to_string(), Value::Object(logger_obj));
}
root.remove("audit");
root.remove(AUDIT_WEBHOOK_SUB_SYS);
root.remove(AUDIT_MQTT_SUB_SYS);
for descriptor in audit_target_descriptors() {
root.remove(descriptor.subsystem_key);
}
Ok(serde_json::to_vec(&Value::Object(root))?)
}
@@ -1655,6 +1691,7 @@ mod tests {
"client_id":"console",
"client_secret":"secret-value",
"scopes":["openid","profile","email"],
"other_audiences":["aud1", "aud2"],
"redirect_uri_dynamic":true,
"display_name":"Default Provider"
},
@@ -1679,6 +1716,7 @@ mod tests {
);
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_CLIENT_ID), "console");
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_SCOPES), "openid,profile,email");
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_OTHER_AUDIENCES), "aud1,aud2");
assert_eq!(default_kvs.get(ENABLE_KEY), EnableState::On.to_string());
let smoke_kvs = cfg
@@ -1863,6 +1901,7 @@ mod tests {
);
default_provider.insert(rustfs_config::oidc::OIDC_CLIENT_ID.to_string(), "console".to_string());
default_provider.insert(rustfs_config::oidc::OIDC_SCOPES.to_string(), "openid,profile,email".to_string());
default_provider.insert(rustfs_config::oidc::OIDC_OTHER_AUDIENCES.to_string(), "aud1,aud2".to_string());
oidc_section.insert(DEFAULT_DELIMITER.to_string(), default_provider);
cfg.0.insert(IDENTITY_OPENID_SUB_SYS.to_string(), oidc_section);
@@ -1890,6 +1929,13 @@ mod tests {
.map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
Some(vec!["openid", "profile", "email"])
);
assert_eq!(
default_provider
.get(rustfs_config::oidc::OIDC_OTHER_AUDIENCES)
.and_then(Value::as_array)
.map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
Some(vec!["aud1", "aud2"])
);
assert_eq!(default_provider.get(ENABLE_KEY).and_then(Value::as_bool), Some(true));
}
+6 -2
View File
@@ -25,8 +25,8 @@ use crate::store::ECStore;
use com::{STORAGE_CLASS_SUB_SYS, lookup_configs, read_config_without_migrate};
use rustfs_config::COMMENT_KEY;
use rustfs_config::DEFAULT_DELIMITER;
use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -241,6 +241,10 @@ pub fn init() {
kvs.insert(AUDIT_WEBHOOK_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_WEBHOOK_KVS.clone());
kvs.insert(NOTIFY_MQTT_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_MQTT_KVS.clone());
kvs.insert(AUDIT_MQTT_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_MQTT_KVS.clone());
kvs.insert(NOTIFY_NATS_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_NATS_KVS.clone());
kvs.insert(AUDIT_NATS_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_NATS_KVS.clone());
kvs.insert(NOTIFY_PULSAR_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_PULSAR_KVS.clone());
kvs.insert(AUDIT_PULSAR_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_PULSAR_KVS.clone());
kvs.insert(IDENTITY_OPENID_SUB_SYS.to_owned(), oidc::DEFAULT_IDENTITY_OPENID_KVS.clone());
// Register all default configurations
+146 -3
View File
@@ -16,9 +16,12 @@ use crate::config::{KV, KVS};
use rustfs_config::{
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD,
MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, WEBHOOK_AUTH_TOKEN,
WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT,
WEBHOOK_SKIP_TLS_VERIFY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS,
NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT,
NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD,
PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION,
PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY,
WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
};
use std::sync::LazyLock;
@@ -171,3 +174,143 @@ pub static DEFAULT_NOTIFY_MQTT_KVS: LazyLock<KVS> = LazyLock::new(|| {
},
])
});
pub static DEFAULT_NOTIFY_NATS_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: NATS_ADDRESS.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_SUBJECT.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_USERNAME.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_PASSWORD.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TOKEN.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_CREDENTIALS_FILE.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CLIENT_CERT.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_CLIENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: NATS_TLS_REQUIRED.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: NATS_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: NATS_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
pub static DEFAULT_NOTIFY_PULSAR_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_BROKER.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_TOPIC.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_AUTH_TOKEN.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_USERNAME.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_PASSWORD.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: PULSAR_TLS_ALLOW_INSECURE.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_TLS_HOSTNAME_VERIFICATION.to_owned(),
value: EnableState::On.to_string(),
hidden_if_empty: false,
},
KV {
key: PULSAR_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: PULSAR_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
+7 -2
View File
@@ -18,8 +18,8 @@ use rustfs_config::{
oidc::{
OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, OIDC_DEFAULT_CLAIM_NAME,
OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_ROLES_CLAIM, OIDC_DEFAULT_SCOPES,
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_REDIRECT_URI,
OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_OTHER_AUDIENCES,
OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
},
};
use std::sync::LazyLock;
@@ -52,6 +52,11 @@ pub static DEFAULT_IDENTITY_OPENID_KVS: LazyLock<KVS> = LazyLock::new(|| {
value: OIDC_DEFAULT_SCOPES.to_owned(),
hidden_if_empty: false,
},
KV {
key: OIDC_OTHER_AUDIENCES.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: OIDC_REDIRECT_URI.to_owned(),
value: "".to_owned(),
+4 -1
View File
@@ -707,6 +707,10 @@ impl ObjectIO for SetDisks {
tokio::spawn(async move {
let _guard = read_lock_guard; // keep guard alive until task ends (None if optimization enabled)
let mut writer = wd;
// Do not wrap the entire read+write pipeline in `disk_read_timeout`.
// `get_object_with_fileinfo` also waits on `writer`, so an outer timeout
// would incorrectly treat downstream backpressure as disk-read latency.
// Disk read timeouts must be enforced at the actual disk I/O operations.
if let Err(e) = Self::get_object_with_fileinfo(
&bucket,
&object,
@@ -757,7 +761,6 @@ impl ObjectIO for SetDisks {
user_defined.insert(key.clone(), value.clone());
}
}
let sc_parity_drives = {
if let Some(sc) = GLOBAL_STORAGE_CLASS.get() {
sc.get_parity_for_sc(user_defined.get(AMZ_STORAGE_CLASS).cloned().unwrap_or_default().as_str())
+87 -67
View File
@@ -808,14 +808,19 @@ impl TierConfigMgr {
}
}
if !force {
let inuse = d.expect("err").in_use().await;
if let Err(err) = inuse {
let mut e = ERR_TIER_PERM_ERR.clone();
e.message.push('.');
e.message.push_str(&err.to_string());
return Err(e);
} else if inuse.expect("err") {
return Err(ERR_TIER_BACKEND_NOT_EMPTY.clone());
if let Ok(driver) = d {
match driver.in_use().await {
Err(err) => {
let mut e = ERR_TIER_PERM_ERR.clone();
e.message.push('.');
e.message.push_str(&err.to_string());
return Err(e);
}
Ok(in_use) if in_use => {
return Err(ERR_TIER_BACKEND_NOT_EMPTY.clone());
}
_ => {}
}
}
}
self.tiers.remove(tier_name);
@@ -842,11 +847,11 @@ impl TierConfigMgr {
}
pub fn tier_type(&self, tier_name: &str) -> String {
let cfg = self.tiers.get(tier_name);
if cfg.is_none() {
return "internal".to_string();
if let Some(cfg) = self.tiers.get(tier_name) {
cfg.tier_type.as_lowercase()
} else {
"internal".to_string()
}
cfg.expect("err").tier_type.as_lowercase()
}
pub fn list_tiers(&self) -> Vec<TierConfig> {
@@ -876,81 +881,90 @@ impl TierConfigMgr {
let mut tier_config = self.tiers[tier_name].clone();
match tier_type {
TierType::S3 => {
let mut s3 = tier_config.s3.as_mut().expect("err");
if creds.aws_role {
s3.aws_role = true
}
if creds.aws_role_web_identity_token_file != "" && creds.aws_role_arn != "" {
s3.aws_role_arn = creds.aws_role_arn;
s3.aws_role_web_identity_token_file = creds.aws_role_web_identity_token_file;
}
if creds.access_key != "" && creds.secret_key != "" {
s3.access_key = creds.access_key;
s3.secret_key = creds.secret_key;
if let Some(s3) = tier_config.s3.as_mut() {
if creds.aws_role {
s3.aws_role = true
}
if creds.aws_role_web_identity_token_file != "" && creds.aws_role_arn != "" {
s3.aws_role_arn = creds.aws_role_arn;
s3.aws_role_web_identity_token_file = creds.aws_role_web_identity_token_file;
}
if creds.access_key != "" && creds.secret_key != "" {
s3.access_key = creds.access_key;
s3.secret_key = creds.secret_key;
}
}
}
TierType::RustFS => {
let mut rustfs = tier_config.rustfs.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(rustfs) = tier_config.rustfs.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
rustfs.access_key = creds.access_key;
rustfs.secret_key = creds.secret_key;
}
rustfs.access_key = creds.access_key;
rustfs.secret_key = creds.secret_key;
}
TierType::MinIO => {
let compatible_backend = tier_config.minio.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(compatible_backend) = tier_config.minio.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
compatible_backend.access_key = creds.access_key;
compatible_backend.secret_key = creds.secret_key;
}
compatible_backend.access_key = creds.access_key;
compatible_backend.secret_key = creds.secret_key;
}
TierType::Aliyun => {
let mut aliyun = tier_config.aliyun.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(aliyun) = tier_config.aliyun.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
aliyun.access_key = creds.access_key;
aliyun.secret_key = creds.secret_key;
}
aliyun.access_key = creds.access_key;
aliyun.secret_key = creds.secret_key;
}
TierType::Tencent => {
let mut tencent = tier_config.tencent.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(tencent) = tier_config.tencent.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
tencent.access_key = creds.access_key;
tencent.secret_key = creds.secret_key;
}
tencent.access_key = creds.access_key;
tencent.secret_key = creds.secret_key;
}
TierType::Huaweicloud => {
let mut huaweicloud = tier_config.huaweicloud.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(huaweicloud) = tier_config.huaweicloud.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
huaweicloud.access_key = creds.access_key;
huaweicloud.secret_key = creds.secret_key;
}
huaweicloud.access_key = creds.access_key;
huaweicloud.secret_key = creds.secret_key;
}
TierType::Azure => {
let mut azure = tier_config.azure.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(azure) = tier_config.azure.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
azure.access_key = creds.access_key;
azure.secret_key = creds.secret_key;
}
azure.access_key = creds.access_key;
azure.secret_key = creds.secret_key;
}
TierType::GCS => {
let mut gcs = tier_config.gcs.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(gcs) = tier_config.gcs.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
gcs.creds = creds.access_key; //creds.creds_json
}
gcs.creds = creds.access_key; //creds.creds_json
}
TierType::R2 => {
let mut r2 = tier_config.r2.as_mut().expect("err");
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
if let Some(r2) = tier_config.r2.as_mut() {
if creds.access_key == "" || creds.secret_key == "" {
return Err(ERR_TIER_MISSING_CREDENTIALS.clone());
}
r2.access_key = creds.access_key;
r2.secret_key = creds.secret_key;
}
r2.access_key = creds.access_key;
r2.secret_key = creds.secret_key;
}
_ => (),
}
@@ -964,7 +978,7 @@ impl TierConfigMgr {
pub async fn get_driver<'a>(&'a mut self, tier_name: &str) -> std::result::Result<&'a WarmBackendImpl, AdminError> {
// Return cached driver if present
if self.driver_cache.contains_key(tier_name) {
return Ok(self.driver_cache.get(tier_name).unwrap());
return Ok(self.driver_cache.get(tier_name).expect("Driver not found in cache"));
}
// Get tier configuration and create new driver
@@ -974,7 +988,10 @@ impl TierConfigMgr {
// Insert and return reference
self.driver_cache.insert(tier_name.to_string(), driver);
Ok(self.driver_cache.get(tier_name).unwrap())
Ok(self
.driver_cache
.get(tier_name)
.expect("Driver not found in cache after insertion"))
}
pub async fn reload(&mut self, api: Arc<ECStore>) -> std::result::Result<(), std::io::Error> {
@@ -989,9 +1006,12 @@ impl TierConfigMgr {
}
self.driver_cache.clear();
self.tiers.clear();
let new_config = new_config.expect("err");
for (tier, cfg) in new_config.tiers {
self.tiers.insert(tier, cfg);
if let Ok(config) = new_config {
for (tier, cfg) in config.tiers {
self.tiers.insert(tier, cfg);
}
} else {
return Err(std::io::Error::other("Failed to load tier configuration"));
}
self.last_refreshed_at = OffsetDateTime::now_utc();
Ok(())
+81 -63
View File
@@ -155,49 +155,67 @@ impl Clone for TierConfig {
let mut r2 = None;
match self.tier_type {
TierType::S3 => {
let mut s3_ = self.s3.as_ref().expect("err").clone();
s3_.secret_key = "REDACTED".to_string();
s3 = Some(s3_);
if let Some(s3_) = self.s3.as_ref() {
let mut s3_clone = s3_.clone();
s3_clone.secret_key = "REDACTED".to_string();
s3 = Some(s3_clone);
}
}
TierType::RustFS => {
let mut r_ = self.rustfs.as_ref().expect("err").clone();
r_.secret_key = "REDACTED".to_string();
r = Some(r_);
if let Some(r_) = self.rustfs.as_ref() {
let mut r_clone = r_.clone();
r_clone.secret_key = "REDACTED".to_string();
r = Some(r_clone);
}
}
TierType::MinIO => {
let mut compatible_backend_ = self.minio.as_ref().expect("err").clone();
compatible_backend_.secret_key = "REDACTED".to_string();
compatible_backend = Some(compatible_backend_);
if let Some(compatible_backend_) = self.minio.as_ref() {
let mut compatible_backend_clone = compatible_backend_.clone();
compatible_backend_clone.secret_key = "REDACTED".to_string();
compatible_backend = Some(compatible_backend_clone);
}
}
TierType::Aliyun => {
let mut aliyun_ = self.aliyun.as_ref().expect("err").clone();
aliyun_.secret_key = "REDACTED".to_string();
aliyun = Some(aliyun_);
if let Some(aliyun_) = self.aliyun.as_ref() {
let mut aliyun_clone = aliyun_.clone();
aliyun_clone.secret_key = "REDACTED".to_string();
aliyun = Some(aliyun_clone);
}
}
TierType::Tencent => {
let mut tencent_ = self.tencent.as_ref().expect("err").clone();
tencent_.secret_key = "REDACTED".to_string();
tencent = Some(tencent_);
if let Some(tencent_) = self.tencent.as_ref() {
let mut tencent_clone = tencent_.clone();
tencent_clone.secret_key = "REDACTED".to_string();
tencent = Some(tencent_clone);
}
}
TierType::Huaweicloud => {
let mut huaweicloud_ = self.huaweicloud.as_ref().expect("err").clone();
huaweicloud_.secret_key = "REDACTED".to_string();
huaweicloud = Some(huaweicloud_);
if let Some(huaweicloud_) = self.huaweicloud.as_ref() {
let mut huaweicloud_clone = huaweicloud_.clone();
huaweicloud_clone.secret_key = "REDACTED".to_string();
huaweicloud = Some(huaweicloud_clone);
}
}
TierType::Azure => {
let mut azure_ = self.azure.as_ref().expect("err").clone();
azure_.secret_key = "REDACTED".to_string();
azure = Some(azure_);
if let Some(azure_) = self.azure.as_ref() {
let mut azure_clone = azure_.clone();
azure_clone.secret_key = "REDACTED".to_string();
azure = Some(azure_clone);
}
}
TierType::GCS => {
let mut gcs_ = self.gcs.as_ref().expect("err").clone();
gcs_.creds = "REDACTED".to_string();
gcs = Some(gcs_);
if let Some(gcs_) = self.gcs.as_ref() {
let mut gcs_clone = gcs_.clone();
gcs_clone.creds = "REDACTED".to_string();
gcs = Some(gcs_clone);
}
}
TierType::R2 => {
let mut r2_ = self.r2.as_ref().expect("err").clone();
r2_.secret_key = "REDACTED".to_string();
r2 = Some(r2_);
if let Some(r2_) = self.r2.as_ref() {
let mut r2_clone = r2_.clone();
r2_clone.secret_key = "REDACTED".to_string();
r2 = Some(r2_clone);
}
}
_ => (),
}
@@ -222,15 +240,15 @@ impl Clone for TierConfig {
impl TierConfig {
fn endpoint(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().expect("err").endpoint.clone(),
TierType::RustFS => self.rustfs.as_ref().expect("err").endpoint.clone(),
TierType::MinIO => self.minio.as_ref().expect("err").endpoint.clone(),
TierType::Aliyun => self.aliyun.as_ref().expect("err").endpoint.clone(),
TierType::Tencent => self.tencent.as_ref().expect("err").endpoint.clone(),
TierType::Huaweicloud => self.huaweicloud.as_ref().expect("err").endpoint.clone(),
TierType::Azure => self.azure.as_ref().expect("err").endpoint.clone(),
TierType::GCS => self.gcs.as_ref().expect("err").endpoint.clone(),
TierType::R2 => self.r2.as_ref().expect("err").endpoint.clone(),
TierType::S3 => self.s3.as_ref().map(|s| s.endpoint.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.endpoint.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.endpoint.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.endpoint.clone()).unwrap_or_default(),
TierType::Tencent => self.tencent.as_ref().map(|t| t.endpoint.clone()).unwrap_or_default(),
TierType::Huaweicloud => self.huaweicloud.as_ref().map(|h| h.endpoint.clone()).unwrap_or_default(),
TierType::Azure => self.azure.as_ref().map(|a| a.endpoint.clone()).unwrap_or_default(),
TierType::GCS => self.gcs.as_ref().map(|g| g.endpoint.clone()).unwrap_or_default(),
TierType::R2 => self.r2.as_ref().map(|r| r.endpoint.clone()).unwrap_or_default(),
_ => {
info!("unexpected tier type {}", self.tier_type);
"".to_string()
@@ -240,15 +258,15 @@ impl TierConfig {
fn bucket(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().expect("err").bucket.clone(),
TierType::RustFS => self.rustfs.as_ref().expect("err").bucket.clone(),
TierType::MinIO => self.minio.as_ref().expect("err").bucket.clone(),
TierType::Aliyun => self.aliyun.as_ref().expect("err").bucket.clone(),
TierType::Tencent => self.tencent.as_ref().expect("err").bucket.clone(),
TierType::Huaweicloud => self.huaweicloud.as_ref().expect("err").bucket.clone(),
TierType::Azure => self.azure.as_ref().expect("err").bucket.clone(),
TierType::GCS => self.gcs.as_ref().expect("err").bucket.clone(),
TierType::R2 => self.r2.as_ref().expect("err").bucket.clone(),
TierType::S3 => self.s3.as_ref().map(|s| s.bucket.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.bucket.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.bucket.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.bucket.clone()).unwrap_or_default(),
TierType::Tencent => self.tencent.as_ref().map(|t| t.bucket.clone()).unwrap_or_default(),
TierType::Huaweicloud => self.huaweicloud.as_ref().map(|h| h.bucket.clone()).unwrap_or_default(),
TierType::Azure => self.azure.as_ref().map(|a| a.bucket.clone()).unwrap_or_default(),
TierType::GCS => self.gcs.as_ref().map(|g| g.bucket.clone()).unwrap_or_default(),
TierType::R2 => self.r2.as_ref().map(|r| r.bucket.clone()).unwrap_or_default(),
_ => {
info!("unexpected tier type {}", self.tier_type);
"".to_string()
@@ -258,15 +276,15 @@ impl TierConfig {
fn prefix(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().expect("err").prefix.clone(),
TierType::RustFS => self.rustfs.as_ref().expect("err").prefix.clone(),
TierType::MinIO => self.minio.as_ref().expect("err").prefix.clone(),
TierType::Aliyun => self.aliyun.as_ref().expect("err").prefix.clone(),
TierType::Tencent => self.tencent.as_ref().expect("err").prefix.clone(),
TierType::Huaweicloud => self.huaweicloud.as_ref().expect("err").prefix.clone(),
TierType::Azure => self.azure.as_ref().expect("err").prefix.clone(),
TierType::GCS => self.gcs.as_ref().expect("err").prefix.clone(),
TierType::R2 => self.r2.as_ref().expect("err").prefix.clone(),
TierType::S3 => self.s3.as_ref().map(|s| s.prefix.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.prefix.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.prefix.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.prefix.clone()).unwrap_or_default(),
TierType::Tencent => self.tencent.as_ref().map(|t| t.prefix.clone()).unwrap_or_default(),
TierType::Huaweicloud => self.huaweicloud.as_ref().map(|h| h.prefix.clone()).unwrap_or_default(),
TierType::Azure => self.azure.as_ref().map(|a| a.prefix.clone()).unwrap_or_default(),
TierType::GCS => self.gcs.as_ref().map(|g| g.prefix.clone()).unwrap_or_default(),
TierType::R2 => self.r2.as_ref().map(|r| r.prefix.clone()).unwrap_or_default(),
_ => {
info!("unexpected tier type {}", self.tier_type);
"".to_string()
@@ -276,15 +294,15 @@ impl TierConfig {
fn region(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().expect("err").region.clone(),
TierType::RustFS => self.rustfs.as_ref().expect("err").region.clone(),
TierType::MinIO => self.minio.as_ref().expect("err").region.clone(),
TierType::Aliyun => self.aliyun.as_ref().expect("err").region.clone(),
TierType::Tencent => self.tencent.as_ref().expect("err").region.clone(),
TierType::Huaweicloud => self.huaweicloud.as_ref().expect("err").region.clone(),
TierType::Azure => self.azure.as_ref().expect("err").region.clone(),
TierType::GCS => self.gcs.as_ref().expect("err").region.clone(),
TierType::R2 => self.r2.as_ref().expect("err").region.clone(),
TierType::S3 => self.s3.as_ref().map(|s| s.region.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.region.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.region.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.region.clone()).unwrap_or_default(),
TierType::Tencent => self.tencent.as_ref().map(|t| t.region.clone()).unwrap_or_default(),
TierType::Huaweicloud => self.huaweicloud.as_ref().map(|h| h.region.clone()).unwrap_or_default(),
TierType::Azure => self.azure.as_ref().map(|a| a.region.clone()).unwrap_or_default(),
TierType::GCS => self.gcs.as_ref().map(|g| g.region.clone()).unwrap_or_default(),
TierType::R2 => self.r2.as_ref().map(|r| r.region.clone()).unwrap_or_default(),
_ => {
info!("unexpected tier type {}", self.tier_type);
"".to_string()
+130 -52
View File
@@ -1,4 +1,3 @@
#![allow(unused_imports)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,6 +11,7 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
@@ -27,7 +27,7 @@ use crate::error::is_err_bucket_not_found;
use crate::tier::{
tier::ERR_TIER_TYPE_UNSUPPORTED,
tier_config::{TierConfig, TierType},
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_PERM_ERR},
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_NOT_FOUND, ERR_TIER_PERM_ERR},
warm_backend_aliyun::WarmBackendAliyun,
warm_backend_azure::WarmBackendAzure,
warm_backend_gcs::WarmBackendGCS,
@@ -155,7 +155,7 @@ pub fn build_transition_put_options(storage_class: String, mut metadata: HashMap
}
pub async fn check_warm_backend(w: Option<&WarmBackendImpl>) -> Result<(), AdminError> {
let w = w.expect("err");
let w = w.ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
let remote_version_id = w
.put(PROBE_OBJECT, ReaderImpl::Body(Bytes::from("RustFS".as_bytes().to_vec())), 5)
.await;
@@ -176,9 +176,11 @@ pub async fn check_warm_backend(w: Option<&WarmBackendImpl>) -> Result<(), Admin
return Err(ERR_TIER_PERM_ERR.clone());
//}
}
if let Err(err) = w.remove(PROBE_OBJECT, &remote_version_id.expect("err")).await {
return Err(ERR_TIER_PERM_ERR.clone());
};
if let Ok(version_id) = remote_version_id {
if let Err(err) = w.remove(PROBE_OBJECT, &version_id).await {
return Err(ERR_TIER_PERM_ERR.clone());
};
}
Ok(())
}
@@ -186,119 +188,195 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
let mut d: Option<WarmBackendImpl> = None;
match tier.tier_type {
TierType::S3 => {
let dd = WarmBackendS3::new(tier.s3.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(s3_config) = tier.s3.as_ref() {
let dd = WarmBackendS3::new(s3_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create S3 backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "S3 tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::RustFS => {
let dd = WarmBackendRustFS::new(tier.rustfs.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(rustfs_config) = tier.rustfs.as_ref() {
let dd = WarmBackendRustFS::new(rustfs_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create RustFS backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "RustFS tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::MinIO => {
let dd = WarmBackendMinIO::new(tier.minio.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(minio_config) = tier.minio.as_ref() {
let dd = WarmBackendMinIO::new(minio_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create MinIO backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "MinIO tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::Aliyun => {
let dd = WarmBackendAliyun::new(tier.aliyun.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(aliyun_config) = tier.aliyun.as_ref() {
let dd = WarmBackendAliyun::new(aliyun_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create Aliyun backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "Aliyun tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::Tencent => {
let dd = WarmBackendTencent::new(tier.tencent.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(tencent_config) = tier.tencent.as_ref() {
let dd = WarmBackendTencent::new(tencent_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create Tencent backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "Tencent tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::Huaweicloud => {
let dd = WarmBackendHuaweicloud::new(tier.huaweicloud.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(huaweicloud_config) = tier.huaweicloud.as_ref() {
let dd = WarmBackendHuaweicloud::new(huaweicloud_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create Huaweicloud backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "Huaweicloud tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::Azure => {
let dd = WarmBackendAzure::new(tier.azure.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(azure_config) = tier.azure.as_ref() {
let dd = WarmBackendAzure::new(azure_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create Azure backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "Azure tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::GCS => {
let dd = WarmBackendGCS::new(tier.gcs.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(gcs_config) = tier.gcs.as_ref() {
let dd = WarmBackendGCS::new(gcs_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create GCS backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "GCS tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
TierType::R2 => {
let dd = WarmBackendR2::new(tier.r2.as_ref().expect("err"), &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
if let Some(r2_config) = tier.r2.as_ref() {
let dd = WarmBackendR2::new(r2_config, &tier.name).await;
if let Err(err) = dd {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("Failed to create R2 backend")));
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {}", err.to_string()),
message: "R2 tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
d = Some(Box::new(dd.expect("err")));
}
_ => {
return Err(ERR_TIER_TYPE_UNSUPPORTED.clone());
}
}
Ok(d.expect("err"))
d.ok_or_else(|| AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: "Tier backend not initialized".to_string(),
status_code: StatusCode::BAD_REQUEST,
})
}
#[cfg(test)]
@@ -76,12 +76,10 @@ impl WarmBackendAliyun {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"aliyun",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "aliyun").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
@@ -76,12 +76,10 @@ impl WarmBackendAzure {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"azure",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "azure").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
@@ -76,12 +76,11 @@ impl WarmBackendHuaweicloud {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"huaweicloud",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client =
TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "huaweicloud").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
@@ -75,12 +75,10 @@ impl WarmBackendMinIO {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"minio",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "minio").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
+4 -6
View File
@@ -75,12 +75,10 @@ impl WarmBackendR2 {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"r2",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "r2").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
+8 -3
View File
@@ -95,7 +95,10 @@ impl WarmBackendS3 {
region: conf.region.clone(),
..Default::default()
};
let client = TransitionClient::new(&u.host().expect("err").to_string(), opts, "s3").await?;
let host = u
.host()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&host.to_string(), opts, "s3").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
@@ -164,8 +167,10 @@ impl WarmBackend for WarmBackendS3 {
ropts.version_id = rv.to_string();
}
let client = self.client.clone();
let err = client.remove_object(&self.bucket, &self.get_dest(object), ropts).await;
Err(std::io::Error::other(err.expect("err")))
match client.remove_object(&self.bucket, &self.get_dest(object), ropts).await {
None => Ok(()),
Some(err) => Err(std::io::Error::other(err)),
}
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
@@ -190,6 +190,6 @@ impl WarmBackend for WarmBackendS3 {
return Err(std::io::Error::other("list_objects_v2 error"));
};
Ok(res.common_prefixes.unwrap().len() > 0 || res.contents.unwrap().len() > 0)
Ok(res.common_prefixes.unwrap_or_default().len() > 0 || res.contents.unwrap_or_default().len() > 0)
}
}
@@ -76,12 +76,10 @@ impl WarmBackendTencent {
};
let scheme = u.scheme();
let default_port = if scheme == "https" { 443 } else { 80 };
let client = TransitionClient::new(
&format!("{}:{}", u.host_str().expect("err"), u.port().unwrap_or(default_port)),
opts,
"tencent",
)
.await?;
let host = u
.host_str()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&format!("{}:{}", host, u.port().unwrap_or(default_port)), opts, "tencent").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
+45 -6
View File
@@ -21,8 +21,8 @@
use crate::oidc_state::{OidcAuthSession, OidcStateStore};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreProviderMetadata};
use openidconnect::{
AsyncHttpClient, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, Scope,
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
};
use reqwest::Client;
use rustfs_config::oidc::*;
@@ -157,6 +157,7 @@ pub struct OidcProviderConfig {
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
pub other_audiences: Vec<String>,
pub redirect_uri: Option<String>,
pub redirect_uri_dynamic: bool,
pub claim_name: String,
@@ -235,6 +236,15 @@ pub struct OidcSys {
http_client: ReqwestHttpClient,
}
fn trusted_aud(other_audiences: &[String], audience: &Audience) -> bool {
for aud in other_audiences {
if audience.as_str() == aud.as_str() {
return true;
}
}
false
}
impl OidcSys {
/// Parse environment variables and discover all configured OIDC providers.
pub async fn new() -> Result<Self, String> {
@@ -394,7 +404,9 @@ impl OidcSys {
.id_token()
.ok_or_else(|| "no id_token in token response".to_string())?;
let verifier = client.id_token_verifier();
let verifier = client
.id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
let verified = id_token.claims(&verifier, &Nonce::new(session.nonce.clone()));
if let Err(e) = verified {
let refreshed_state = self
@@ -416,7 +428,9 @@ impl OidcSys {
)
.set_auth_type(AuthType::RequestBody);
let verifier = client.id_token_verifier();
let verifier = client
.id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
id_token
.claims(&verifier, &Nonce::new(session.nonce.clone()))
.map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?;
@@ -529,7 +543,9 @@ impl OidcSys {
// Verify the token (signature, issuer, audience, expiry) — skip nonce
// (nonce is only required for the authorization code flow)
let verifier = client.id_token_verifier();
let verifier = client
.id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
if let Err(e) = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(())) {
state = self
.refresh_provider_state(&provider_id, &config)
@@ -544,7 +560,9 @@ impl OidcSys {
config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
)
.set_auth_type(AuthType::RequestBody);
let verifier = client.id_token_verifier();
let verifier = client
.id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
id_token
.claims(&verifier, |_: Option<&Nonce>| Ok(()))
.map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?;
@@ -742,6 +760,14 @@ impl OidcSys {
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
};
let other_audiences_str = get_env(ENV_IDENTITY_OPENID_OTHER_AUDIENCES);
let other_audiences = other_audiences_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let redirect_uri_dynamic_str = get_env(ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC);
let redirect_uri_dynamic = redirect_uri_dynamic_str.is_empty()
|| redirect_uri_dynamic_str
@@ -802,6 +828,7 @@ impl OidcSys {
client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID),
client_secret,
scopes,
other_audiences,
redirect_uri,
redirect_uri_dynamic,
claim_name,
@@ -835,6 +862,14 @@ impl OidcSys {
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
};
let other_audiences_str = kvs.get(OIDC_OTHER_AUDIENCES);
let other_audiences = other_audiences_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let redirect_uri_dynamic = kvs
.lookup(OIDC_REDIRECT_URI_DYNAMIC)
.unwrap_or_else(|| EnableState::On.to_string())
@@ -868,6 +903,7 @@ impl OidcSys {
client_id: kvs.get(OIDC_CLIENT_ID),
client_secret,
scopes,
other_audiences,
redirect_uri,
redirect_uri_dynamic,
claim_name,
@@ -1352,6 +1388,7 @@ mod tests {
client_id: "rustfs-oidc-test".to_string(),
client_secret: None,
scopes: vec!["openid".to_string()],
other_audiences: vec![],
redirect_uri: None,
redirect_uri_dynamic: false,
claim_name: "sub".to_string(),
@@ -1717,6 +1754,7 @@ mod tests {
client_id: "client-id".to_string(),
client_secret: None,
scopes: vec!["openid".to_string()],
other_audiences: vec![],
redirect_uri: None,
redirect_uri_dynamic: true,
claim_name: "groups".to_string(),
@@ -1811,6 +1849,7 @@ mod tests {
client_id: "my-client".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
other_audiences: vec![],
redirect_uri: None,
redirect_uri_dynamic: true,
claim_name: "groups".to_string(),
+3
View File
@@ -38,3 +38,6 @@ time.workspace = true
[lib]
doctest = false
[dev-dependencies]
rmp-serde.workspace = true
+131 -5
View File
@@ -86,10 +86,6 @@ pub struct Disk {
pub write_latency: f64,
pub utilization: f64,
pub metrics: Option<DiskMetrics>,
#[serde(rename = "runtimeState", default, skip_serializing_if = "Option::is_none")]
pub runtime_state: Option<String>,
#[serde(rename = "offlineDurationSeconds", default, skip_serializing_if = "Option::is_none")]
pub offline_duration_seconds: Option<u64>,
pub heal_info: Option<HealingDisk>,
pub used_inodes: u64,
pub free_inodes: u64,
@@ -97,6 +93,10 @@ pub struct Disk {
pub pool_index: i32,
pub set_index: i32,
pub disk_index: i32,
#[serde(rename = "runtimeState", default, skip_serializing_if = "Option::is_none")]
pub runtime_state: Option<String>,
#[serde(rename = "offlineDurationSeconds", default, skip_serializing_if = "Option::is_none")]
pub offline_duration_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -353,10 +353,50 @@ pub struct InfoMessage {
#[cfg(test)]
mod tests {
use super::*;
use rmp_serde::{Deserializer, Serializer};
use serde_json;
use std::collections::HashMap;
use std::{collections::HashMap, io::Cursor};
use time::OffsetDateTime;
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
struct LegacyDiskCompat {
endpoint: String,
#[serde(rename = "rootDisk")]
root_disk: bool,
#[serde(rename = "path")]
drive_path: String,
healing: bool,
scanning: bool,
state: String,
uuid: String,
major: u32,
minor: u32,
model: Option<String>,
#[serde(rename = "totalspace")]
total_space: u64,
#[serde(rename = "usedspace")]
used_space: u64,
#[serde(rename = "availspace")]
available_space: u64,
#[serde(rename = "readthroughput")]
read_throughput: f64,
#[serde(rename = "writethroughput")]
write_throughput: f64,
#[serde(rename = "readlatency")]
read_latency: f64,
#[serde(rename = "writelatency")]
write_latency: f64,
utilization: f64,
metrics: Option<DiskMetrics>,
heal_info: Option<HealingDisk>,
used_inodes: u64,
free_inodes: u64,
local: bool,
pool_index: i32,
set_index: i32,
disk_index: i32,
}
#[test]
fn test_item_state_to_string() {
assert_eq!(ItemState::Offline.to_string(), ITEM_OFFLINE);
@@ -496,6 +536,92 @@ mod tests {
assert!(disk.local);
}
#[test]
fn test_disk_msgpack_backward_compat_from_legacy_layout() {
let legacy = LegacyDiskCompat {
endpoint: "http://legacy-node:9000".to_string(),
root_disk: false,
drive_path: "/data/legacy".to_string(),
healing: false,
scanning: false,
state: ITEM_ONLINE.to_string(),
uuid: "legacy-uuid".to_string(),
major: 8,
minor: 2,
model: Some("legacy".to_string()),
total_space: 42,
used_space: 12,
available_space: 30,
read_throughput: 1.0,
write_throughput: 2.0,
read_latency: 3.0,
write_latency: 4.0,
utilization: 5.0,
metrics: None,
heal_info: None,
used_inodes: 11_125,
free_inodes: 98_000,
local: true,
pool_index: 1,
set_index: 2,
disk_index: 3,
};
let mut encoded = Vec::new();
legacy.serialize(&mut Serializer::new(&mut encoded)).unwrap();
let mut decoder = Deserializer::new(Cursor::new(encoded));
let decoded: Disk = serde::Deserialize::deserialize(&mut decoder).unwrap();
assert_eq!(decoded.used_inodes, 11_125);
assert_eq!(decoded.runtime_state, None);
assert_eq!(decoded.offline_duration_seconds, None);
}
#[test]
fn test_disk_msgpack_forward_compat_to_legacy_layout() {
let current = Disk {
endpoint: "http://current-node:9000".to_string(),
root_disk: false,
drive_path: "/data/current".to_string(),
healing: false,
scanning: false,
state: ITEM_ONLINE.to_string(),
uuid: "current-uuid".to_string(),
major: 8,
minor: 3,
model: Some("current".to_string()),
total_space: 64,
used_space: 20,
available_space: 44,
read_throughput: 1.5,
write_throughput: 2.5,
read_latency: 3.5,
write_latency: 4.5,
utilization: 6.5,
metrics: None,
heal_info: None,
used_inodes: 22_250,
free_inodes: 97_000,
local: true,
pool_index: 1,
set_index: 2,
disk_index: 3,
runtime_state: Some("online".to_string()),
offline_duration_seconds: Some(0),
};
let mut encoded = Vec::new();
current
.serialize(&mut Serializer::new(&mut encoded).with_struct_map())
.unwrap();
let mut decoder = Deserializer::new(Cursor::new(encoded));
let decoded: LegacyDiskCompat = serde::Deserialize::deserialize(&mut decoder).unwrap();
assert_eq!(decoded.used_inodes, 22_250);
assert_eq!(decoded.disk_index, 3);
assert_eq!(decoded.endpoint, "http://current-node:9000");
}
#[test]
fn test_healing_disk_default() {
let healing_disk = HealingDisk::default();
+48 -176
View File
@@ -14,28 +14,19 @@
use crate::Event;
use async_trait::async_trait;
use hashbrown::HashSet;
use rumqttc::QoS;
use rustfs_config::notify::{ENV_NOTIFY_MQTT_KEYS, ENV_NOTIFY_WEBHOOK_KEYS, NOTIFY_MQTT_KEYS, NOTIFY_WEBHOOK_KEYS};
use rustfs_config::{
DEFAULT_LIMIT, EVENT_DEFAULT_DIR, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR,
MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY,
MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT,
WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR,
WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
};
use rustfs_config::EVENT_DEFAULT_DIR;
use rustfs_config::notify::{NOTIFY_MQTT_KEYS, NOTIFY_NATS_KEYS, NOTIFY_PULSAR_KEYS, NOTIFY_WEBHOOK_KEYS};
use rustfs_ecstore::config::KVS;
use rustfs_targets::{
Target,
error::TargetError,
target::{
mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url},
webhook::WebhookArgs,
config::{
build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config,
validate_pulsar_config, validate_webhook_config,
},
error::TargetError,
target::TargetType,
};
use std::time::Duration;
use tracing::{debug, warn};
use url::Url;
use std::collections::HashSet;
/// Trait for creating targets from configuration
#[async_trait]
@@ -49,10 +40,6 @@ pub trait TargetFactory: Send + Sync {
/// Returns a set of valid configuration field names for this target type.
/// This is used to filter environment variables.
fn get_valid_fields(&self) -> HashSet<String>;
/// Returns a set of valid configuration env field names for this target type.
/// This is used to filter environment variables.
fn get_valid_env_fields(&self) -> HashSet<String>;
}
/// Factory for creating Webhook targets
@@ -61,75 +48,18 @@ pub struct WebhookTargetFactory;
#[async_trait]
impl TargetFactory for WebhookTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<Event> + Send + Sync>, TargetError> {
// All config values are now read directly from the merged `config` KVS.
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
let parsed_endpoint = endpoint.trim();
let endpoint_url = Url::parse(parsed_endpoint)
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
let args = WebhookArgs {
enable: true, // If we are here, it's already enabled.
endpoint: endpoint_url,
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
queue_dir: config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string()),
queue_limit: config
.lookup(WEBHOOK_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(),
skip_tls_verify: config
.lookup(WEBHOOK_SKIP_TLS_VERIFY)
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT),
target_type: rustfs_targets::target::TargetType::NotifyEvent,
};
let args = build_webhook_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
let target = rustfs_targets::target::webhook::WebhookTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
// Validation also uses the merged `config` KVS directly.
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
debug!("endpoint: {}", endpoint);
let parsed_endpoint = endpoint.trim();
Url::parse(parsed_endpoint)
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default();
let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default();
if client_cert.is_empty() != client_key.is_empty() {
return Err(TargetError::Configuration(
"Both client_cert and client_key must be specified together".to_string(),
));
}
let queue_dir = config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string());
if !queue_dir.is_empty() && !std::path::Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
}
Ok(())
validate_webhook_config(config, EVENT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
NOTIFY_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
}
fn get_valid_env_fields(&self) -> HashSet<String> {
ENV_NOTIFY_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
}
}
/// Factory for creating MQTT targets
@@ -138,112 +68,54 @@ pub struct MQTTTargetFactory;
#[async_trait]
impl TargetFactory for MQTTTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<Event> + Send + Sync>, TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let broker_url = Url::parse(&broker)
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
let topic = config
.lookup(MQTT_TOPIC)
.ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?;
let args = MQTTArgs {
enable: true, // Assumed enabled.
broker: broker_url,
topic,
qos: config
.lookup(MQTT_QOS)
.and_then(|v| v.parse::<u8>().ok())
.map(|q| match q {
0 => QoS::AtMostOnce,
1 => QoS::AtLeastOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtLeastOnce,
})
.unwrap_or(QoS::AtLeastOnce),
username: config.lookup(MQTT_USERNAME).unwrap_or_default(),
password: config.lookup(MQTT_PASSWORD).unwrap_or_default(),
max_reconnect_interval: config
.lookup(MQTT_RECONNECT_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(5)),
keep_alive: config
.lookup(MQTT_KEEP_ALIVE_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(30)),
tls: MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?,
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| EVENT_DEFAULT_DIR.to_string()),
queue_limit: config
.lookup(MQTT_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
target_type: rustfs_targets::target::TargetType::NotifyEvent,
};
let args = build_mqtt_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
let target = rustfs_targets::target::mqtt::MQTTTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let url = Url::parse(&broker)
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
let tls = MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?;
validate_mqtt_broker_url(&url, &tls)?;
if config.lookup(MQTT_TOPIC).is_none() {
return Err(TargetError::Configuration("Missing MQTT topic".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS) {
let qos = qos_str
.parse::<u8>()
.map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?;
if qos > 2 {
return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string()));
}
}
let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default();
if !queue_dir.is_empty() {
if !std::path::Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS)
&& qos_str == "0"
{
warn!("Using queue_dir with QoS 0 may result in event loss");
}
}
Ok(())
validate_mqtt_config(config)
}
fn get_valid_fields(&self) -> HashSet<String> {
NOTIFY_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
}
}
fn get_valid_env_fields(&self) -> HashSet<String> {
ENV_NOTIFY_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
pub struct NATSTargetFactory;
#[async_trait]
impl TargetFactory for NATSTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<Event> + Send + Sync>, TargetError> {
let args = build_nats_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
let target = rustfs_targets::target::nats::NATSTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
validate_nats_config(config, EVENT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
NOTIFY_NATS_KEYS.iter().map(|s| s.to_string()).collect()
}
}
pub struct PulsarTargetFactory;
#[async_trait]
impl TargetFactory for PulsarTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<Event> + Send + Sync>, TargetError> {
let args = build_pulsar_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
let target = rustfs_targets::target::pulsar::PulsarTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
validate_pulsar_config(config, EVENT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
NOTIFY_PULSAR_KEYS.iter().map(|s| s.to_string()).collect()
}
}
+18 -1
View File
@@ -19,7 +19,8 @@ use crate::{
};
use hashbrown::HashMap;
use rustfs_config::notify::{
DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS,
DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS,
NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_ecstore::config::{Config, KVS};
use rustfs_s3_common::EventName;
@@ -40,6 +41,8 @@ fn subsystem_target_type(target_type: &str) -> &str {
match target_type {
NOTIFY_WEBHOOK_SUB_SYS => "webhook",
NOTIFY_MQTT_SUB_SYS => "mqtt",
NOTIFY_NATS_SUB_SYS => "nats",
NOTIFY_PULSAR_SUB_SYS => "pulsar",
_ => target_type,
}
}
@@ -746,4 +749,18 @@ mod tests {
assert_eq!(target_id.id, "analytics");
assert_eq!(target_id.name, "mqtt");
}
#[test]
fn runtime_target_id_for_subsystem_maps_notify_nats_to_runtime_type() {
let target_id = runtime_target_id_for_subsystem(NOTIFY_NATS_SUB_SYS, "Bus");
assert_eq!(target_id.id, "bus");
assert_eq!(target_id.name, "nats");
}
#[test]
fn runtime_target_id_for_subsystem_maps_notify_pulsar_to_runtime_type() {
let target_id = runtime_target_id_for_subsystem(NOTIFY_PULSAR_SUB_SYS, "Ledger");
assert_eq!(target_id.id, "ledger");
assert_eq!(target_id.name, "pulsar");
}
}
+15 -135
View File
@@ -13,15 +13,14 @@
// limitations under the License.
use crate::Event;
use crate::factory::{MQTTTargetFactory, TargetFactory, WebhookTargetFactory};
use crate::factory::{MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory};
use futures::stream::{FuturesUnordered, StreamExt};
use hashbrown::{HashMap, HashSet};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, notify::NOTIFY_ROUTE_PREFIX};
use hashbrown::HashMap;
use rustfs_config::notify::NOTIFY_ROUTE_PREFIX;
use rustfs_ecstore::config::{Config, KVS};
use rustfs_targets::{Target, TargetError, target::ChannelTargetType};
use std::str::FromStr;
use rustfs_targets::{Target, TargetError, config::collect_target_configs, target::ChannelTargetType};
use std::sync::Arc;
use tracing::{debug, error, info, warn};
use tracing::{error, info};
/// Registry for managing target factories
pub struct TargetRegistry {
@@ -44,6 +43,8 @@ impl TargetRegistry {
// Register built-in factories
registry.register(ChannelTargetType::Webhook.as_str(), Box::new(WebhookTargetFactory));
registry.register(ChannelTargetType::Mqtt.as_str(), Box::new(MQTTTargetFactory));
registry.register(ChannelTargetType::Nats.as_str(), Box::new(NATSTargetFactory));
registry.register(ChannelTargetType::Pulsar.as_str(), Box::new(PulsarTargetFactory));
registry
}
@@ -85,143 +86,22 @@ impl TargetRegistry {
&self,
config: &Config,
) -> Result<Vec<Box<dyn Target<Event> + Send + Sync>>, TargetError> {
// Collect only environment variables with the relevant prefix to reduce memory usage
let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect();
// A collection of asynchronous tasks for concurrently executing target creation
let mut tasks = FuturesUnordered::new();
// 1. Traverse all registered plants and process them by target type
for (target_type, factory) in &self.factories {
tracing::Span::current().record("target_type", target_type.as_str());
info!("Start working on target types...");
// 2. Prepare the configuration source
// 2.1. Get the configuration segment in the file, e.g. 'notify_webhook'
let section_name = format!("{NOTIFY_ROUTE_PREFIX}{target_type}").to_lowercase();
let file_configs = config.0.get(&section_name).cloned().unwrap_or_default();
// 2.2. Get the default configuration for that type
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
debug!(?default_cfg, "Get the default configuration");
// *** Optimization point 1: Get all legitimate fields of the current target type ***
let valid_fields = factory.get_valid_fields();
debug!(?valid_fields, "Get the legitimate configuration fields");
// 3. Resolve instance IDs and configuration overrides from environment variables
let mut instance_ids_from_env = HashSet::new();
// 3.1. Instance discovery: Based on the '..._ENABLE_INSTANCEID' format
let enable_prefix =
format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}")
.to_uppercase();
for (key, value) in &all_env {
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false)
&& let Some(id) = key.strip_prefix(&enable_prefix)
&& !id.is_empty()
{
instance_ids_from_env.insert(id.to_lowercase());
}
}
// 3.2. Parse all relevant environment variable configurations
// 3.2.1. Build environment variable prefixes such as 'RUSTFS_NOTIFY_WEBHOOK_'
let env_prefix = format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}").to_uppercase();
// 3.2.2. 'env_overrides' is used to store configurations parsed from environment variables in the format: {instance id -> {field -> value}}
let mut env_overrides: HashMap<String, HashMap<String, String>> = HashMap::new();
for (key, value) in &all_env {
if let Some(rest) = key.strip_prefix(&env_prefix) {
// Use rsplitn to split from the right side to properly extract the INSTANCE_ID at the end
// Format: <FIELD_NAME>_<INSTANCE_ID> or <FIELD_NAME>
let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER);
// The first part from the right is INSTANCE_ID
let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER);
// The remaining part is FIELD_NAME
let field_name_part = parts.next();
let (field_name, instance_id) = match field_name_part {
// Case 1: The format is <FIELD_NAME>_<INSTANCE_ID>
// e.g., rest = "ENDPOINT_PRIMARY" -> field_name="ENDPOINT", instance_id="PRIMARY"
Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()),
// Case 2: The format is <FIELD_NAME> (without INSTANCE_ID)
// e.g., rest = "ENABLE" -> field_name="ENABLE", instance_id="" (Universal configuration `_ DEFAULT_DELIMITER`)
None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()),
};
// *** Optimization point 2: Verify whether the parsed field_name is legal ***
if !field_name.is_empty() && valid_fields.contains(&field_name) {
debug!(
instance_id = %if instance_id.is_empty() { DEFAULT_DELIMITER } else { &instance_id },
%field_name,
%value,
"Parsing to environment variables"
);
env_overrides
.entry(instance_id)
.or_default()
.insert(field_name, value.clone());
} else {
// Ignore illegal field names
warn!(
field_name = %field_name,
"Ignore environment variable fields, not found in the list of valid fields for target type {}",
target_type
);
}
}
}
debug!(?env_overrides, "Complete the environment variable analysis");
// 4. Determine all instance IDs that need to be processed
let mut all_instance_ids: HashSet<String> =
file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect();
all_instance_ids.extend(instance_ids_from_env);
debug!(?all_instance_ids, "Determine all instance IDs");
// 5. Merge configurations and create tasks for each instance
for id in all_instance_ids {
// 5.1. Merge configuration, priority: Environment variables > File instance configuration > File default configuration
let mut merged_config = default_cfg.clone();
// Instance-specific configuration in application files
if let Some(file_instance_cfg) = file_configs.get(&id) {
merged_config.extend(file_instance_cfg.clone());
}
// Application instance-specific environment variable configuration
if let Some(env_instance_cfg) = env_overrides.get(&id) {
// Convert HashMap<String, String> to KVS
let mut kvs_from_env = KVS::new();
for (k, v) in env_instance_cfg {
kvs_from_env.insert(k.clone(), v.clone());
}
merged_config.extend(kvs_from_env);
}
debug!(instance_id = %id, ?merged_config, "Complete configuration merge");
// 5.2. Check if the instance is enabled
let enabled = merged_config
.lookup(ENABLE_KEY)
.map(|v| {
EnableState::from_str(v.as_str())
.ok()
.map(|s| s.is_enabled())
.unwrap_or(false)
})
.unwrap_or(false);
if enabled {
info!(instance_id = %id, "Target is enabled, ready to create a task");
// 5.3. Create asynchronous tasks for enabled instances
let tid = id.clone();
let merged_config_arc = Arc::new(merged_config);
tasks.push(async move {
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
(tid, result)
});
} else {
info!(instance_id = %id, "Skip disabled target");
}
for (id, merged_config) in collect_target_configs(config, NOTIFY_ROUTE_PREFIX, target_type, &valid_fields) {
info!(instance_id = %id, "Target is enabled, ready to create a task");
let tid = id.clone();
let merged_config_arc = Arc::new(merged_config);
tasks.push(async move {
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
(tid, result)
});
}
}
// 6. Concurrently execute all creation tasks and collect results
let mut successful_targets = Vec::new();
while let Some((id, result)) = tasks.next().await {
match result {
+99 -14
View File
@@ -20,13 +20,14 @@ use futures::FutureExt;
use rustfs_common::capacity_scope::{CapacityScope, CapacityScopeDisk, drain_global_dirty_scopes, take_capacity_scope};
use rustfs_config::{
DEFAULT_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, DEFAULT_CAPACITY_FOLLOW_SYMLINKS, DEFAULT_CAPACITY_MAX_SYMLINK_DEPTH,
DEFAULT_CAPACITY_MAX_TIMEOUT_SECS, DEFAULT_CAPACITY_MIN_TIMEOUT_SECS, DEFAULT_CAPACITY_STALL_TIMEOUT_SECS,
DEFAULT_FAST_UPDATE_THRESHOLD_SECS, DEFAULT_MAX_FILES_THRESHOLD, DEFAULT_SAMPLE_RATE, DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS,
DEFAULT_STAT_TIMEOUT_SECS, DEFAULT_WRITE_FREQUENCY_THRESHOLD, DEFAULT_WRITE_TRIGGER_DELAY_SECS,
ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, ENV_CAPACITY_FAST_UPDATE_THRESHOLD, ENV_CAPACITY_FOLLOW_SYMLINKS,
ENV_CAPACITY_MAX_FILES_THRESHOLD, ENV_CAPACITY_MAX_SYMLINK_DEPTH, ENV_CAPACITY_MAX_TIMEOUT, ENV_CAPACITY_MIN_TIMEOUT,
ENV_CAPACITY_SAMPLE_RATE, ENV_CAPACITY_SCHEDULED_INTERVAL, ENV_CAPACITY_STALL_TIMEOUT, ENV_CAPACITY_STAT_TIMEOUT,
ENV_CAPACITY_WRITE_FREQUENCY_THRESHOLD, ENV_CAPACITY_WRITE_TRIGGER_DELAY,
DEFAULT_CAPACITY_MAX_TIMEOUT_SECS, DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, DEFAULT_CAPACITY_MIN_TIMEOUT_SECS,
DEFAULT_CAPACITY_STALL_TIMEOUT_SECS, DEFAULT_FAST_UPDATE_THRESHOLD_SECS, DEFAULT_MAX_FILES_THRESHOLD, DEFAULT_SAMPLE_RATE,
DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS, DEFAULT_STAT_TIMEOUT_SECS, DEFAULT_WRITE_FREQUENCY_THRESHOLD,
DEFAULT_WRITE_TRIGGER_DELAY_SECS, ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, ENV_CAPACITY_FAST_UPDATE_THRESHOLD,
ENV_CAPACITY_FOLLOW_SYMLINKS, ENV_CAPACITY_MAX_FILES_THRESHOLD, ENV_CAPACITY_MAX_SYMLINK_DEPTH, ENV_CAPACITY_MAX_TIMEOUT,
ENV_CAPACITY_METRICS_INTERVAL, ENV_CAPACITY_MIN_TIMEOUT, ENV_CAPACITY_SAMPLE_RATE, ENV_CAPACITY_SCHEDULED_INTERVAL,
ENV_CAPACITY_STALL_TIMEOUT, ENV_CAPACITY_STAT_TIMEOUT, ENV_CAPACITY_WRITE_FREQUENCY_THRESHOLD,
ENV_CAPACITY_WRITE_TRIGGER_DELAY,
};
use rustfs_io_metrics::capacity_metrics::{
record_capacity_current_bytes, record_capacity_dirty_disk_count, record_capacity_refresh_inflight,
@@ -63,6 +64,8 @@ struct CachedCapacityConfig {
stat_timeout: Duration,
/// Sample rate
sample_rate: usize,
/// Metrics logging interval
metrics_interval: Duration,
/// Follow symlinks flag
follow_symlinks: bool,
/// Max symlink depth
@@ -97,6 +100,10 @@ impl CachedCapacityConfig {
max_files_threshold: get_env_usize(ENV_CAPACITY_MAX_FILES_THRESHOLD, DEFAULT_MAX_FILES_THRESHOLD),
stat_timeout: Duration::from_secs(get_env_u64(ENV_CAPACITY_STAT_TIMEOUT, DEFAULT_STAT_TIMEOUT_SECS)),
sample_rate: get_env_usize(ENV_CAPACITY_SAMPLE_RATE, DEFAULT_SAMPLE_RATE),
metrics_interval: Duration::from_secs(get_env_u64(
ENV_CAPACITY_METRICS_INTERVAL,
DEFAULT_CAPACITY_METRICS_INTERVAL_SECS,
)),
follow_symlinks: get_env_bool(ENV_CAPACITY_FOLLOW_SYMLINKS, DEFAULT_CAPACITY_FOLLOW_SYMLINKS),
max_symlink_depth: get_env_u64(ENV_CAPACITY_MAX_SYMLINK_DEPTH, DEFAULT_CAPACITY_MAX_SYMLINK_DEPTH as u64) as u8,
enable_dynamic_timeout: get_env_bool(ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, DEFAULT_CAPACITY_ENABLE_DYNAMIC_TIMEOUT),
@@ -204,6 +211,18 @@ pub fn get_sample_rate() -> usize {
get_cached_config().sample_rate
}
/// Get capacity metrics logging interval from environment or default
#[cfg(not(test))]
pub fn get_metrics_interval() -> Duration {
get_cached_config().metrics_interval
}
/// Get capacity metrics logging interval from environment or default (test mode)
#[cfg(test)]
pub fn get_metrics_interval() -> Duration {
get_cached_config().metrics_interval
}
/// Get follow symlinks flag from environment or default
#[cfg(not(test))]
pub fn get_follow_symlinks() -> bool {
@@ -462,6 +481,8 @@ pub struct HybridStrategyConfig {
pub write_frequency_threshold: usize,
/// Fast update threshold
pub fast_update_threshold: Duration,
/// Metrics logging interval
pub metrics_interval: Duration,
/// Enable smart update
pub enable_smart_update: bool,
/// Enable write trigger
@@ -475,6 +496,7 @@ impl Default for HybridStrategyConfig {
write_trigger_delay: get_write_trigger_delay(),
write_frequency_threshold: get_write_frequency_threshold(),
fast_update_threshold: get_fast_update_threshold(),
metrics_interval: get_metrics_interval(),
enable_smart_update: true,
enable_write_trigger: true,
}
@@ -877,6 +899,35 @@ impl HybridCapacityManager {
pub async fn refresh_in_progress(&self) -> bool {
self.refresh_state.lock().await.running
}
/// Log capacity runtime summary for observability.
async fn log_runtime_summary(&self) {
let cached = self.get_capacity().await;
let recent_write_frequency = self.get_write_frequency().await;
let dirty_disks = self.get_dirty_disks().await;
let refresh_running = self.refresh_in_progress().await;
if let Some(cached) = cached {
info!(
total_used = cached.total_used,
file_count = cached.file_count,
estimated = cached.is_estimated,
source = ?cached.source,
cache_age_secs = cached.last_update.elapsed().as_secs(),
writes_per_minute = recent_write_frequency,
dirty_disk_count = dirty_disks.len(),
refresh_inflight = refresh_running,
"Capacity metrics summary"
);
} else {
info!(
writes_per_minute = recent_write_frequency,
dirty_disk_count = dirty_disks.len(),
refresh_inflight = refresh_running,
"Capacity metrics summary (cache empty)"
);
}
}
}
/// Global capacity manager instance
@@ -909,23 +960,30 @@ pub fn create_isolated_manager(config: HybridStrategyConfig) -> Arc<HybridCapaci
/// Start background update task
pub async fn start_background_task(disks: Vec<CapacityDiskRef>) {
let manager = get_capacity_manager();
let mut interval = manager.get_config().scheduled_update_interval;
let manager_for_refresh = manager.clone();
let manager_for_metrics = manager.clone();
let mut refresh_interval = manager.get_config().scheduled_update_interval;
let mut metrics_interval = manager.get_config().metrics_interval;
// Prevent panic in tokio::time::interval when misconfigured to 0
if interval.is_zero() {
if refresh_interval.is_zero() {
warn!("RUSTFS_CAPACITY_SCHEDULED_INTERVAL is configured as 0; clamping to 1s to avoid panic");
interval = Duration::from_secs(1);
refresh_interval = Duration::from_secs(1);
}
if metrics_interval.is_zero() {
warn!("RUSTFS_CAPACITY_METRICS_INTERVAL is configured as 0; clamping to 1s to avoid panic");
metrics_interval = Duration::from_secs(1);
}
tokio::spawn(async move {
let mut timer = tokio::time::interval(interval);
let mut timer = tokio::time::interval(refresh_interval);
loop {
timer.tick().await;
info!("Starting scheduled capacity update");
let start = Instant::now();
let manager = manager.clone();
let manager = manager_for_refresh.clone();
let disks = disks.clone();
let started = manager
.clone()
@@ -942,6 +1000,14 @@ pub async fn start_background_task(disks: Vec<CapacityDiskRef>) {
}
}
});
tokio::spawn(async move {
let mut timer = tokio::time::interval(metrics_interval);
loop {
timer.tick().await;
manager_for_metrics.log_runtime_summary().await;
}
});
}
// ============================================================================
@@ -953,8 +1019,9 @@ mod tests {
use super::*;
use rustfs_common::capacity_scope::{CapacityScope, CapacityScopeDisk, record_capacity_scope, record_global_dirty_scope};
use rustfs_config::{
ENV_CAPACITY_FAST_UPDATE_THRESHOLD, ENV_CAPACITY_MAX_FILES_THRESHOLD, ENV_CAPACITY_SAMPLE_RATE,
ENV_CAPACITY_STAT_TIMEOUT, ENV_CAPACITY_WRITE_FREQUENCY_THRESHOLD, ENV_CAPACITY_WRITE_TRIGGER_DELAY,
ENV_CAPACITY_FAST_UPDATE_THRESHOLD, ENV_CAPACITY_MAX_FILES_THRESHOLD, ENV_CAPACITY_METRICS_INTERVAL,
ENV_CAPACITY_SAMPLE_RATE, ENV_CAPACITY_STAT_TIMEOUT, ENV_CAPACITY_WRITE_FREQUENCY_THRESHOLD,
ENV_CAPACITY_WRITE_TRIGGER_DELAY,
};
use serial_test::serial;
use std::sync::Arc;
@@ -1009,6 +1076,13 @@ mod tests {
assert_eq!(rate, 200);
}
#[test]
#[serial]
fn test_get_metrics_interval() {
let interval = get_metrics_interval();
assert_eq!(interval, Duration::from_secs(600));
}
#[test]
#[serial]
fn test_env_var_override_scheduled_interval() {
@@ -1045,6 +1119,15 @@ mod tests {
});
}
#[test]
#[serial]
fn test_env_var_override_metrics_interval() {
temp_env::with_var(ENV_CAPACITY_METRICS_INTERVAL, Some("90"), || {
let interval = get_metrics_interval();
assert_eq!(interval, Duration::from_secs(90));
});
}
#[test]
#[serial]
fn test_env_var_override_max_files_threshold() {
@@ -1214,6 +1297,7 @@ mod tests {
write_trigger_delay: Duration::from_millis(50),
write_frequency_threshold: 1,
fast_update_threshold: Duration::from_millis(10),
metrics_interval: Duration::from_secs(600),
enable_smart_update: true,
enable_write_trigger: true,
});
@@ -1244,6 +1328,7 @@ mod tests {
write_trigger_delay: Duration::from_secs(60),
write_frequency_threshold: 1,
fast_update_threshold: Duration::from_millis(10),
metrics_interval: Duration::from_secs(600),
enable_smart_update: true,
enable_write_trigger: false,
});
+90 -6
View File
@@ -1169,9 +1169,6 @@ impl FolderScanner {
let bucket_clone = bucket.clone();
let prefix_clone = prefix.clone();
let child_ctx_clone = child_ctx.clone();
let agreed_tx = agreed_tx.clone();
let partial_tx = partial_tx.clone();
let finished_tx = finished_tx.clone();
tokio::spawn(async move {
if let Err(e) = list_path_raw(
@@ -1219,13 +1216,28 @@ impl FolderScanner {
});
let mut found_objects = false;
let mut agreed_closed = false;
let mut partial_closed = false;
let mut finished_closed = false;
loop {
if agreed_closed && partial_closed && finished_closed {
break;
}
select! {
Some(entry_name) = agreed_rx.recv() => {
entry_name = agreed_rx.recv(), if !agreed_closed => {
let Some(entry_name) = entry_name else {
agreed_closed = true;
continue;
};
(self.update_current_path)(&entry_name).await;
}
Some(entries) = partial_rx.recv() => {
entries = partial_rx.recv(), if !partial_closed => {
let Some(entries) = entries else {
partial_closed = true;
continue;
};
if !self.should_heal().await {
child_ctx.cancel();
break;
@@ -1297,7 +1309,11 @@ impl FolderScanner {
}
Some(errs) = finished_rx.recv() => {
errs = finished_rx.recv(), if !finished_closed => {
let Some(errs) = errs else {
finished_closed = true;
continue;
};
error!("scan_folder: list_path_raw: failed to get finished errs: {:?}", errs);
child_ctx.cancel();
}
@@ -1840,6 +1856,74 @@ mod tests {
assert!(result.is_ok(), "expected unreadable child directory to be skipped");
}
#[tokio::test]
#[serial]
async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
let _heal_responder = rustfs_common::heal_channel::init_heal_channel().ok().map(|mut heal_rx| {
tokio::spawn(async move {
while let Some(command) = heal_rx.recv().await {
if let rustfs_common::heal_channel::HealChannelCommand::Start { response_tx, .. } = command {
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
}
}
})
});
let bucket = "src-archive";
tokio::fs::create_dir_all(temp_dir.join(bucket))
.await
.expect("failed to create bucket directory");
let mut disks = vec![scanner.local_disk.clone()];
for disk_name in ["disk2", "disk3", "disk4"] {
let disk_root = temp_dir.join(disk_name);
tokio::fs::create_dir_all(disk_root.join(bucket))
.await
.expect("failed to create extra disk bucket directory");
let endpoint =
Endpoint::try_from(disk_root.to_string_lossy().as_ref()).expect("failed to create extra disk endpoint");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("failed to create extra disk");
disks.push(disk);
}
scanner.heal_object_select = 1;
scanner.disks = disks;
scanner.disks_quorum = 2;
scanner.old_cache.replace(
"src-archive/snapshots/37b3f20d941e2f5e6d99114d9bb2f3e67a8a2e5c9c4c5a1b0d6e7f8091a2b3c4",
bucket,
DataUsageEntry {
objects: 1,
..Default::default()
},
);
let mut into = DataUsageEntry::default();
let folder = CachedFolder {
name: bucket.to_string(),
parent: None,
object_heal_prob_div: 1,
};
tokio::time::timeout(
Duration::from_millis(200),
scanner.scan_folder(CancellationToken::new(), folder, &mut into),
)
.await
.expect("scan_folder should not hang after list_path_raw finishes")
.expect("scan_folder should finish successfully");
}
#[tokio::test]
#[serial]
#[cfg(unix)]
+3
View File
@@ -13,10 +13,13 @@ documentation = "https://docs.rs/rustfs-target/latest/rustfs_target/"
[dependencies]
rustfs-config = { workspace = true, features = ["notify", "constants", "audit"] }
rustfs-ecstore = { workspace = true }
rustfs-utils = { workspace = true, features = ["notify", "tls"] }
rustfs-s3-common = { workspace = true }
async-trait = { workspace = true }
async-nats = { workspace = true }
hyper-rustls = { workspace = true }
pulsar = { workspace = true }
reqwest = { workspace = true }
rumqttc = { workspace = true }
rustls = { workspace = true }
+36
View File
@@ -92,3 +92,39 @@ pub async fn check_mqtt_broker_available_with_tls(
Err(_) => Err(crate::TargetError::Timeout("MQTT connection timed out".to_string())),
}
}
pub async fn check_nats_server_available(args: &crate::target::nats::NATSArgs) -> Result<(), crate::TargetError> {
match tokio::time::timeout(std::time::Duration::from_secs(5), async {
let client = crate::target::nats::connect_nats(args).await?;
client
.flush()
.await
.map_err(|e| crate::TargetError::Network(format!("NATS connection check failed: {e}")))?;
client
.drain()
.await
.map_err(|e| crate::TargetError::Network(format!("Failed to close NATS check connection: {e}")))?;
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err(crate::TargetError::Timeout("NATS connection timed out".to_string())),
}
}
pub async fn check_pulsar_broker_available(args: &crate::target::pulsar::PulsarArgs) -> Result<(), crate::TargetError> {
match tokio::time::timeout(std::time::Duration::from_secs(5), async {
let client = crate::target::pulsar::connect_pulsar(args).await?;
client
.lookup_partitioned_topic(args.topic.clone())
.await
.map_err(|e| crate::TargetError::Network(format!("Pulsar topic lookup failed: {e}")))?;
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err(crate::TargetError::Timeout("Pulsar connection timed out".to_string())),
}
}
+231
View File
@@ -0,0 +1,231 @@
// 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::TargetError;
use crate::target::pulsar::validate_pulsar_broker;
use async_nats::ServerAddr;
use rustfs_config::{
DEFAULT_DELIMITER, ENABLE_KEY, EnableState, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_SUBJECT, NATS_TLS_CA,
NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_PASSWORD, PULSAR_QUEUE_DIR,
PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME,
};
use rustfs_ecstore::config::KVS;
use std::collections::HashSet;
use std::path::Path;
use std::str::FromStr;
use url::Url;
pub(super) fn split_env_field_and_instance(rest: &str, valid_fields: &HashSet<String>) -> Option<(String, String)> {
let normalized = rest.to_lowercase();
if valid_fields.contains(&normalized) {
return Some((normalized, DEFAULT_DELIMITER.to_string()));
}
valid_fields
.iter()
.filter_map(|field| {
normalized
.strip_prefix(field)
.and_then(|suffix| suffix.strip_prefix(DEFAULT_DELIMITER))
.filter(|instance_id| !instance_id.is_empty())
.map(|instance_id| (field.clone(), instance_id.to_string()))
})
.max_by_key(|(field, _)| field.len())
}
pub(super) fn is_target_enabled(config: &KVS) -> bool {
config
.lookup(ENABLE_KEY)
.map(|v| {
EnableState::from_str(v.as_str())
.ok()
.map(|s| s.is_enabled())
.unwrap_or(false)
})
.unwrap_or(false)
}
pub(super) fn parse_target_bool(value: Option<&str>) -> Option<bool> {
let value = value?.trim();
if value.is_empty() {
return None;
}
value
.parse::<EnableState>()
.map(EnableState::is_enabled)
.or_else(|_| value.parse::<bool>())
.ok()
}
pub(super) fn validate_nats_server_config(server: &ServerAddr, config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> {
if config.lookup(NATS_SUBJECT).unwrap_or_default().trim().is_empty() {
return Err(TargetError::Configuration("Missing NATS subject".to_string()));
}
if server.has_user_pass() {
return Err(TargetError::Configuration("NATS address must not embed username or password".to_string()));
}
let username = config.lookup(NATS_USERNAME).unwrap_or_default();
let password = config.lookup(NATS_PASSWORD).unwrap_or_default();
let token = config.lookup(NATS_TOKEN).unwrap_or_default();
let credentials_file = config.lookup(NATS_CREDENTIALS_FILE).unwrap_or_default();
let mut auth_methods = 0usize;
if !token.is_empty() {
auth_methods += 1;
}
if !credentials_file.is_empty() {
auth_methods += 1;
if !Path::new(&credentials_file).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_CREDENTIALS_FILE} must be an absolute path")));
}
}
if !username.is_empty() || !password.is_empty() {
if username.is_empty() != password.is_empty() {
return Err(TargetError::Configuration(
"NATS username and password must be specified together".to_string(),
));
}
auth_methods += 1;
}
if auth_methods > 1 {
return Err(TargetError::Configuration(
"NATS supports only one auth method at a time: token, username/password, or credentials_file".to_string(),
));
}
let tls_ca = config.lookup(NATS_TLS_CA).unwrap_or_default();
let tls_client_cert = config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default();
let tls_client_key = config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default();
if !tls_ca.is_empty() && !Path::new(&tls_ca).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_TLS_CA} must be an absolute path")));
}
if !tls_client_cert.is_empty() && !Path::new(&tls_client_cert).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_CERT} must be an absolute path")));
}
if !tls_client_key.is_empty() && !Path::new(&tls_client_key).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_TLS_CLIENT_KEY} must be an absolute path")));
}
if tls_client_cert.is_empty() != tls_client_key.is_empty() {
return Err(TargetError::Configuration(
"NATS tls_client_cert and tls_client_key must be specified together".to_string(),
));
}
let queue_dir = config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string()));
}
let _ = server;
Ok(())
}
pub(super) fn validate_pulsar_broker_config(broker: &str, config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> {
let url = validate_pulsar_broker(broker)?;
if config.lookup(PULSAR_TOPIC).unwrap_or_default().trim().is_empty() {
return Err(TargetError::Configuration("Missing Pulsar topic".to_string()));
}
let auth_token = config.lookup(PULSAR_AUTH_TOKEN).unwrap_or_default();
let username = config.lookup(PULSAR_USERNAME).unwrap_or_default();
let password = config.lookup(PULSAR_PASSWORD).unwrap_or_default();
if !auth_token.is_empty() && (!username.is_empty() || !password.is_empty()) {
return Err(TargetError::Configuration(
"Pulsar supports either auth_token or username/password auth, not both".to_string(),
));
}
if username.is_empty() != password.is_empty() {
return Err(TargetError::Configuration(
"Pulsar username and password must be specified together".to_string(),
));
}
let tls_ca = config.lookup(PULSAR_TLS_CA).unwrap_or_default();
let tls_allow_insecure = parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false);
let tls_hostname_verification = parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true);
if !tls_ca.is_empty() && !Path::new(&tls_ca).is_absolute() {
return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string()));
}
if url.scheme() != "pulsar+ssl" && (!tls_ca.is_empty() || tls_allow_insecure || !tls_hostname_verification) {
return Err(TargetError::Configuration(
"Pulsar TLS settings are only allowed with pulsar+ssl brokers".to_string(),
));
}
let queue_dir = config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
}
Ok(())
}
pub(super) fn parse_url(value: &str, field_label: &str) -> Result<Url, TargetError> {
Url::parse(value).map_err(|e| TargetError::Configuration(format!("Invalid {field_label}: {e} (value: '{value}')")))
}
#[cfg(test)]
mod tests {
use super::{validate_nats_server_config, validate_pulsar_broker_config};
use async_nats::ServerAddr;
use rustfs_config::{
NATS_PASSWORD, NATS_QUEUE_DIR, NATS_SUBJECT, NATS_TOKEN, NATS_USERNAME, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TOPIC,
};
use rustfs_ecstore::config::KVS;
use std::str::FromStr;
#[test]
fn validate_nats_server_config_rejects_multiple_auth_methods() {
let server = ServerAddr::from_str("nats://127.0.0.1:4222").expect("valid nats address");
let mut config = KVS::new();
config.insert(NATS_SUBJECT.to_string(), "events".to_string());
config.insert(NATS_TOKEN.to_string(), "token".to_string());
config.insert(NATS_USERNAME.to_string(), "user".to_string());
config.insert(NATS_PASSWORD.to_string(), "password".to_string());
let err = validate_nats_server_config(&server, &config, "").expect_err("conflicting auth should be rejected");
assert!(err.to_string().contains("only one auth method"));
}
#[test]
fn validate_nats_server_config_rejects_relative_queue_dir() {
let server = ServerAddr::from_str("nats://127.0.0.1:4222").expect("valid nats address");
let mut config = KVS::new();
config.insert(NATS_SUBJECT.to_string(), "events".to_string());
config.insert(NATS_QUEUE_DIR.to_string(), "relative-queue".to_string());
let err = validate_nats_server_config(&server, &config, "").expect_err("relative queue_dir should be rejected");
assert!(err.to_string().contains("absolute path"));
}
#[test]
fn validate_pulsar_broker_config_rejects_tls_flags_without_tls_scheme() {
let mut config = KVS::new();
config.insert(PULSAR_TOPIC.to_string(), "events".to_string());
config.insert(PULSAR_TLS_ALLOW_INSECURE.to_string(), "on".to_string());
let err = validate_pulsar_broker_config("pulsar://127.0.0.1:6650", &config, "")
.expect_err("TLS flags should require pulsar+ssl");
assert!(err.to_string().contains("only allowed with pulsar+ssl"));
}
}
+239
View File
@@ -0,0 +1,239 @@
// 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 super::common::{is_target_enabled, split_env_field_and_instance};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState};
use rustfs_ecstore::config::{Config, KVS};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use tracing::{debug, warn};
pub fn collect_target_configs(
config: &Config,
route_prefix: &str,
target_type: &str,
valid_fields: &HashSet<String>,
) -> Vec<(String, KVS)> {
collect_target_configs_from_env(config, route_prefix, target_type, valid_fields, std::env::vars())
}
pub fn collect_env_target_instance_ids(route_prefix: &str, target_type: &str, valid_fields: &HashSet<String>) -> HashSet<String> {
collect_env_target_instance_ids_from_env(route_prefix, target_type, valid_fields, std::env::vars())
}
pub fn collect_env_target_instance_ids_from_env<I>(
route_prefix: &str,
target_type: &str,
valid_fields: &HashSet<String>,
env_vars: I,
) -> HashSet<String>
where
I: IntoIterator<Item = (String, String)>,
{
let env_prefix = format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}").to_uppercase();
let mut instance_ids = HashSet::new();
for (key, _value) in env_vars.into_iter().filter(|(key, _)| key.starts_with(ENV_PREFIX)) {
let Some(rest) = key.strip_prefix(&env_prefix) else {
continue;
};
let Some((_field_name, instance_id)) = split_env_field_and_instance(rest, valid_fields) else {
continue;
};
if instance_id != DEFAULT_DELIMITER && !instance_id.is_empty() {
instance_ids.insert(instance_id);
}
}
instance_ids
}
pub fn collect_target_configs_from_env<I>(
config: &Config,
route_prefix: &str,
target_type: &str,
valid_fields: &HashSet<String>,
env_vars: I,
) -> Vec<(String, KVS)>
where
I: IntoIterator<Item = (String, String)>,
{
let all_env: Vec<(String, String)> = env_vars.into_iter().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect();
let section_name = format!("{route_prefix}{target_type}").to_lowercase();
let file_configs = config.0.get(&section_name).cloned().unwrap_or_default();
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
let enable_prefix =
format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}").to_uppercase();
let env_prefix = format!("{ENV_PREFIX}{route_prefix}{target_type}{DEFAULT_DELIMITER}").to_uppercase();
let mut instance_ids_from_env = HashSet::new();
let mut env_overrides: HashMap<String, KVS> = HashMap::new();
for (key, value) in &all_env {
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false)
&& let Some(id) = key.strip_prefix(&enable_prefix)
&& !id.is_empty()
{
instance_ids_from_env.insert(id.to_lowercase());
}
let Some(rest) = key.strip_prefix(&env_prefix) else {
continue;
};
let Some((field_name, instance_id)) = split_env_field_and_instance(rest, valid_fields) else {
warn!(
field_name = %rest.to_lowercase(),
"Ignore environment variable field not found in the valid field list for target type {}",
target_type
);
continue;
};
debug!(
instance_id = %if instance_id == DEFAULT_DELIMITER { DEFAULT_DELIMITER } else { &instance_id },
%field_name,
%value,
"Parsed target environment override"
);
env_overrides
.entry(instance_id)
.or_default()
.insert(field_name, value.clone());
}
let mut effective_default = default_cfg;
if let Some(default_env_cfg) = env_overrides.remove(DEFAULT_DELIMITER) {
effective_default.extend(default_env_cfg);
}
let mut all_instance_ids: Vec<String> = file_configs
.keys()
.filter(|key| key.as_str() != DEFAULT_DELIMITER)
.cloned()
.collect();
all_instance_ids.extend(instance_ids_from_env);
all_instance_ids.sort();
all_instance_ids.dedup();
let mut merged_configs = Vec::new();
for id in all_instance_ids {
let mut merged_config = effective_default.clone();
if let Some(file_instance_cfg) = file_configs.get(&id) {
merged_config.extend(file_instance_cfg.clone());
}
if let Some(env_instance_cfg) = env_overrides.get(&id) {
merged_config.extend(env_instance_cfg.clone());
}
debug!(instance_id = %id, ?merged_config, "Merged target configuration");
if is_target_enabled(&merged_config) {
merged_configs.push((id, merged_config));
}
}
merged_configs
}
#[cfg(test)]
mod tests {
use super::{collect_env_target_instance_ids_from_env, collect_target_configs_from_env};
use rustfs_config::notify::NOTIFY_ROUTE_PREFIX;
use rustfs_config::{ENABLE_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_LIMIT};
use rustfs_ecstore::config::{Config, KVS};
use std::collections::{HashMap, HashSet};
#[test]
fn collect_target_configs_applies_default_env_overrides_to_file_targets() {
let mut cfg = Config(HashMap::new());
let mut subsystem = HashMap::new();
let mut default_kvs = KVS::new();
default_kvs.insert(ENABLE_KEY.to_string(), "off".to_string());
subsystem.insert("_".to_string(), default_kvs);
let mut primary = KVS::new();
primary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/primary".to_string());
subsystem.insert("primary".to_string(), primary);
let mut secondary = KVS::new();
secondary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/secondary".to_string());
subsystem.insert("secondary".to_string(), secondary);
cfg.0.insert("notify_webhook".to_string(), subsystem);
let configs = collect_target_configs_from_env(
&cfg,
NOTIFY_ROUTE_PREFIX,
"webhook",
&HashSet::from([
ENABLE_KEY.to_string(),
WEBHOOK_ENDPOINT.to_string(),
WEBHOOK_QUEUE_LIMIT.to_string(),
]),
vec![
("RUSTFS_NOTIFY_WEBHOOK_ENABLE".to_string(), "on".to_string()),
("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT".to_string(), "42".to_string()),
],
);
let configs: HashMap<String, KVS> = configs.into_iter().collect();
assert_eq!(configs.len(), 2);
assert_eq!(configs["primary"].lookup(ENABLE_KEY).as_deref(), Some("on"));
assert_eq!(configs["secondary"].lookup(ENABLE_KEY).as_deref(), Some("on"));
assert_eq!(configs["primary"].lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42"));
assert_eq!(configs["secondary"].lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42"));
}
#[test]
fn collect_target_configs_discovers_enabled_instance_from_env() {
let cfg = Config(HashMap::new());
let configs = collect_target_configs_from_env(
&cfg,
NOTIFY_ROUTE_PREFIX,
"webhook",
&HashSet::from([ENABLE_KEY.to_string(), WEBHOOK_ENDPOINT.to_string()]),
vec![
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY".to_string(), "on".to_string()),
(
"RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY".to_string(),
"https://example.com/from-env".to_string(),
),
],
);
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].0, "primary");
assert_eq!(configs[0].1.lookup(WEBHOOK_ENDPOINT).as_deref(), Some("https://example.com/from-env"));
}
#[test]
fn collect_env_target_instance_ids_handles_keys_with_internal_underscores() {
let ids = collect_env_target_instance_ids_from_env(
NOTIFY_ROUTE_PREFIX,
"webhook",
&HashSet::from([
ENABLE_KEY.to_string(),
WEBHOOK_ENDPOINT.to_string(),
WEBHOOK_QUEUE_LIMIT.to_string(),
]),
vec![
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY".to_string(), "on".to_string()),
("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT_PRIMARY".to_string(), "42".to_string()),
],
);
assert_eq!(ids, HashSet::from(["primary".to_string()]));
}
}
+26
View File
@@ -0,0 +1,26 @@
// 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.
mod common;
mod loader;
mod target_args;
pub use loader::{
collect_env_target_instance_ids, collect_env_target_instance_ids_from_env, collect_target_configs,
collect_target_configs_from_env,
};
pub use target_args::{
build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config,
validate_pulsar_config, validate_webhook_config,
};
+267
View File
@@ -0,0 +1,267 @@
// 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 super::common::{parse_target_bool, parse_url, validate_nats_server_config, validate_pulsar_broker_config};
use crate::error::TargetError;
use crate::target::{
TargetType,
mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url},
nats::{NATSArgs, validate_nats_address},
pulsar::{PulsarArgs, validate_pulsar_broker},
webhook::WebhookArgs,
};
use rumqttc::QoS;
use rustfs_config::{
DEFAULT_LIMIT, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT,
MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA,
MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR,
NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN,
NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT,
PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME,
RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY,
WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
};
use rustfs_ecstore::config::KVS;
use std::path::Path;
use std::time::Duration;
pub fn build_webhook_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result<WebhookArgs, TargetError> {
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
let parsed_endpoint = endpoint.trim();
let endpoint_url = parse_url(parsed_endpoint, "endpoint URL")?;
Ok(WebhookArgs {
enable: true,
endpoint: endpoint_url,
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
queue_dir: config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(WEBHOOK_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(),
skip_tls_verify: config
.lookup(WEBHOOK_SKIP_TLS_VERIFY)
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT),
target_type,
})
}
pub fn validate_webhook_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> {
let endpoint = config
.lookup(WEBHOOK_ENDPOINT)
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
let parsed_endpoint = endpoint.trim();
let _ = parse_url(parsed_endpoint, "endpoint URL")?;
let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default();
let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default();
if client_cert.is_empty() != client_key.is_empty() {
return Err(TargetError::Configuration(
"Both client_cert and client_key must be specified together".to_string(),
));
}
let queue_dir = config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
}
Ok(())
}
pub fn build_mqtt_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result<MQTTArgs, TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let broker_url = parse_url(&broker, "broker URL")?;
let topic = config
.lookup(MQTT_TOPIC)
.ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?;
Ok(MQTTArgs {
enable: true,
broker: broker_url,
topic,
qos: config
.lookup(MQTT_QOS)
.and_then(|v| v.parse::<u8>().ok())
.map(|q| match q {
0 => QoS::AtMostOnce,
1 => QoS::AtLeastOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtLeastOnce,
})
.unwrap_or(QoS::AtLeastOnce),
username: config.lookup(MQTT_USERNAME).unwrap_or_default(),
password: config.lookup(MQTT_PASSWORD).unwrap_or_default(),
max_reconnect_interval: config
.lookup(MQTT_RECONNECT_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(5)),
keep_alive: config
.lookup(MQTT_KEEP_ALIVE_INTERVAL)
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(30)),
tls: MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?,
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(MQTT_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
target_type,
})
}
pub fn validate_mqtt_config(config: &KVS) -> Result<(), TargetError> {
let broker = config
.lookup(MQTT_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
let url = parse_url(&broker, "broker URL")?;
let tls = MQTTTlsConfig::from_values(
config.lookup(MQTT_TLS_POLICY).as_deref(),
config.lookup(MQTT_TLS_CA).as_deref(),
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?;
validate_mqtt_broker_url(&url, &tls)?;
if config.lookup(MQTT_TOPIC).is_none() {
return Err(TargetError::Configuration("Missing MQTT topic".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS) {
let qos = qos_str
.parse::<u8>()
.map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?;
if qos > 2 {
return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string()));
}
}
let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default();
if !queue_dir.is_empty() {
if !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string()));
}
if let Some(qos_str) = config.lookup(MQTT_QOS)
&& qos_str == "0"
{
return Err(TargetError::Configuration(
"QoS should be AtLeastOnce (1) or ExactlyOnce (2) if queue_dir is set".to_string(),
));
}
}
Ok(())
}
pub fn build_nats_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result<NATSArgs, TargetError> {
let address = config
.lookup(NATS_ADDRESS)
.ok_or_else(|| TargetError::Configuration("Missing NATS address".to_string()))?;
validate_nats_address(&address)?;
let subject = config
.lookup(NATS_SUBJECT)
.ok_or_else(|| TargetError::Configuration("Missing NATS subject".to_string()))?;
Ok(NATSArgs {
enable: true,
address,
subject,
username: config.lookup(NATS_USERNAME).unwrap_or_default(),
password: config.lookup(NATS_PASSWORD).unwrap_or_default(),
token: config.lookup(NATS_TOKEN).unwrap_or_default(),
credentials_file: config.lookup(NATS_CREDENTIALS_FILE).unwrap_or_default(),
tls_ca: config.lookup(NATS_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(),
tls_required: parse_target_bool(config.lookup(NATS_TLS_REQUIRED).as_deref()).unwrap_or(false),
queue_dir: config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(NATS_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
target_type,
})
}
pub fn validate_nats_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> {
let address = config
.lookup(NATS_ADDRESS)
.ok_or_else(|| TargetError::Configuration("Missing NATS address".to_string()))?;
let server = validate_nats_address(&address)?;
validate_nats_server_config(&server, config, default_queue_dir)
}
pub fn build_pulsar_args(config: &KVS, default_queue_dir: &str, target_type: TargetType) -> Result<PulsarArgs, TargetError> {
let broker = config
.lookup(PULSAR_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing Pulsar broker".to_string()))?;
validate_pulsar_broker(&broker)?;
let topic = config
.lookup(PULSAR_TOPIC)
.ok_or_else(|| TargetError::Configuration("Missing Pulsar topic".to_string()))?;
Ok(PulsarArgs {
enable: true,
broker,
topic,
auth_token: config.lookup(PULSAR_AUTH_TOKEN).unwrap_or_default(),
username: config.lookup(PULSAR_USERNAME).unwrap_or_default(),
password: config.lookup(PULSAR_PASSWORD).unwrap_or_default(),
tls_ca: config.lookup(PULSAR_TLS_CA).unwrap_or_default(),
tls_allow_insecure: parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false),
tls_hostname_verification: parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true),
queue_dir: config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(PULSAR_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_LIMIT),
target_type,
})
}
pub fn validate_pulsar_config(config: &KVS, default_queue_dir: &str) -> Result<(), TargetError> {
let broker = config
.lookup(PULSAR_BROKER)
.ok_or_else(|| TargetError::Configuration("Missing Pulsar broker".to_string()))?;
validate_pulsar_broker_config(&broker, config, default_queue_dir)
}
+4 -1
View File
@@ -14,12 +14,15 @@
pub mod arn;
mod check;
pub mod config;
pub mod error;
pub mod store;
pub mod sys;
pub mod target;
pub use check::{check_mqtt_broker_available, check_mqtt_broker_available_with_tls};
pub use check::{
check_mqtt_broker_available, check_mqtt_broker_available_with_tls, check_nats_server_available, check_pulsar_broker_available,
};
pub use error::{StoreError, TargetError};
pub use rustfs_s3_common::EventName;
use serde::{Deserialize, Serialize};
+8
View File
@@ -26,6 +26,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
pub mod mqtt;
pub mod nats;
pub mod pulsar;
pub mod webhook;
/// A read-only snapshot of delivery counters for a target.
@@ -286,6 +288,8 @@ pub enum ChannelTargetType {
Webhook,
Kafka,
Mqtt,
Nats,
Pulsar,
}
impl ChannelTargetType {
@@ -294,6 +298,8 @@ impl ChannelTargetType {
ChannelTargetType::Webhook => "webhook",
ChannelTargetType::Kafka => "kafka",
ChannelTargetType::Mqtt => "mqtt",
ChannelTargetType::Nats => "nats",
ChannelTargetType::Pulsar => "pulsar",
}
}
}
@@ -304,6 +310,8 @@ impl std::fmt::Display for ChannelTargetType {
ChannelTargetType::Webhook => write!(f, "webhook"),
ChannelTargetType::Kafka => write!(f, "kafka"),
ChannelTargetType::Mqtt => write!(f, "mqtt"),
ChannelTargetType::Nats => write!(f, "nats"),
ChannelTargetType::Pulsar => write!(f, "pulsar"),
}
}
}
+2 -2
View File
@@ -467,12 +467,12 @@ impl MQTTArgs {
if !self.queue_dir.is_empty() {
let path = std::path::Path::new(&self.queue_dir);
if !path.is_absolute() {
return Err(TargetError::Configuration("mqtt queueDir path should be absolute".to_string()));
return Err(TargetError::Configuration("mqtt queue_dir path should be absolute".to_string()));
}
if self.qos == QoS::AtMostOnce {
return Err(TargetError::Configuration(
"QoS should be AtLeastOnce (1) or ExactlyOnce (2) if queueDir is set".to_string(),
"QoS should be AtLeastOnce (1) or ExactlyOnce (2) if queue_dir is set".to_string(),
));
}
}
+367
View File
@@ -0,0 +1,367 @@
// 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, TargetLog,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType,
},
};
use async_trait::async_trait;
use rustfs_config::{NATS_CREDENTIALS_FILE, NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{error, info, instrument};
#[derive(Debug, Clone)]
pub struct NATSArgs {
pub enable: bool,
pub address: String,
pub subject: String,
pub username: String,
pub password: String,
pub token: String,
pub credentials_file: String,
pub tls_ca: String,
pub tls_client_cert: String,
pub tls_client_key: String,
pub tls_required: bool,
pub queue_dir: String,
pub queue_limit: u64,
pub target_type: TargetType,
}
impl NATSArgs {
pub fn validate(&self) -> Result<(), TargetError> {
if !self.enable {
return Ok(());
}
validate_nats_address(&self.address)?;
validate_nats_auth(self)?;
if self.subject.trim().is_empty() || self.subject.chars().any(char::is_whitespace) {
return Err(TargetError::Configuration(
"NATS subject cannot be empty or contain whitespace".to_string(),
));
}
if !self.credentials_file.is_empty() && !Path::new(&self.credentials_file).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_CREDENTIALS_FILE} must be an absolute path")));
}
if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
return Err(TargetError::Configuration(format!("{NATS_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!("{NATS_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!("{NATS_TLS_CLIENT_KEY} must be an absolute path")));
}
if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
return Err(TargetError::Configuration(
"NATS tls_client_cert and tls_client_key must be specified together".to_string(),
));
}
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string()));
}
Ok(())
}
}
pub fn validate_nats_address(address: &str) -> Result<async_nats::ServerAddr, TargetError> {
let server = async_nats::ServerAddr::from_str(address)
.map_err(|e| TargetError::Configuration(format!("Invalid NATS address: {e}")))?;
if server.has_user_pass() {
return Err(TargetError::Configuration("NATS address must not embed username or password".to_string()));
}
Ok(server)
}
fn validate_nats_auth(args: &NATSArgs) -> Result<(), TargetError> {
let mut auth_methods = 0usize;
if !args.token.is_empty() {
auth_methods += 1;
}
if !args.credentials_file.is_empty() {
auth_methods += 1;
}
let has_user = !args.username.is_empty();
let has_password = !args.password.is_empty();
if has_user || has_password {
if has_user != has_password {
return Err(TargetError::Configuration(
"NATS username and password must be specified together".to_string(),
));
}
auth_methods += 1;
}
if auth_methods > 1 {
return Err(TargetError::Configuration(
"NATS supports only one auth method at a time: token, username/password, or credentials_file".to_string(),
));
}
Ok(())
}
pub async fn connect_nats(args: &NATSArgs) -> Result<async_nats::Client, TargetError> {
args.validate()?;
let mut options = async_nats::ConnectOptions::new().require_tls(args.tls_required);
if !args.token.is_empty() {
options = options.token(args.token.clone());
} else if !args.username.is_empty() {
options = options.user_and_password(args.username.clone(), args.password.clone());
} else if !args.credentials_file.is_empty() {
options = options
.credentials_file(&args.credentials_file)
.await
.map_err(|e| TargetError::Configuration(format!("Failed to load NATS credentials file: {e}")))?;
}
if !args.tls_ca.is_empty() {
options = options.add_root_certificates(PathBuf::from(&args.tls_ca));
}
if !args.tls_client_cert.is_empty() {
options = options.add_client_certificate(PathBuf::from(&args.tls_client_cert), PathBuf::from(&args.tls_client_key));
}
options
.connect(args.address.clone())
.await
.map_err(|e| TargetError::Network(format!("Failed to connect to NATS server: {e}")))
}
pub struct NATSTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
id: TargetID,
args: NATSArgs,
client: Mutex<Option<async_nats::Client>>,
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
connected: AtomicBool,
delivery_counters: Arc<TargetDeliveryCounters>,
_phantom: std::marker::PhantomData<E>,
}
impl<E> NATSTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(NATSTarget::<E> {
id: self.id.clone(),
args: self.args.clone(),
client: Mutex::new(self.client.lock().unwrap().clone()),
store: self.store.as_ref().map(|s| s.boxed_clone()),
connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)),
delivery_counters: Arc::clone(&self.delivery_counters),
_phantom: std::marker::PhantomData,
})
}
#[instrument(skip(args), fields(target_id_as_string = %id))]
pub fn new(id: String, args: NATSArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Nats.as_str().to_string());
let queue_store = if !args.queue_dir.is_empty() {
let base_path = PathBuf::from(&args.queue_dir);
let specific_queue_path = base_path.join(format!("rustfs-{}-{}", ChannelTargetType::Nats.as_str(), target_id.id));
let extension = match args.target_type {
TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(specific_queue_path, args.queue_limit, extension);
if let Err(e) = store.open() {
error!(target_id = %target_id, error = %e, "Failed to open store for NATS target");
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
Ok(Self {
id: target_id,
args,
client: Mutex::new(None),
store: queue_store,
connected: AtomicBool::new(false),
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
_phantom: std::marker::PhantomData,
})
}
async fn get_or_connect(&self) -> Result<async_nats::Client, TargetError> {
if let Some(client) = self.client.lock().unwrap().clone() {
return Ok(client);
}
let client = connect_nats(&self.args).await?;
client
.flush()
.await
.map_err(|e| TargetError::Network(format!("Failed to flush NATS connection: {e}")))?;
self.connected.store(true, Ordering::SeqCst);
let mut guard = self.client.lock().unwrap();
let shared = guard.get_or_insert_with(|| client.clone()).clone();
Ok(shared)
}
fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
let object_name = crate::target::decode_object_name(&event.object_name)?;
let key = format!("{}/{}", event.bucket_name, object_name);
let log = TargetLog {
event_name: event.event_name,
key,
records: vec![event.clone()],
};
let body = serde_json::to_vec(&log).map_err(|e| TargetError::Serialization(format!("Failed to serialize event: {e}")))?;
let meta = QueuedPayloadMeta::new(
event.event_name,
event.bucket_name.clone(),
event.object_name.clone(),
"application/json",
body.len(),
);
Ok(QueuedPayload::new(meta, body))
}
async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
let client = self.get_or_connect().await?;
client
.publish(self.args.subject.clone(), body.into())
.await
.map_err(|e| TargetError::Request(format!("Failed to publish NATS message: {e}")))?;
self.delivery_counters.record_success();
Ok(())
}
}
#[async_trait]
impl<E> Target<E> for NATSTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
let client = self.get_or_connect().await?;
client
.flush()
.await
.map_err(|e| TargetError::Network(format!("NATS health check failed: {e}")))?;
Ok(true)
}
async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
let queued = match self.build_queued_payload(&event) {
Ok(queued) => queued,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(err);
}
};
if let Some(store) = &self.store {
let encoded = match queued.encode() {
Ok(encoded) => encoded,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to encode queued payload: {err}")));
}
};
if let Err(e) = store.put_raw(&encoded) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
}
Ok(())
} else {
if let Err(err) = self.send_body(queued.body).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> {
self.send_body(body).await
}
async fn close(&self) -> Result<(), TargetError> {
let client = self.client.lock().unwrap().take();
self.connected.store(false, Ordering::SeqCst);
if let Some(client) = client {
client
.drain()
.await
.map_err(|e| TargetError::Network(format!("Failed to drain NATS client: {e}")))?;
}
info!(target_id = %self.id, "NATS 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(());
}
let _ = self.get_or_connect().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();
}
}
+387
View File
@@ -0,0 +1,387 @@
// 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, TargetLog,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType,
},
};
use async_trait::async_trait;
use pulsar::{Authentication, Producer, Pulsar, TokioExecutor};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
use tracing::{error, info, instrument};
use url::Url;
#[derive(Debug, Clone)]
pub struct PulsarArgs {
pub enable: bool,
pub broker: String,
pub topic: String,
pub auth_token: String,
pub username: String,
pub password: String,
pub tls_ca: String,
pub tls_allow_insecure: bool,
pub tls_hostname_verification: bool,
pub queue_dir: String,
pub queue_limit: u64,
pub target_type: TargetType,
}
impl PulsarArgs {
pub fn validate(&self) -> Result<(), TargetError> {
if !self.enable {
return Ok(());
}
validate_pulsar_broker(&self.broker)?;
if self.topic.trim().is_empty() {
return Err(TargetError::Configuration("Pulsar topic cannot be empty".to_string()));
}
if !self.auth_token.is_empty() && (!self.username.is_empty() || !self.password.is_empty()) {
return Err(TargetError::Configuration(
"Pulsar supports either auth_token or username/password auth, not both".to_string(),
));
}
if self.username.is_empty() != self.password.is_empty() {
return Err(TargetError::Configuration(
"Pulsar username and password must be specified together".to_string(),
));
}
if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string()));
}
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
}
let parsed = Url::parse(&self.broker)
.map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{}')", self.broker)))?;
let tls_enabled = parsed.scheme() == "pulsar+ssl";
if !tls_enabled && (!self.tls_ca.is_empty() || self.tls_allow_insecure || !self.tls_hostname_verification) {
return Err(TargetError::Configuration(
"Pulsar TLS settings are only allowed with pulsar+ssl brokers".to_string(),
));
}
Ok(())
}
}
pub fn validate_pulsar_broker(broker: &str) -> Result<Url, TargetError> {
let url = Url::parse(broker)
.map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{broker}')")))?;
match url.scheme() {
"pulsar" | "pulsar+ssl" => {}
_ => {
return Err(TargetError::Configuration(
"Pulsar broker must use pulsar:// or pulsar+ssl://".to_string(),
));
}
}
if !url.username().is_empty() || url.password().is_some() {
return Err(TargetError::Configuration(
"Pulsar broker URL must not embed username or password".to_string(),
));
}
if url.host_str().is_none() {
return Err(TargetError::Configuration("Pulsar broker is missing host".to_string()));
}
Ok(url)
}
pub async fn connect_pulsar(args: &PulsarArgs) -> Result<Pulsar<TokioExecutor>, TargetError> {
args.validate()?;
let mut builder = Pulsar::builder(args.broker.clone(), TokioExecutor);
if !args.auth_token.is_empty() {
builder = builder.with_auth(Authentication {
name: "token".to_string(),
data: args.auth_token.clone().into_bytes(),
});
} else if !args.username.is_empty() {
builder =
builder.with_auth_provider(pulsar::authentication::basic::BasicAuthentication::new(&args.username, &args.password));
}
if !args.tls_ca.is_empty() {
builder = builder
.with_certificate_chain_file(&args.tls_ca)
.map_err(|e| TargetError::Configuration(format!("Failed to load Pulsar tls_ca: {e}")))?;
}
builder = builder
.with_allow_insecure_connection(args.tls_allow_insecure)
.with_tls_hostname_verification_enabled(args.tls_hostname_verification);
builder
.build()
.await
.map_err(|e| TargetError::Network(format!("Failed to connect to Pulsar broker: {e}")))
}
pub struct PulsarTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
id: TargetID,
args: PulsarArgs,
client: Mutex<Option<Pulsar<TokioExecutor>>>,
producer: AsyncMutex<Option<Producer<TokioExecutor>>>,
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
connected: AtomicBool,
delivery_counters: Arc<TargetDeliveryCounters>,
_phantom: std::marker::PhantomData<E>,
}
impl<E> PulsarTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(PulsarTarget::<E> {
id: self.id.clone(),
args: self.args.clone(),
client: Mutex::new(self.client.lock().unwrap().clone()),
producer: AsyncMutex::new(None),
store: self.store.as_ref().map(|s| s.boxed_clone()),
connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)),
delivery_counters: Arc::clone(&self.delivery_counters),
_phantom: std::marker::PhantomData,
})
}
#[instrument(skip(args), fields(target_id_as_string = %id))]
pub fn new(id: String, args: PulsarArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Pulsar.as_str().to_string());
let queue_store = if !args.queue_dir.is_empty() {
let base_path = PathBuf::from(&args.queue_dir);
let specific_queue_path = base_path.join(format!("rustfs-{}-{}", ChannelTargetType::Pulsar.as_str(), target_id.id));
let extension = match args.target_type {
TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(specific_queue_path, args.queue_limit, extension);
if let Err(e) = store.open() {
error!(target_id = %target_id, error = %e, "Failed to open store for Pulsar target");
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
Ok(Self {
id: target_id,
args,
client: Mutex::new(None),
producer: AsyncMutex::new(None),
store: queue_store,
connected: AtomicBool::new(false),
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
_phantom: std::marker::PhantomData,
})
}
async fn get_or_connect_client(&self) -> Result<Pulsar<TokioExecutor>, TargetError> {
if let Some(client) = self.client.lock().unwrap().clone() {
return Ok(client);
}
let client = connect_pulsar(&self.args).await?;
self.connected.store(true, Ordering::SeqCst);
let mut guard = self.client.lock().unwrap();
let shared = guard.get_or_insert_with(|| client.clone()).clone();
Ok(shared)
}
async fn init_producer(&self) -> Result<(), TargetError> {
if self.producer.lock().await.is_some() {
return Ok(());
}
let client = self.get_or_connect_client().await?;
let producer = client
.producer()
.with_topic(self.args.topic.clone())
.with_name(self.id.id.clone())
.build()
.await
.map_err(|e| TargetError::Network(format!("Failed to create Pulsar producer: {e}")))?;
let mut guard = self.producer.lock().await;
if guard.is_none() {
*guard = Some(producer);
}
Ok(())
}
fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
let object_name = crate::target::decode_object_name(&event.object_name)?;
let key = format!("{}/{}", event.bucket_name, object_name);
let log = TargetLog {
event_name: event.event_name,
key,
records: vec![event.clone()],
};
let body = serde_json::to_vec(&log).map_err(|e| TargetError::Serialization(format!("Failed to serialize event: {e}")))?;
let meta = QueuedPayloadMeta::new(
event.event_name,
event.bucket_name.clone(),
event.object_name.clone(),
"application/json",
body.len(),
);
Ok(QueuedPayload::new(meta, body))
}
async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
self.init_producer().await?;
let mut guard = self.producer.lock().await;
let producer = guard
.as_mut()
.ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
let receipt = producer
.send_non_blocking(body)
.await
.map_err(|e| TargetError::Request(format!("Failed to send Pulsar message: {e}")))?;
receipt
.await
.map_err(|e| TargetError::Request(format!("Failed to receive Pulsar receipt: {e}")))?;
self.delivery_counters.record_success();
Ok(())
}
}
#[async_trait]
impl<E> Target<E> for PulsarTarget<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
self.init_producer().await?;
let guard = self.producer.lock().await;
let producer = guard
.as_ref()
.ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
producer
.check_connection()
.await
.map_err(|e| TargetError::Network(format!("Pulsar health check failed: {e}")))?;
Ok(true)
}
async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
let queued = match self.build_queued_payload(&event) {
Ok(queued) => queued,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(err);
}
};
if let Some(store) = &self.store {
let encoded = match queued.encode() {
Ok(encoded) => encoded,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to encode queued payload: {err}")));
}
};
if let Err(e) = store.put_raw(&encoded) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
}
Ok(())
} else {
if let Err(err) = self.send_body(queued.body).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> {
self.send_body(body).await
}
async fn close(&self) -> Result<(), TargetError> {
let mut producer = self.producer.lock().await;
if let Some(producer) = producer.as_mut() {
producer
.close()
.await
.map_err(|e| TargetError::Network(format!("Failed to close Pulsar producer: {e}")))?;
}
*producer = None;
self.client.lock().unwrap().take();
self.connected.store(false, Ordering::SeqCst);
info!(target_id = %self.id, "Pulsar 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_producer().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))
}
fn record_final_failure(&self) {
self.delivery_counters.record_final_failure();
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ impl WebhookArgs {
if !self.queue_dir.is_empty() {
let path = std::path::Path::new(&self.queue_dir);
if !path.is_absolute() {
return Err(TargetError::Configuration("webhook queueDir path should be absolute".to_string()));
return Err(TargetError::Configuration("webhook queue_dir path should be absolute".to_string()));
}
}
+5
View File
@@ -105,6 +105,11 @@ RustFS helm chart supports **standalone and distributed mode**. For standalone m
| mtls.enabled | bool | `false` | Enable mtls betweens pods. |
| mtls.clientCertPath | string | `/opt/tls/client_cert.pem` | The path for client cert. |
| mtls.clientKeyPath | string | `/opt/tls/client_key.pem` | The path for client key. |
| mtls.existingIssuerRef.enabled | bool | `false` | Enable to use external/existing certificate issuer.|
| mtls.existingIssuerRef.name | string | `""` | The name of external/existing certificate issuer. |
| mtls.existingIssuerRef.kind | string | `""` | The kind of external/existing certificate iss
uer. `ClusterIssuer` or `Issuer`. |
| mtls.existingIssuerRef.group | string | `""` | The group of external/existing certificate issuer. |
| nameOverride | string | `""` | |
| nodeSelector | object | `{}` | |
| pdb.create | bool | `false` | Enable/disable a Pod Disruption Budget creation |
@@ -1,4 +1,4 @@
{{- if .Values.mtls.enabled }}
{{- if and .Values.mtls.enabled (not .Values.mtls.existingIssuerRef.enabled) }}
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
@@ -6,3 +6,4 @@ metadata:
spec:
selfSigned: {}
{{- end }}
@@ -1,4 +1,4 @@
{{- if .Values.mtls.enabled }}
{{- if and .Values.mtls.enabled (not .Values.mtls.existingIssuerRef.enabled) }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
@@ -11,4 +11,5 @@ spec:
issuerRef:
name: {{ include "rustfs.fullname" . }}-selfsigned-issuer
kind: ClusterIssuer
{{- end }}
{{- end }}
@@ -1,4 +1,4 @@
{{- if .Values.mtls.enabled }}
{{- if and .Values.mtls.enabled (not .Values.mtls.existingIssuerRef.enabled) }}
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
@@ -7,4 +7,5 @@ metadata:
spec:
ca:
secretName: {{ include "rustfs.fullname" . }}-root-ca-secret
{{- end }}
{{- end }}
@@ -6,9 +6,16 @@ metadata:
namespace: {{ .Release.Namespace }}
spec:
secretName: {{ include "rustfs.fullname" . }}-server-tls
{{- if .Values.mtls.existingIssuerRef.enabled }}
issuerRef:
name: {{ .Values.mtls.existingIssuerRef.name }}
kind: {{ .Values.mtls.existingIssuerRef.kind }}
group: {{ .Values.mtls.existingIssuerRef.group }}
{{- else }}
issuerRef:
name: {{ include "rustfs.fullname" . }}-ca-issuer
kind: Issuer
{{- end }}
commonName: {{ include "rustfs.fullname" . }}-cluster
dnsNames:
- "*.{{ include "rustfs.fullname" . }}-headless"
@@ -20,4 +27,4 @@ spec:
{{- end }}
usages:
- server auth
{{- end }}
{{- end }}
@@ -6,10 +6,18 @@ metadata:
namespace: {{ .Release.Namespace }}
spec:
secretName: {{ include "rustfs.fullname" . }}-client-tls
{{- if .Values.mtls.existingIssuerRef.enabled }}
issuerRef:
name: {{ .Values.mtls.existingIssuerRef.name }}
kind: {{ .Values.mtls.existingIssuerRef.kind }}
group: {{ .Values.mtls.existingIssuerRef.group }}
{{- else }}
issuerRef:
name: {{ include "rustfs.fullname" . }}-ca-issuer
kind: Issuer
{{- end }}
commonName: {{ include "rustfs.fullname" . }}-cluster
usages:
- client auth
{{- end }}
+5
View File
@@ -230,6 +230,11 @@ mtls:
enabled: false
clientCertPath: "/opt/tls/client_cert.pem"
clientKeyPath: "/opt/tls/client_key.pem"
existingIssuerRef:
enabled: false
name: ""
kind: ""
group: ""
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
+55 -198
View File
@@ -14,6 +14,13 @@
use crate::admin::{
auth::validate_admin_request,
handlers::target_descriptor::{
AdminTargetSpec, AdminTargetValidator, EndpointKey, TargetDomain, allowed_target_keys,
collect_config_entry_keys as shared_collect_config_entry_keys,
collect_configured_endpoint_keys as shared_collect_configured_endpoint_keys,
collect_env_endpoint_keys as shared_collect_env_endpoint_keys, normalized_endpoint_key, target_service_name, target_spec,
validate_target_request,
},
router::{AdminOperation, Operation, S3Router},
};
use crate::auth::{check_key_valid, get_session_token};
@@ -24,22 +31,20 @@ use http::{HeaderMap, StatusCode};
use hyper::Method;
use matchit::Params;
use rustfs_audit::{audit_system, start_audit_system as start_global_audit_system, system::AuditSystemState};
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_ROUTE_PREFIX, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
use rustfs_config::audit::{
AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS,
AUDIT_ROUTE_PREFIX, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS,
};
use rustfs_config::{AUDIT_DEFAULT_DIR, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
use rustfs_ecstore::config::Config;
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_targets::{TargetError, check_mqtt_broker_available_with_tls, target::mqtt::MQTTTlsConfig};
use s3s::{Body, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{Duration, sleep, timeout};
use tokio::time::{Duration, timeout};
use tracing::{Span, warn};
use url::Url;
pub fn register_audit_target_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
@@ -87,8 +92,6 @@ struct AuditEndpointsResponse {
audit_endpoints: Vec<AuditEndpoint>,
}
type EndpointKey = (String, String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
enum AuditEndpointSource {
@@ -98,8 +101,33 @@ enum AuditEndpointSource {
Runtime,
}
fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey {
(account_id.to_lowercase(), service.to_string())
fn audit_target_specs() -> [AdminTargetSpec; 4] {
[
AdminTargetSpec {
subsystem: AUDIT_WEBHOOK_SUB_SYS,
service: "webhook",
valid_keys: AUDIT_WEBHOOK_KEYS,
validator: AdminTargetValidator::Webhook,
},
AdminTargetSpec {
subsystem: AUDIT_MQTT_SUB_SYS,
service: "mqtt",
valid_keys: AUDIT_MQTT_KEYS,
validator: AdminTargetValidator::Mqtt,
},
AdminTargetSpec {
subsystem: AUDIT_NATS_SUB_SYS,
service: "nats",
valid_keys: AUDIT_NATS_KEYS,
validator: AdminTargetValidator::Nats(TargetDomain::Audit),
},
AdminTargetSpec {
subsystem: AUDIT_PULSAR_SUB_SYS,
service: "pulsar",
valid_keys: AUDIT_PULSAR_KEYS,
validator: AdminTargetValidator::Pulsar(TargetDomain::Audit),
},
]
}
async fn authorize_audit_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
@@ -121,58 +149,9 @@ fn build_response(status: StatusCode, body: Body, request_id: Option<&http::Head
S3Response::with_headers((status, body), header)
}
async fn retry_with_backoff<F, Fut, T>(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, Error>>,
{
let mut attempts = 0;
let mut delay = base_delay;
let mut last_err = None;
while attempts < max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
last_err = Some(e);
attempts += 1;
if attempts < max_attempts {
sleep(delay).await;
delay = delay.saturating_mul(2);
}
}
}
}
Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error")))
}
async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
if !queue_dir.is_empty() {
if !Path::new(queue_dir).is_absolute() {
return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path"));
}
retry_with_backoff(
|| async { tokio::fs::metadata(queue_dir).await.map(|_| ()) },
3,
Duration::from_millis(100),
)
.await
.map_err(|e| match e.kind() {
ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"),
ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"),
_ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e),
})?;
}
Ok(())
}
fn config_enable_is_on(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
}
fn has_any_audit_targets(config: &Config) -> bool {
for subsystem in [AUDIT_WEBHOOK_SUB_SYS, AUDIT_MQTT_SUB_SYS] {
let Some(targets) = config.0.get(subsystem) else {
for spec in audit_target_specs() {
let Some(targets) = config.0.get(spec.subsystem) else {
continue;
};
if targets.keys().any(|key| key != DEFAULT_DELIMITER) {
@@ -183,73 +162,15 @@ fn has_any_audit_targets(config: &Config) -> bool {
}
fn collect_configured_audit_endpoint_keys(config: &Config) -> Vec<EndpointKey> {
let mut endpoints = Vec::new();
for (subsystem, service) in [(AUDIT_WEBHOOK_SUB_SYS, "webhook"), (AUDIT_MQTT_SUB_SYS, "mqtt")] {
let Some(targets) = config.0.get(subsystem) else {
continue;
};
for (target_name, kvs) in targets {
if target_name == DEFAULT_DELIMITER {
continue;
}
let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false);
if enabled {
endpoints.push((target_name.clone(), service.to_string()));
}
}
}
endpoints
shared_collect_configured_endpoint_keys(&audit_target_specs(), config)
}
fn collect_config_entry_keys(config: &Config) -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for (subsystem, service) in [(AUDIT_WEBHOOK_SUB_SYS, "webhook"), (AUDIT_MQTT_SUB_SYS, "mqtt")] {
let Some(targets) = config.0.get(subsystem) else {
continue;
};
for target_name in targets.keys() {
if target_name == DEFAULT_DELIMITER {
continue;
}
endpoints.insert(normalized_endpoint_key(target_name, service));
}
}
endpoints
shared_collect_config_entry_keys(&audit_target_specs(), config)
}
fn collect_env_endpoint_keys() -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for (service, valid_keys) in [("webhook", AUDIT_WEBHOOK_KEYS), ("mqtt", AUDIT_MQTT_KEYS)] {
let env_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{service}{DEFAULT_DELIMITER}").to_uppercase();
for (key, _value) in std::env::vars() {
let Some(rest) = key.strip_prefix(&env_prefix) else {
continue;
};
let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER);
let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER);
let field_name_part = parts.next();
let (field_name, instance_id) = match field_name_part {
Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()),
None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()),
};
if instance_id == DEFAULT_DELIMITER || instance_id.is_empty() {
continue;
}
if valid_keys.contains(&field_name.as_str()) {
endpoints.insert(normalized_endpoint_key(&instance_id, service));
}
}
}
endpoints
shared_collect_env_endpoint_keys(&audit_target_specs(), AUDIT_ROUTE_PREFIX)
}
fn classify_audit_endpoint_source(
@@ -268,11 +189,7 @@ fn classify_audit_endpoint_source(
fn audit_endpoint_source(config: &Config, target_type: &str, target_name: &str) -> AuditEndpointSource {
let config_targets = collect_config_entry_keys(config);
let env_targets = collect_env_endpoint_keys();
let service = match target_type {
AUDIT_WEBHOOK_SUB_SYS => "webhook",
AUDIT_MQTT_SUB_SYS => "mqtt",
_ => "",
};
let service = target_service_name(&audit_target_specs(), target_type).unwrap_or_default();
let key = normalized_endpoint_key(target_name, service);
classify_audit_endpoint_source(&config_targets, &env_targets, &key)
@@ -384,7 +301,7 @@ fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, &
let target_type = params
.get("target_type")
.ok_or_else(|| s3_error!(InvalidArgument, "missing required parameter: 'target_type'"))?;
if target_type != AUDIT_WEBHOOK_SUB_SYS && target_type != AUDIT_MQTT_SUB_SYS {
if target_service_name(&audit_target_specs(), target_type).is_none() {
return Err(s3_error!(InvalidArgument, "unsupported audit target type: '{}'", target_type));
}
let target_name = params
@@ -486,77 +403,16 @@ impl Operation for AuditTargetConfig {
let audit_body: AuditTargetBody = serde_json::from_slice(&body_bytes)
.map_err(|e| s3_error!(InvalidArgument, "invalid json body for audit target config: {}", e))?;
let allowed_keys: HashSet<&str> = match target_type {
AUDIT_WEBHOOK_SUB_SYS => AUDIT_WEBHOOK_KEYS.iter().cloned().collect(),
AUDIT_MQTT_SUB_SYS => AUDIT_MQTT_KEYS.iter().cloned().collect(),
_ => unreachable!(),
};
let specs = audit_target_specs();
let allowed_keys: HashSet<&str> = allowed_target_keys(&specs, target_type);
let kv_map = collect_validated_key_values(&audit_body.key_values, &allowed_keys, target_type)?;
if target_type == AUDIT_WEBHOOK_SUB_SYS {
let endpoint = kv_map
.get("endpoint")
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?;
let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?;
match parsed_endpoint.scheme() {
"http" | "https" => {}
other => {
return Err(s3_error!(
InvalidArgument,
"unsupported endpoint scheme: {} (only http and https are allowed)",
other
));
}
}
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
}
if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") {
return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair"));
}
} else if target_type == AUDIT_MQTT_SUB_SYS {
let endpoint = kv_map
.get(rustfs_config::MQTT_BROKER)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?;
let topic = kv_map
.get(rustfs_config::MQTT_TOPIC)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?;
let username = kv_map.get(rustfs_config::MQTT_USERNAME).map(String::as_str);
let password = kv_map.get(rustfs_config::MQTT_PASSWORD).map(String::as_str);
let tls = MQTTTlsConfig::from_values(
kv_map.get(rustfs_config::MQTT_TLS_POLICY).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CA).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CLIENT_CERT).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CLIENT_KEY).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str),
kv_map.get(rustfs_config::MQTT_WS_PATH_ALLOWLIST).map(String::as_str),
)
.map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?;
let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?;
rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls)
.map_err(|e| s3_error!(InvalidArgument, "{}", e))?;
check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls)
.await
.map_err(|e| match e {
TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e),
_ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e),
})?;
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
if let Some(qos) = kv_map.get("qos") {
match qos.parse::<u8>() {
Ok(1) | Ok(2) => {}
Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")),
_ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")),
}
}
}
}
let spec = target_spec(&specs, target_type)
.ok_or_else(|| s3_error!(InvalidArgument, "unsupported audit target type: '{}'", target_type))?;
timeout(Duration::from_secs(10), validate_target_request(spec, &kv_map, AUDIT_DEFAULT_DIR))
.await
.map_err(|_| s3_error!(InvalidArgument, "audit target validation timed out"))??;
let mut kvs = rustfs_ecstore::config::KVS::new();
for (key, value) in kv_map {
@@ -656,6 +512,7 @@ impl Operation for RemoveAuditTarget {
mod tests {
use super::*;
use matchit::Router;
use rustfs_config::ENV_PREFIX;
use rustfs_ecstore::config::{KV, KVS};
use std::collections::{HashMap, HashSet};
use temp_env::{with_var, with_vars, with_vars_unset};
+51 -198
View File
@@ -14,6 +14,13 @@
use crate::admin::{
auth::validate_admin_request,
handlers::target_descriptor::{
AdminTargetSpec, AdminTargetValidator, EndpointKey, TargetDomain, allowed_target_keys,
collect_config_entry_keys as shared_collect_config_entry_keys,
collect_configured_endpoint_keys as shared_collect_configured_endpoint_keys,
collect_env_endpoint_keys as shared_collect_env_endpoint_keys, normalized_endpoint_key, target_service_name, target_spec,
validate_target_request,
},
router::{AdminOperation, Operation, S3Router},
};
use crate::auth::{check_key_valid, get_session_token};
@@ -24,23 +31,19 @@ use http::{HeaderMap, StatusCode};
use hyper::Method;
use matchit::Params;
use rustfs_config::notify::{
NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_ROUTE_PREFIX, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS,
NOTIFY_ROUTE_PREFIX, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
use rustfs_config::{ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
use rustfs_ecstore::config::Config;
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_targets::{TargetError, check_mqtt_broker_available_with_tls, target::mqtt::MQTTTlsConfig};
use s3s::{Body, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{Duration, sleep, timeout};
use tokio::time::{Duration, timeout};
use tracing::{Span, info, warn};
use url::Url;
pub fn register_notification_target_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
@@ -94,8 +97,6 @@ struct NotificationEndpointsResponse {
notification_endpoints: Vec<NotificationEndpoint>,
}
type EndpointKey = (String, String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
enum NotificationEndpointSource {
@@ -105,8 +106,33 @@ enum NotificationEndpointSource {
Runtime,
}
fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey {
(account_id.to_lowercase(), service.to_string())
fn notification_target_specs() -> [AdminTargetSpec; 4] {
[
AdminTargetSpec {
subsystem: NOTIFY_WEBHOOK_SUB_SYS,
service: "webhook",
valid_keys: NOTIFY_WEBHOOK_KEYS,
validator: AdminTargetValidator::Webhook,
},
AdminTargetSpec {
subsystem: NOTIFY_MQTT_SUB_SYS,
service: "mqtt",
valid_keys: NOTIFY_MQTT_KEYS,
validator: AdminTargetValidator::Mqtt,
},
AdminTargetSpec {
subsystem: NOTIFY_NATS_SUB_SYS,
service: "nats",
valid_keys: NOTIFY_NATS_KEYS,
validator: AdminTargetValidator::Nats(TargetDomain::Notify),
},
AdminTargetSpec {
subsystem: NOTIFY_PULSAR_SUB_SYS,
service: "pulsar",
valid_keys: NOTIFY_PULSAR_KEYS,
validator: AdminTargetValidator::Pulsar(TargetDomain::Notify),
},
]
}
// --- Helper Functions ---
@@ -134,123 +160,16 @@ fn build_response(status: StatusCode, body: Body, request_id: Option<&http::Head
S3Response::with_headers((status, body), header)
}
async fn retry_with_backoff<F, Fut, T>(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, Error>>,
{
let mut attempts = 0;
let mut delay = base_delay;
let mut last_err = None;
while attempts < max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
last_err = Some(e);
attempts += 1;
if attempts < max_attempts {
sleep(delay).await;
delay = delay.saturating_mul(2);
}
}
}
}
Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error")))
}
async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
if !queue_dir.is_empty() {
if !Path::new(queue_dir).is_absolute() {
return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path"));
}
retry_with_backoff(
|| async { tokio::fs::metadata(queue_dir).await.map(|_| ()) },
3,
Duration::from_millis(100),
)
.await
.map_err(|e| match e.kind() {
ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"),
ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"),
_ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e),
})?;
}
Ok(())
}
fn config_enable_is_on(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
}
fn collect_configured_endpoint_keys(config: &Config) -> Vec<EndpointKey> {
let mut endpoints = Vec::new();
for (subsystem, service) in [(NOTIFY_WEBHOOK_SUB_SYS, "webhook"), (NOTIFY_MQTT_SUB_SYS, "mqtt")] {
let Some(targets) = config.0.get(subsystem) else {
continue;
};
for (target_name, kvs) in targets {
if target_name == DEFAULT_DELIMITER {
continue;
}
let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false);
if enabled {
endpoints.push((target_name.clone(), service.to_string()));
}
}
}
endpoints
shared_collect_configured_endpoint_keys(&notification_target_specs(), config)
}
fn collect_config_entry_keys(config: &Config) -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for (subsystem, service) in [(NOTIFY_WEBHOOK_SUB_SYS, "webhook"), (NOTIFY_MQTT_SUB_SYS, "mqtt")] {
let Some(targets) = config.0.get(subsystem) else {
continue;
};
for target_name in targets.keys() {
if target_name == DEFAULT_DELIMITER {
continue;
}
endpoints.insert(normalized_endpoint_key(target_name, service));
}
}
endpoints
shared_collect_config_entry_keys(&notification_target_specs(), config)
}
fn collect_env_endpoint_keys() -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for (service, valid_keys) in [("webhook", NOTIFY_WEBHOOK_KEYS), ("mqtt", NOTIFY_MQTT_KEYS)] {
let env_prefix = format!("{ENV_PREFIX}{NOTIFY_ROUTE_PREFIX}{service}{DEFAULT_DELIMITER}").to_uppercase();
for (key, _value) in std::env::vars() {
let Some(rest) = key.strip_prefix(&env_prefix) else {
continue;
};
let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER);
let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER);
let field_name_part = parts.next();
let (field_name, instance_id) = match field_name_part {
Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()),
None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()),
};
if instance_id == DEFAULT_DELIMITER || instance_id.is_empty() {
continue;
}
if valid_keys.contains(&field_name.as_str()) {
endpoints.insert(normalized_endpoint_key(&instance_id, service));
}
}
}
endpoints
shared_collect_env_endpoint_keys(&notification_target_specs(), NOTIFY_ROUTE_PREFIX)
}
fn classify_notification_endpoint_source(
@@ -269,11 +188,7 @@ fn classify_notification_endpoint_source(
fn notification_endpoint_source(config: &Config, target_type: &str, target_name: &str) -> NotificationEndpointSource {
let config_targets = collect_config_entry_keys(config);
let env_targets = collect_env_endpoint_keys();
let service = match target_type {
NOTIFY_WEBHOOK_SUB_SYS => "webhook",
NOTIFY_MQTT_SUB_SYS => "mqtt",
_ => "",
};
let service = target_service_name(&notification_target_specs(), target_type).unwrap_or_default();
let key = normalized_endpoint_key(target_name, service);
classify_notification_endpoint_source(&config_targets, &env_targets, &key)
@@ -414,78 +329,15 @@ impl Operation for NotificationTarget {
let notification_body: NotificationTargetBody = serde_json::from_slice(&body_bytes)
.map_err(|e| s3_error!(InvalidArgument, "invalid json body for target config: {}", e))?;
let allowed_keys: HashSet<&str> = match target_type {
NOTIFY_WEBHOOK_SUB_SYS => rustfs_config::notify::NOTIFY_WEBHOOK_KEYS.iter().cloned().collect(),
NOTIFY_MQTT_SUB_SYS => rustfs_config::notify::NOTIFY_MQTT_KEYS.iter().cloned().collect(),
_ => unreachable!(),
};
let specs = notification_target_specs();
let allowed_keys: HashSet<&str> = allowed_target_keys(&specs, target_type);
let kv_map = collect_validated_key_values(&notification_body.key_values, &allowed_keys, target_type)?;
// Type-specific validation
if target_type == NOTIFY_WEBHOOK_SUB_SYS {
let endpoint = kv_map
.get("endpoint")
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?;
let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?;
match parsed_endpoint.scheme() {
"http" | "https" => {}
other => {
return Err(s3_error!(
InvalidArgument,
"unsupported endpoint scheme: {} (only http and https are allowed)",
other
));
}
}
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
}
if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") {
return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair"));
}
} else if target_type == NOTIFY_MQTT_SUB_SYS {
let endpoint = kv_map
.get(rustfs_config::MQTT_BROKER)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?;
let topic = kv_map
.get(rustfs_config::MQTT_TOPIC)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?;
let username = kv_map.get(rustfs_config::MQTT_USERNAME).map(String::as_str);
let password = kv_map.get(rustfs_config::MQTT_PASSWORD).map(String::as_str);
let tls = MQTTTlsConfig::from_values(
kv_map.get(rustfs_config::MQTT_TLS_POLICY).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CA).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CLIENT_CERT).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_CLIENT_KEY).map(String::as_str),
kv_map.get(rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str),
kv_map.get(rustfs_config::MQTT_WS_PATH_ALLOWLIST).map(String::as_str),
)
.map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?;
let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?;
rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls)
.map_err(|e| s3_error!(InvalidArgument, "{}", e))?;
check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls)
.await
.map_err(|e| match e {
TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e),
_ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e),
})?;
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
if let Some(qos) = kv_map.get("qos") {
match qos.parse::<u8>() {
Ok(1) | Ok(2) => {}
Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")),
_ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")),
}
}
}
}
let spec = target_spec(&specs, target_type)
.ok_or_else(|| s3_error!(InvalidArgument, "unsupported target type: '{}'", target_type))?;
timeout(Duration::from_secs(10), validate_target_request(spec, &kv_map, EVENT_DEFAULT_DIR))
.await
.map_err(|_| s3_error!(InvalidArgument, "target validation timed out"))??;
let mut kvs = rustfs_ecstore::config::KVS::new();
for (key, value) in kv_map {
@@ -616,7 +468,7 @@ fn extract_param<'a>(params: &'a Params<'_, '_>, key: &str) -> S3Result<&'a str>
fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, &'a str)> {
let target_type = extract_param(params, "target_type")?;
if target_type != NOTIFY_WEBHOOK_SUB_SYS && target_type != NOTIFY_MQTT_SUB_SYS {
if target_service_name(&notification_target_specs(), target_type).is_none() {
return Err(s3_error!(InvalidArgument, "unsupported target type: '{}'", target_type));
}
let target_name = extract_param(params, "target_name")?;
@@ -626,6 +478,7 @@ fn extract_target_params<'a>(params: &'a Params<'_, '_>) -> S3Result<(&'a str, &
#[cfg(test)]
mod tests {
use super::*;
use rustfs_config::DEFAULT_DELIMITER;
use rustfs_ecstore::config::{KV, KVS};
use rustfs_targets::arn::TargetID;
use std::collections::{HashMap, HashSet};
+1
View File
@@ -37,6 +37,7 @@ pub mod service_account;
pub mod site_replication;
pub mod sts;
pub mod system;
mod target_descriptor;
pub mod tier;
pub mod trace;
pub mod user;
+11 -1
View File
@@ -23,7 +23,7 @@ use matchit::Params;
use rustfs_config::oidc::{
IDENTITY_OPENID_SUB_SYS, OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL,
OIDC_DEFAULT_CLAIM_NAME, OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_ROLES_CLAIM, OIDC_DEFAULT_SCOPES,
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_REDIRECT_URI,
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_OTHER_AUDIENCES, OIDC_REDIRECT_URI,
OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE};
@@ -126,6 +126,7 @@ struct OidcConfigView {
client_id: String,
client_secret_configured: bool,
scopes: Vec<String>,
other_audiences: Vec<String>,
redirect_uri: Option<String>,
redirect_uri_dynamic: bool,
claim_name: String,
@@ -162,6 +163,7 @@ struct OidcConfigUpsertRequest {
client_id: String,
client_secret: Option<String>,
scopes: Vec<String>,
other_audiences: Vec<String>,
redirect_uri: Option<String>,
redirect_uri_dynamic: bool,
claim_name: String,
@@ -182,6 +184,7 @@ impl Default for OidcConfigUpsertRequest {
client_id: String::new(),
client_secret: None,
scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(),
other_audiences: Vec::new(),
redirect_uri: None,
redirect_uri_dynamic: true,
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
@@ -205,6 +208,7 @@ struct OidcConfigValidateRequest {
client_id: String,
client_secret: Option<String>,
scopes: Vec<String>,
other_audiences: Vec<String>,
redirect_uri: Option<String>,
redirect_uri_dynamic: bool,
claim_name: String,
@@ -226,6 +230,7 @@ impl Default for OidcConfigValidateRequest {
client_id: String::new(),
client_secret: None,
scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(),
other_audiences: Vec::new(),
redirect_uri: None,
redirect_uri_dynamic: true,
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
@@ -280,6 +285,7 @@ impl Operation for GetOidcConfigHandler {
client_id: provider.config.client_id.clone(),
client_secret_configured: provider.config.client_secret.is_some(),
scopes: provider.config.scopes.clone(),
other_audiences: provider.config.other_audiences.clone(),
redirect_uri: provider.config.redirect_uri.clone(),
redirect_uri_dynamic: provider.config.redirect_uri_dynamic,
claim_name: provider.config.claim_name.clone(),
@@ -785,6 +791,7 @@ fn build_provider_config_from_upsert(
client_id: request.client_id.trim().to_string(),
client_secret,
scopes,
other_audiences: request.other_audiences,
redirect_uri: normalize_optional(request.redirect_uri),
redirect_uri_dynamic: request.redirect_uri_dynamic,
claim_name: if request.claim_name.trim().is_empty() {
@@ -836,6 +843,7 @@ fn build_provider_config_from_validate(
client_id: request.client_id.trim().to_string(),
client_secret: request.client_secret.filter(|value| !value.trim().is_empty()),
scopes: normalize_scopes(&request.scopes),
other_audiences: request.other_audiences,
redirect_uri: normalize_optional(request.redirect_uri),
redirect_uri_dynamic: request.redirect_uri_dynamic,
claim_name: if request.claim_name.trim().is_empty() {
@@ -902,6 +910,7 @@ fn upsert_persisted_provider_config(config: &mut ServerConfig, provider_config:
set_kvs_value(&mut kvs, OIDC_CLIENT_ID, provider_config.client_id.clone());
set_kvs_value(&mut kvs, OIDC_CLIENT_SECRET, provider_config.client_secret.clone().unwrap_or_default());
set_kvs_value(&mut kvs, OIDC_SCOPES, provider_config.scopes.join(","));
set_kvs_value(&mut kvs, OIDC_OTHER_AUDIENCES, provider_config.other_audiences.join(","));
set_kvs_value(&mut kvs, OIDC_REDIRECT_URI, provider_config.redirect_uri.clone().unwrap_or_default());
set_kvs_value(
&mut kvs,
@@ -1170,6 +1179,7 @@ mod tests {
client_id: "console".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["openid".to_string(), "profile".to_string()],
other_audiences: vec![],
redirect_uri: None,
redirect_uri_dynamic: true,
claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(),
@@ -0,0 +1,299 @@
// 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 hashbrown::HashSet as HbHashSet;
use rustfs_config::{
ENABLE_KEY, MQTT_BROKER, MQTT_PASSWORD, MQTT_QOS, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY,
MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST,
};
use rustfs_ecstore::config::Config;
use rustfs_targets::{
TargetError, check_mqtt_broker_available_with_tls, check_nats_server_available, check_pulsar_broker_available,
config::{build_nats_args, build_pulsar_args, collect_env_target_instance_ids},
target::{TargetType, mqtt::MQTTTlsConfig},
};
use s3s::{S3Result, s3_error};
use std::collections::{HashMap, HashSet};
use std::io::{Error, ErrorKind};
use std::path::Path;
use tokio::time::{Duration, sleep};
use url::Url;
pub(crate) type EndpointKey = (String, String);
#[derive(Clone, Copy)]
pub(crate) enum TargetDomain {
Notify,
Audit,
}
impl TargetDomain {
fn runtime_target_type(self) -> TargetType {
match self {
TargetDomain::Notify => TargetType::NotifyEvent,
TargetDomain::Audit => TargetType::AuditLog,
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum AdminTargetValidator {
Webhook,
Mqtt,
Nats(TargetDomain),
Pulsar(TargetDomain),
}
#[derive(Clone, Copy)]
pub(crate) struct AdminTargetSpec {
pub subsystem: &'static str,
pub service: &'static str,
pub valid_keys: &'static [&'static str],
pub validator: AdminTargetValidator,
}
pub(crate) fn normalized_endpoint_key(account_id: &str, service: &str) -> EndpointKey {
(account_id.to_lowercase(), service.to_string())
}
pub(crate) fn target_spec<'a>(specs: &'a [AdminTargetSpec], target_type: &str) -> Option<&'a AdminTargetSpec> {
specs.iter().find(|spec| spec.subsystem == target_type)
}
pub(crate) fn target_service_name(specs: &[AdminTargetSpec], target_type: &str) -> Option<&'static str> {
target_spec(specs, target_type).map(|spec| spec.service)
}
pub(crate) fn collect_configured_endpoint_keys(specs: &[AdminTargetSpec], config: &Config) -> Vec<EndpointKey> {
let mut endpoints = Vec::new();
for spec in specs {
let Some(targets) = config.0.get(spec.subsystem) else {
continue;
};
for (target_name, kvs) in targets {
if target_name == rustfs_config::DEFAULT_DELIMITER {
continue;
}
let enabled = kvs.lookup(ENABLE_KEY).as_deref().map(config_enable_is_on).unwrap_or(false);
if enabled {
endpoints.push((target_name.clone(), spec.service.to_string()));
}
}
}
endpoints
}
pub(crate) fn collect_config_entry_keys(specs: &[AdminTargetSpec], config: &Config) -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for spec in specs {
let Some(targets) = config.0.get(spec.subsystem) else {
continue;
};
for target_name in targets.keys() {
if target_name == rustfs_config::DEFAULT_DELIMITER {
continue;
}
endpoints.insert(normalized_endpoint_key(target_name, spec.service));
}
}
endpoints
}
pub(crate) fn collect_env_endpoint_keys(specs: &[AdminTargetSpec], route_prefix: &str) -> HbHashSet<EndpointKey> {
let mut endpoints = HbHashSet::new();
for spec in specs {
let valid_keys = spec.valid_keys.iter().map(|key| (*key).to_string()).collect::<HashSet<_>>();
for instance_id in collect_env_target_instance_ids(route_prefix, spec.service, &valid_keys) {
if instance_id != rustfs_config::DEFAULT_DELIMITER && !instance_id.is_empty() {
endpoints.insert(normalized_endpoint_key(&instance_id, spec.service));
}
}
}
endpoints
}
pub(crate) fn allowed_target_keys(specs: &[AdminTargetSpec], target_type: &str) -> HashSet<&'static str> {
target_spec(specs, target_type)
.map(|spec| spec.valid_keys.iter().copied().collect())
.unwrap_or_default()
}
pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
if !queue_dir.is_empty() {
if !Path::new(queue_dir).is_absolute() {
return Err(s3_error!(InvalidArgument, "queue_dir must be absolute path"));
}
retry_with_backoff(
|| async { tokio::fs::metadata(queue_dir).await.map(|_| ()) },
3,
Duration::from_millis(100),
)
.await
.map_err(|e| match e.kind() {
ErrorKind::NotFound => s3_error!(InvalidArgument, "queue_dir does not exist"),
ErrorKind::PermissionDenied => s3_error!(InvalidArgument, "queue_dir exists but permission denied"),
_ => s3_error!(InvalidArgument, "failed to access queue_dir: {}", e),
})?;
}
Ok(())
}
pub(crate) async fn validate_target_request(
spec: &AdminTargetSpec,
kv_map: &HashMap<String, String>,
default_queue_dir: &str,
) -> S3Result<()> {
match spec.validator {
AdminTargetValidator::Webhook => validate_webhook_request(kv_map).await,
AdminTargetValidator::Mqtt => validate_mqtt_request(kv_map).await,
AdminTargetValidator::Nats(domain) => validate_nats_request(kv_map, default_queue_dir, domain).await,
AdminTargetValidator::Pulsar(domain) => validate_pulsar_request(kv_map, default_queue_dir, domain).await,
}
}
fn config_enable_is_on(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
}
async fn retry_with_backoff<F, Fut, T>(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, Error>>,
{
let mut attempts = 0;
let mut delay = base_delay;
let mut last_err = None;
while attempts < max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
last_err = Some(e);
attempts += 1;
if attempts < max_attempts {
sleep(delay).await;
delay = delay.saturating_mul(2);
}
}
}
}
Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error")))
}
async fn validate_webhook_request(kv_map: &HashMap<String, String>) -> S3Result<()> {
let endpoint = kv_map
.get("endpoint")
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "endpoint is required"))?;
let parsed_endpoint = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid endpoint url: {}", e))?;
match parsed_endpoint.scheme() {
"http" | "https" => {}
other => {
return Err(s3_error!(
InvalidArgument,
"unsupported endpoint scheme: {} (only http and https are allowed)",
other
));
}
}
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
}
if kv_map.contains_key("client_cert") != kv_map.contains_key("client_key") {
return Err(s3_error!(InvalidArgument, "client_cert and client_key must be specified as a pair"));
}
Ok(())
}
async fn validate_mqtt_request(kv_map: &HashMap<String, String>) -> S3Result<()> {
let endpoint = kv_map
.get(MQTT_BROKER)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "broker endpoint is required"))?;
let topic = kv_map
.get(MQTT_TOPIC)
.map(String::as_str)
.ok_or_else(|| s3_error!(InvalidArgument, "topic is required"))?;
let username = kv_map.get(MQTT_USERNAME).map(String::as_str);
let password = kv_map.get(MQTT_PASSWORD).map(String::as_str);
let tls = MQTTTlsConfig::from_values(
kv_map.get(MQTT_TLS_POLICY).map(String::as_str),
kv_map.get(MQTT_TLS_CA).map(String::as_str),
kv_map.get(MQTT_TLS_CLIENT_CERT).map(String::as_str),
kv_map.get(MQTT_TLS_CLIENT_KEY).map(String::as_str),
kv_map.get(MQTT_TLS_TRUST_LEAF_AS_CA).map(String::as_str),
kv_map.get(MQTT_WS_PATH_ALLOWLIST).map(String::as_str),
)
.map_err(|e| s3_error!(InvalidArgument, "invalid MQTT TLS settings: {}", e))?;
let parsed_broker = Url::parse(endpoint).map_err(|e| s3_error!(InvalidArgument, "invalid broker URL: {}", e))?;
rustfs_targets::target::mqtt::validate_mqtt_broker_url(&parsed_broker, &tls)
.map_err(|e| s3_error!(InvalidArgument, "{}", e))?;
check_mqtt_broker_available_with_tls(parsed_broker.as_str(), topic, username, password, &tls)
.await
.map_err(|e| match e {
TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e),
_ => s3_error!(InvalidArgument, "MQTT broker check failed: {}", e),
})?;
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
if let Some(qos) = kv_map.get(MQTT_QOS) {
match qos.parse::<u8>() {
Ok(1) | Ok(2) => {}
Ok(0) => return Err(s3_error!(InvalidArgument, "qos should be 1 or 2 if queue_dir is set")),
_ => return Err(s3_error!(InvalidArgument, "qos must be an integer 0, 1, or 2")),
}
}
}
Ok(())
}
async fn validate_nats_request(kv_map: &HashMap<String, String>, default_queue_dir: &str, domain: TargetDomain) -> S3Result<()> {
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
}
let args = build_nats_args(&to_kvs(kv_map), default_queue_dir, domain.runtime_target_type())
.map_err(|e| s3_error!(InvalidArgument, "{}", e))?;
check_nats_server_available(&args).await.map_err(|e| match e {
TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e),
_ => s3_error!(InvalidArgument, "NATS server check failed: {}", e),
})
}
async fn validate_pulsar_request(
kv_map: &HashMap<String, String>,
default_queue_dir: &str,
domain: TargetDomain,
) -> S3Result<()> {
if let Some(queue_dir) = kv_map.get("queue_dir") {
validate_queue_dir(queue_dir.as_str()).await?;
}
let args = build_pulsar_args(&to_kvs(kv_map), default_queue_dir, domain.runtime_target_type())
.map_err(|e| s3_error!(InvalidArgument, "{}", e))?;
check_pulsar_broker_available(&args).await.map_err(|e| match e {
TargetError::Configuration(_) => s3_error!(InvalidArgument, "{}", e),
_ => s3_error!(InvalidArgument, "Pulsar broker check failed: {}", e),
})
}
fn to_kvs(kv_map: &HashMap<String, String>) -> rustfs_ecstore::config::KVS {
let mut kvs = rustfs_ecstore::config::KVS::new();
for (key, value) in kv_map {
kvs.insert(key.clone(), value.clone());
}
kvs
}
+272 -13
View File
@@ -515,7 +515,8 @@ async fn enrich_delete_replication_state_if_needed(
let Some(replication_state) = delete_object.replication_state.as_ref() else {
return;
};
if !replication_state.replicate_decision_str.is_empty()
if obj_info.replication_status != ReplicationStatusType::Replica
&& !replication_state.replicate_decision_str.is_empty()
&& (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty())
{
return;
@@ -550,12 +551,61 @@ fn should_schedule_delete_replication(
return false;
}
if opts.version_id.is_some() && !deleted_delete_marker_version && !replication_source.delete_marker {
return matches!(
replication_source.replication_status,
ReplicationStatusType::Replica
| ReplicationStatusType::Pending
| ReplicationStatusType::Completed
| ReplicationStatusType::Failed
);
}
replication_source.replication_status == ReplicationStatusType::Replica
|| replication_source.replication_status == ReplicationStatusType::Pending
|| replication_source.version_purge_status == VersionPurgeStatusType::Pending
|| (deleted_delete_marker_version && replication_source.replication_status == ReplicationStatusType::Completed)
}
async fn should_schedule_replica_delete_replication(
bucket: &str,
replication_source: &ObjectInfo,
version_id: Option<Uuid>,
) -> bool {
let Ok((config, _)) = metadata_sys::get_replication_config(bucket).await else {
return false;
};
delete_replication_state_from_config(&config, replication_source, version_id, true).is_some()
}
fn delete_replication_version_id(replication_source: &ObjectInfo, deleted_delete_marker_version: bool) -> Option<Uuid> {
if replication_source.delete_marker && !deleted_delete_marker_version {
None
} else {
replication_source.version_id
}
}
fn should_use_existing_delete_replication_info(opts: &ObjectOptions) -> bool {
opts.version_id.is_some() && !opts.delete_marker
}
fn delete_replication_state_source<'a>(
opts: &ObjectOptions,
existing_object_info: Option<&'a ObjectInfo>,
deleted_object_info: &'a ObjectInfo,
) -> &'a ObjectInfo {
if opts.replication_request
&& deleted_object_info.delete_marker
&& let Some(existing) = existing_object_info
{
return existing;
}
deleted_object_info
}
const AMZ_SNOWBALL_EXTRACT_COMPAT: &str = "X-Amz-Snowball-Auto-Extract";
#[cfg(test)]
const AMZ_SNOWBALL_PREFIX_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Prefix";
@@ -3242,34 +3292,45 @@ impl DefaultObjectUsecase {
return result;
}
let deleted_replication_info = existing_object_info.as_ref().filter(|_| opts.version_id.is_some());
let replication_source = deleted_replication_info.unwrap_or(&obj_info);
let deleted_replication_info = existing_object_info
.as_ref()
.filter(|_| should_use_existing_delete_replication_info(&opts));
let deleted_object_source = deleted_replication_info.unwrap_or(&obj_info);
let replication_state_source =
delete_replication_state_source(&opts, existing_object_info.as_ref(), deleted_object_source);
let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker);
if should_schedule_delete_replication(&opts, replication_source, deleted_delete_marker_version) {
let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version);
let schedule_delete_replication = if opts.replication_request && replica {
should_schedule_replica_delete_replication(&bucket, replication_state_source, delete_replication_version_id).await
} else {
should_schedule_delete_replication(&opts, deleted_object_source, deleted_delete_marker_version)
};
if schedule_delete_replication {
let mut deleted_object = DeletedObjectReplicationInfo {
delete_object: rustfs_ecstore::store_api::DeletedObject {
delete_marker: replication_source.delete_marker && !deleted_delete_marker_version,
delete_marker_version_id: if replication_source.delete_marker {
replication_source.version_id
delete_marker: deleted_object_source.delete_marker && !deleted_delete_marker_version,
delete_marker_version_id: if deleted_object_source.delete_marker {
deleted_object_source.version_id
} else {
None
},
object_name: key.clone(),
version_id: if replication_source.delete_marker {
version_id: if deleted_object_source.delete_marker {
None
} else {
replication_source.version_id
deleted_object_source.version_id
},
delete_marker_mtime: replication_source.mod_time,
replication_state: Some(replication_source.replication_state()),
delete_marker_mtime: deleted_object_source.mod_time,
replication_state: Some(replication_state_source.replication_state()),
..Default::default()
},
bucket: bucket.clone(),
event_type: REPLICATE_INCOMING_DELETE.to_string(),
..Default::default()
};
enrich_delete_replication_state_if_needed(&bucket, &mut deleted_object.delete_object, replication_source).await;
enrich_delete_replication_state_if_needed(&bucket, &mut deleted_object.delete_object, replication_state_source).await;
schedule_replication_delete(deleted_object).await;
}
@@ -4802,6 +4863,25 @@ mod tests {
);
}
#[test]
fn should_schedule_delete_replication_keeps_object_version_purge_from_completed_source() {
let opts = ObjectOptions {
replication_request: false,
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
let replication_source = ObjectInfo {
delete_marker: false,
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
assert!(
should_schedule_delete_replication(&opts, &replication_source, false),
"source-side object version purge must still enqueue delete replication after the original PUT completed"
);
}
#[tokio::test]
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
let input = GetObjectAttributesInput::builder()
@@ -4990,7 +5070,12 @@ mod tests {
id: Some("rule-1".to_string()),
prefix: Some("test/".to_string()),
priority: Some(1),
source_selection_criteria: None,
source_selection_criteria: Some(SourceSelectionCriteria {
replica_modifications: Some(ReplicaModifications {
status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED),
}),
sse_kms_encrypted_objects: None,
}),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
};
@@ -5011,6 +5096,45 @@ mod tests {
assert!(state.targets.contains_key(&arn));
}
#[test]
fn delete_replication_state_from_config_skips_replica_delete_without_replica_modifications() {
let arn = "arn:aws:s3:::target-bucket".to_string();
let config = ReplicationConfiguration {
role: arn.clone(),
rules: vec![ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
}),
delete_replication: None,
destination: Destination {
bucket: arn,
..Default::default()
},
existing_object_replication: Some(ExistingObjectReplication {
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
}),
filter: None,
id: Some("rule-1".to_string()),
prefix: Some("test/".to_string()),
priority: Some(1),
source_selection_criteria: None,
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
};
let obj_info = ObjectInfo {
bucket: "bucket".to_string(),
name: "test/object.txt".to_string(),
delete_marker: true,
replication_status: ReplicationStatusType::Replica,
..Default::default()
};
assert!(
delete_replication_state_from_config(&config, &obj_info, None, true).is_none(),
"replica deletes must only fan out when ReplicaModifications are enabled"
);
}
#[test]
fn delete_replication_state_from_config_tracks_delete_marker_version_purges() {
let arn = "arn:aws:s3:::target-bucket".to_string();
@@ -5053,4 +5177,139 @@ mod tests {
assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};"));
assert!(state.purge_targets.contains_key(&arn));
}
#[test]
fn delete_replication_state_source_prefers_existing_replica_for_replication_delete_marker_creation() {
let opts = ObjectOptions {
replication_request: true,
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
let existing = ObjectInfo {
name: "test/object.txt".to_string(),
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
let deleted = ObjectInfo {
name: "test/object.txt".to_string(),
delete_marker: true,
..Default::default()
};
let source = delete_replication_state_source(&opts, Some(&existing), &deleted);
assert_eq!(source.replication_status, ReplicationStatusType::Completed);
assert!(
!source.delete_marker,
"downstream fanout should inherit replica identity from the pre-delete object"
);
}
#[test]
fn delete_replication_state_source_keeps_deleted_marker_for_non_replication_requests() {
let opts = ObjectOptions::default();
let existing = ObjectInfo {
name: "test/object.txt".to_string(),
replication_status: ReplicationStatusType::Replica,
..Default::default()
};
let deleted = ObjectInfo {
name: "test/object.txt".to_string(),
delete_marker: true,
..Default::default()
};
let source = delete_replication_state_source(&opts, Some(&existing), &deleted);
assert!(
source.delete_marker,
"source-originated deletes should keep using the new delete marker state"
);
}
#[test]
fn replica_delete_enrichment_must_not_reuse_upstream_targets() {
let delete_object = rustfs_ecstore::store_api::DeletedObject {
replication_state: Some(ReplicationState {
replicate_decision_str: "arn:aws:s3:::upstream=true;false;arn:aws:s3:::upstream;".to_string(),
replication_status_internal: Some("arn:aws:s3:::upstream=COMPLETED;".to_string()),
targets: replication_statuses_map("arn:aws:s3:::upstream=COMPLETED;"),
..Default::default()
}),
..Default::default()
};
let obj_info = ObjectInfo {
replication_status: ReplicationStatusType::Replica,
..Default::default()
};
let should_keep_existing = delete_object.replication_state.as_ref().is_some_and(|state| {
obj_info.replication_status != ReplicationStatusType::Replica
&& !state.replicate_decision_str.is_empty()
&& (!state.targets.is_empty() || !state.purge_targets.is_empty())
});
assert!(
!should_keep_existing,
"replica fanout deletes must recompute targets from the local bucket config instead of reusing upstream replication state"
);
}
#[test]
fn delete_replication_version_id_uses_none_for_delete_marker_creation() {
let source = ObjectInfo {
delete_marker: true,
version_id: Some(Uuid::new_v4()),
..Default::default()
};
assert_eq!(
delete_replication_version_id(&source, false),
None,
"delete-marker creation must stay on the delete-marker replication path"
);
}
#[test]
fn delete_replication_version_id_keeps_version_for_marker_purge() {
let version_id = Uuid::new_v4();
let source = ObjectInfo {
delete_marker: true,
version_id: Some(version_id),
..Default::default()
};
assert_eq!(
delete_replication_version_id(&source, true),
Some(version_id),
"delete-marker version purge must preserve the concrete version id for downstream purge replication"
);
}
#[test]
fn should_use_existing_delete_replication_info_ignores_replication_delete_marker_creation() {
let opts = ObjectOptions {
version_id: Some(Uuid::new_v4().to_string()),
delete_marker: true,
..Default::default()
};
assert!(
!should_use_existing_delete_replication_info(&opts),
"replicated delete-marker creation carries a source version id header but must not be treated as a version purge"
);
}
#[test]
fn should_use_existing_delete_replication_info_keeps_version_delete_requests() {
let opts = ObjectOptions {
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
assert!(
should_use_existing_delete_replication_info(&opts),
"true version-delete requests should keep using the pre-delete object info"
);
}
}
+1
View File
@@ -30,6 +30,7 @@
//! - `RUSTFS_CAPACITY_MAX_FILES_THRESHOLD` - Max files before sampling (default: 200,000)
//! - `RUSTFS_CAPACITY_STAT_TIMEOUT` - Stat operation timeout (default: 3s)
//! - `RUSTFS_CAPACITY_SAMPLE_RATE` - Sampling rate for metrics (default: 200)
//! - `RUSTFS_CAPACITY_METRICS_INTERVAL` - Metrics summary logging interval (default: 600s)
//! - `RUSTFS_CAPACITY_FOLLOW_SYMLINKS` - Follow symlinks during traversal (default: false)
//! - `RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH` - Max symlink depth (default: 3)
//! - `RUSTFS_CAPACITY_ENABLE_DYNAMIC_TIMEOUT` - Enable dynamic timeout (default: true)
+3 -1
View File
@@ -23,6 +23,8 @@ fn server_config_from_context() -> Option<rustfs_ecstore::config::Config> {
fn has_any_audit_targets(config: &rustfs_ecstore::config::Config) -> bool {
for subsystem in [
rustfs_config::audit::AUDIT_MQTT_SUB_SYS,
rustfs_config::audit::AUDIT_NATS_SUB_SYS,
rustfs_config::audit::AUDIT_PULSAR_SUB_SYS,
rustfs_config::audit::AUDIT_WEBHOOK_SUB_SYS,
] {
let Some(targets) = config.0.get(subsystem) else {
@@ -73,7 +75,7 @@ pub async fn start_audit_system() -> AuditResult<()> {
if !has_targets {
info!(
target: "rustfs::main::start_audit_system",
"Audit subsystem (MQTT/Webhook) is not configured, and audit system initialization is skipped."
"Audit subsystem (Webhook/MQTT/NATS/Pulsar) is not configured, and audit system initialization is skipped."
);
return Ok(());
}
+3 -1
View File
@@ -204,7 +204,9 @@ impl NodeService {
) -> Result<Response<ServerInfoResponse>, Status> {
let info = get_local_server_property().await;
let mut buf = Vec::new();
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
// Use map encoding for forward/backward compatibility across mixed versions:
// unknown fields can be ignored by older nodes during deserialization.
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf).with_struct_map()) {
return Ok(Response::new(ServerInfoResponse {
success: false,
server_properties: Bytes::new(),
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
set -euo pipefail
# Batch object benchmark runner for warp/s3bench.
# Runs a fixed size matrix under the same concurrency and exports per-size logs + summary CSV.
DEFAULT_SIZES="1KiB,4KiB,8KiB,16KiB,32KiB,100KiB,512KiB,1MiB,2MiB,5MiB,10MiB"
TOOL="warp"
ENDPOINT=""
ACCESS_KEY=""
SECRET_KEY=""
BUCKET="rustfs-bench"
REGION="us-east-1"
CONCURRENCY=128
DURATION="60s"
SAMPLES=20000
SIZES="$DEFAULT_SIZES"
OUT_DIR=""
WARP_BIN="warp"
WARP_MODE="mixed"
S3BENCH_BIN="s3bench"
INSECURE=false
DRY_RUN=false
EXTRA_ARGS=()
usage() {
cat <<'USAGE'
Usage:
scripts/run_object_batch_bench.sh --tool <warp|s3bench> --endpoint <host:port|url> \
--access-key <ak> --secret-key <sk> [options]
Required:
--tool warp | s3bench
--endpoint S3 endpoint
--access-key S3 access key
--secret-key S3 secret key
Optional:
--bucket Bucket name (default: rustfs-bench)
--region Region (default: us-east-1)
--concurrency Concurrency for all sizes (default: 128)
--duration warp duration, e.g. 60s/2m (default: 60s)
--samples s3bench numSamples (default: 20000)
--sizes Comma-separated sizes (default: 1KiB..10MiB matrix)
--out-dir Output directory (default: target/bench/object-batch-<timestamp>)
--warp-bin warp binary (default: warp)
--warp-mode warp mode: get|put|mixed (default: mixed)
--s3bench-bin s3bench binary (default: s3bench)
--extra-args Extra args appended to tool command, quoted as one string
--insecure For TLS endpoints with self-signed certs
--dry-run Print commands only
-h, --help Show help
Examples:
# warp
scripts/run_object_batch_bench.sh \
--tool warp --endpoint http://127.0.0.1:9000 \
--access-key minioadmin --secret-key minioadmin \
--bucket bench-obj --concurrency 128 --duration 90s --warp-mode get
# s3bench
scripts/run_object_batch_bench.sh \
--tool s3bench --endpoint http://127.0.0.1:9000 \
--access-key minioadmin --secret-key minioadmin \
--bucket bench-obj --concurrency 128 --samples 50000
USAGE
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: command not found: $1" >&2
exit 1
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--tool) TOOL="$2"; shift 2 ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--access-key) ACCESS_KEY="$2"; shift 2 ;;
--secret-key) SECRET_KEY="$2"; shift 2 ;;
--bucket) BUCKET="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
--concurrency) CONCURRENCY="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--samples) SAMPLES="$2"; shift 2 ;;
--sizes) SIZES="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--warp-bin) WARP_BIN="$2"; shift 2 ;;
--warp-mode) WARP_MODE="$2"; shift 2 ;;
--s3bench-bin) S3BENCH_BIN="$2"; shift 2 ;;
--extra-args)
# shellcheck disable=SC2206
EXTRA_ARGS=($2)
shift 2
;;
--insecure) INSECURE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: unknown arg: $1" >&2
usage
exit 1
;;
esac
done
}
validate_args() {
if [[ "$TOOL" != "warp" && "$TOOL" != "s3bench" ]]; then
echo "ERROR: --tool must be warp or s3bench" >&2
exit 1
fi
if [[ -z "$ENDPOINT" || -z "$ACCESS_KEY" || -z "$SECRET_KEY" ]]; then
echo "ERROR: --endpoint/--access-key/--secret-key are required" >&2
exit 1
fi
if ! [[ "$CONCURRENCY" =~ ^[0-9]+$ ]] || [[ "$CONCURRENCY" -le 0 ]]; then
echo "ERROR: --concurrency must be a positive integer" >&2
exit 1
fi
if [[ "$TOOL" == "s3bench" ]]; then
if ! [[ "$SAMPLES" =~ ^[0-9]+$ ]] || [[ "$SAMPLES" -le 0 ]]; then
echo "ERROR: --samples must be a positive integer" >&2
exit 1
fi
fi
}
setup_output() {
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="target/bench/object-batch-$(date +%Y%m%d-%H%M%S)"
fi
mkdir -p "$OUT_DIR"
SUMMARY_CSV="$OUT_DIR/summary.csv"
echo "size,tool,concurrency,status,throughput,requests_per_sec,avg_latency,log_file" > "$SUMMARY_CSV"
}
extract_value() {
local pattern="$1"
local file="$2"
rg -o "$pattern" "$file" | head -n1 | sed -E "s/$pattern/\\1/" || true
}
collect_metrics() {
local log_file="$1"
local throughput reqps latency
throughput="$(extract_value '([0-9]+(\\.[0-9]+)?\\s*(GiB/s|MiB/s|MB/s|KB/s))' "$log_file")"
reqps="$(extract_value '([0-9]+(\\.[0-9]+)?\\s*(req/s|ops/s|requests/s))' "$log_file")"
latency="$(extract_value '([0-9]+(\\.[0-9]+)?\\s*(ms|us|µs|s))(\\s*(avg|mean))?' "$log_file")"
echo "${throughput:-N/A},${reqps:-N/A},${latency:-N/A}"
}
run_one() {
local size="$1"
local log_file="$OUT_DIR/${TOOL}_${size}.log"
local status="ok"
echo "==== [$TOOL] size=$size concurrency=$CONCURRENCY ===="
if [[ "$TOOL" == "warp" ]]; then
local cmd=(
"$WARP_BIN" "$WARP_MODE"
"--host" "$ENDPOINT"
"--access-key" "$ACCESS_KEY"
"--secret-key" "$SECRET_KEY"
"--bucket" "$BUCKET"
"--obj.size" "$size"
"--concurrent" "$CONCURRENCY"
"--duration" "$DURATION"
"--region" "$REGION"
)
if [[ "$INSECURE" == "true" ]]; then
cmd+=("--insecure")
fi
cmd+=("${EXTRA_ARGS[@]}")
if [[ "$DRY_RUN" == "true" ]]; then
printf '[DRY-RUN] %q ' "${cmd[@]}"
printf '\n'
echo "size=$size tool=$TOOL dry_run" > "$log_file"
else
if ! "${cmd[@]}" 2>&1 | tee "$log_file"; then
status="failed"
fi
fi
else
local cmd=(
"$S3BENCH_BIN"
"-accessKey=$ACCESS_KEY"
"-secretKey=$SECRET_KEY"
"-bucket=$BUCKET"
"-endpoint=$ENDPOINT"
"-region=$REGION"
"-numClients=$CONCURRENCY"
"-numSamples=$SAMPLES"
"-objectSize=$size"
)
if [[ "$INSECURE" == "true" ]]; then
cmd+=("-insecure")
fi
cmd+=("${EXTRA_ARGS[@]}")
if [[ "$DRY_RUN" == "true" ]]; then
printf '[DRY-RUN] %q ' "${cmd[@]}"
printf '\n'
echo "size=$size tool=$TOOL dry_run" > "$log_file"
else
if ! "${cmd[@]}" 2>&1 | tee "$log_file"; then
status="failed"
fi
fi
fi
local metrics throughput reqps latency
metrics="$(collect_metrics "$log_file")"
throughput="$(echo "$metrics" | cut -d',' -f1)"
reqps="$(echo "$metrics" | cut -d',' -f2)"
latency="$(echo "$metrics" | cut -d',' -f3)"
echo "$size,$TOOL,$CONCURRENCY,$status,$throughput,$reqps,$latency,$log_file" >> "$SUMMARY_CSV"
}
main() {
parse_args "$@"
validate_args
require_cmd rg
if [[ "$TOOL" == "warp" ]]; then
require_cmd "$WARP_BIN"
else
require_cmd "$S3BENCH_BIN"
fi
setup_output
echo "Output dir: $OUT_DIR"
echo "Tool: $TOOL"
echo "Sizes: $SIZES"
echo "Concurrency: $CONCURRENCY"
IFS=',' read -r -a size_arr <<< "$SIZES"
for raw_size in "${size_arr[@]}"; do
size="$(echo "$raw_size" | xargs)"
if [[ -z "$size" ]]; then
continue
fi
run_one "$size"
done
echo
echo "Done. Summary:"
cat "$SUMMARY_CSV"
}
main "$@"
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env bash
set -euo pipefail
# One-click controller:
# - Switches RUSTFS_CAPACITY_* and RUSTFS_OBJECT_* by profile A/B/C
# - Calls scripts/run_object_batch_bench_enhanced.sh for each profile
# - Supports optional "apply command" hook to reload/restart RustFS per profile
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENHANCED_SCRIPT="$SCRIPT_DIR/run_object_batch_bench_enhanced.sh"
GROUP="all" # all|A|B|C
ENDPOINT=""
ACCESS_KEY=""
SECRET_KEY=""
BUCKET="rustfs-bench"
REGION="us-east-1"
TOOL="warp"
CONCURRENCY=128
ROUNDS=3
RETRY_PER_ROUND=2
RETRY_SLEEP_SECS=2
INSECURE=false
DRY_RUN=false
OUT_ROOT=""
BASELINE_ROOT=""
# tool-specific
WARP_BIN="warp"
WARP_MODE="mixed"
DURATION="60s"
S3BENCH_BIN="s3bench"
SAMPLES=20000
# optional hooks
APPLY_CMD=""
APPLY_WAIT_SECS=20
EXTRA_ARGS=()
usage() {
cat <<'USAGE'
Usage:
scripts/run_object_batch_bench_abc.sh \
--tool <warp|s3bench> --endpoint <url> --access-key <ak> --secret-key <sk> [options]
Required:
--tool warp | s3bench
--endpoint S3 endpoint
--access-key S3 access key
--secret-key S3 secret key
Core options:
--group all|A|B|C (default: all)
--bucket Bucket name (default: rustfs-bench)
--region Region (default: us-east-1)
--concurrency Default 128
--rounds Default 3
--retry-per-round Default 2
--retry-sleep-secs Default 2
--out-root Default target/bench/object-batch-abc-<timestamp>
--baseline-root If set, use <baseline-root>/<group>/median_summary.csv
--insecure Allow insecure TLS
--dry-run Print commands without execution
Warp options:
--warp-bin Default: warp
--warp-mode get|put|mixed (default: mixed)
--duration Default: 60s
s3bench options:
--s3bench-bin Default: s3bench
--samples Default: 20000
Hooks:
--apply-cmd Optional command to apply/restart RustFS after profile env switch.
Runs via: eval "$APPLY_CMD"
--apply-wait-secs Wait time after apply cmd (default: 20)
Extra:
--extra-args Extra args passed to enhanced script, quoted as one string
-h, --help Show this help
Examples:
scripts/run_object_batch_bench_abc.sh \
--tool warp --endpoint http://127.0.0.1:9000 \
--access-key minioadmin --secret-key minioadmin \
--bucket bench-obj --group all --duration 90s
scripts/run_object_batch_bench_abc.sh \
--tool s3bench --endpoint http://127.0.0.1:9000 \
--access-key minioadmin --secret-key minioadmin \
--group B --samples 50000 --apply-cmd "bash scripts/run.capacity-object.lab.sh"
USAGE
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: command not found: $1" >&2
exit 1
fi
}
validate_positive_int() {
local v="$1"
local n="$2"
if ! [[ "$v" =~ ^[0-9]+$ ]] || [[ "$v" -le 0 ]]; then
echo "ERROR: $n must be a positive integer, got: $v" >&2
exit 1
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--tool) TOOL="$2"; shift 2 ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--access-key) ACCESS_KEY="$2"; shift 2 ;;
--secret-key) SECRET_KEY="$2"; shift 2 ;;
--group) GROUP="$2"; shift 2 ;;
--bucket) BUCKET="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
--concurrency) CONCURRENCY="$2"; shift 2 ;;
--rounds) ROUNDS="$2"; shift 2 ;;
--retry-per-round) RETRY_PER_ROUND="$2"; shift 2 ;;
--retry-sleep-secs) RETRY_SLEEP_SECS="$2"; shift 2 ;;
--out-root) OUT_ROOT="$2"; shift 2 ;;
--baseline-root) BASELINE_ROOT="$2"; shift 2 ;;
--insecure) INSECURE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--warp-bin) WARP_BIN="$2"; shift 2 ;;
--warp-mode) WARP_MODE="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--s3bench-bin) S3BENCH_BIN="$2"; shift 2 ;;
--samples) SAMPLES="$2"; shift 2 ;;
--apply-cmd) APPLY_CMD="$2"; shift 2 ;;
--apply-wait-secs) APPLY_WAIT_SECS="$2"; shift 2 ;;
--extra-args)
# shellcheck disable=SC2206
EXTRA_ARGS=($2)
shift 2
;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: unknown arg: $1" >&2
usage
exit 1
;;
esac
done
}
validate_args() {
if [[ "$TOOL" != "warp" && "$TOOL" != "s3bench" ]]; then
echo "ERROR: --tool must be warp or s3bench" >&2
exit 1
fi
case "$GROUP" in
all|A|B|C) ;;
*) echo "ERROR: --group must be all|A|B|C" >&2; exit 1 ;;
esac
if [[ -z "$ENDPOINT" || -z "$ACCESS_KEY" || -z "$SECRET_KEY" ]]; then
echo "ERROR: --endpoint/--access-key/--secret-key are required" >&2
exit 1
fi
validate_positive_int "$CONCURRENCY" "--concurrency"
validate_positive_int "$ROUNDS" "--rounds"
validate_positive_int "$RETRY_PER_ROUND" "--retry-per-round"
validate_positive_int "$RETRY_SLEEP_SECS" "--retry-sleep-secs"
validate_positive_int "$APPLY_WAIT_SECS" "--apply-wait-secs"
if [[ "$TOOL" == "s3bench" ]]; then
validate_positive_int "$SAMPLES" "--samples"
fi
}
setup_out_root() {
if [[ -z "$OUT_ROOT" ]]; then
OUT_ROOT="target/bench/object-batch-abc-$(date +%Y%m%d-%H%M%S)"
fi
mkdir -p "$OUT_ROOT"
}
apply_capacity_common() {
export RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
export RUSTFS_CAPACITY_WRITE_TRIGGER_DELAY=8
export RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=14
export RUSTFS_CAPACITY_FAST_UPDATE_THRESHOLD=45
export RUSTFS_CAPACITY_MAX_FILES_THRESHOLD=1000000
export RUSTFS_CAPACITY_STAT_TIMEOUT=5
export RUSTFS_CAPACITY_SAMPLE_RATE=100
export RUSTFS_CAPACITY_METRICS_INTERVAL=120
}
apply_object_profile_A() {
export RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=128
export RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=2097152
export RUSTFS_OBJECT_GET_TIMEOUT=18
export RUSTFS_OBJECT_DISK_READ_TIMEOUT=6
export RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=4
export RUSTFS_OBJECT_PRIORITY_SCHEDULING_ENABLE=true
export RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE=true
export RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
export RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
}
apply_object_profile_B() {
export RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=112
export RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=4194304
export RUSTFS_OBJECT_GET_TIMEOUT=30
export RUSTFS_OBJECT_DISK_READ_TIMEOUT=10
export RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=5
export RUSTFS_OBJECT_PRIORITY_SCHEDULING_ENABLE=true
export RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE=true
export RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
export RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
}
apply_object_profile_C() {
export RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=72
export RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
export RUSTFS_OBJECT_GET_TIMEOUT=50
export RUSTFS_OBJECT_DISK_READ_TIMEOUT=14
export RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=6
export RUSTFS_OBJECT_PRIORITY_SCHEDULING_ENABLE=true
export RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE=true
export RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
export RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
}
sizes_for_group() {
case "$1" in
A) echo "1KiB,4KiB,8KiB,16KiB,32KiB,100KiB" ;;
B) echo "100KiB,512KiB,1MiB,2MiB" ;;
C) echo "2MiB,5MiB,10MiB" ;;
*) echo "" ;;
esac
}
run_apply_hook_if_needed() {
local group="$1"
if [[ -z "$APPLY_CMD" ]]; then
return
fi
echo "[${group}] running apply command..."
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY-RUN] eval \"$APPLY_CMD\""
echo "[DRY-RUN] sleep $APPLY_WAIT_SECS"
else
eval "$APPLY_CMD"
echo "[${group}] waiting ${APPLY_WAIT_SECS}s for service readiness..."
sleep "$APPLY_WAIT_SECS"
fi
}
write_env_snapshot() {
local out_file="$1"
cat > "$out_file" <<EOF
RUSTFS_CAPACITY_SCHEDULED_INTERVAL=${RUSTFS_CAPACITY_SCHEDULED_INTERVAL}
RUSTFS_CAPACITY_WRITE_TRIGGER_DELAY=${RUSTFS_CAPACITY_WRITE_TRIGGER_DELAY}
RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=${RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD}
RUSTFS_CAPACITY_FAST_UPDATE_THRESHOLD=${RUSTFS_CAPACITY_FAST_UPDATE_THRESHOLD}
RUSTFS_CAPACITY_MAX_FILES_THRESHOLD=${RUSTFS_CAPACITY_MAX_FILES_THRESHOLD}
RUSTFS_CAPACITY_STAT_TIMEOUT=${RUSTFS_CAPACITY_STAT_TIMEOUT}
RUSTFS_CAPACITY_SAMPLE_RATE=${RUSTFS_CAPACITY_SAMPLE_RATE}
RUSTFS_CAPACITY_METRICS_INTERVAL=${RUSTFS_CAPACITY_METRICS_INTERVAL}
RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=${RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS}
RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=${RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE}
RUSTFS_OBJECT_GET_TIMEOUT=${RUSTFS_OBJECT_GET_TIMEOUT}
RUSTFS_OBJECT_DISK_READ_TIMEOUT=${RUSTFS_OBJECT_DISK_READ_TIMEOUT}
RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT}
RUSTFS_OBJECT_PRIORITY_SCHEDULING_ENABLE=${RUSTFS_OBJECT_PRIORITY_SCHEDULING_ENABLE}
RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE=${RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE}
RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD}
RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD}
EOF
}
run_group() {
local g="$1"
local sizes out_dir baseline_csv
apply_capacity_common
case "$g" in
A) apply_object_profile_A ;;
B) apply_object_profile_B ;;
C) apply_object_profile_C ;;
*) echo "ERROR: unsupported group $g" >&2; exit 1 ;;
esac
sizes="$(sizes_for_group "$g")"
out_dir="$OUT_ROOT/$g"
mkdir -p "$out_dir"
write_env_snapshot "$out_dir/env_snapshot.env"
run_apply_hook_if_needed "$g"
baseline_csv=""
if [[ -n "$BASELINE_ROOT" && -f "$BASELINE_ROOT/$g/median_summary.csv" ]]; then
baseline_csv="$BASELINE_ROOT/$g/median_summary.csv"
fi
local cmd=(
"$ENHANCED_SCRIPT"
"--tool" "$TOOL"
"--endpoint" "$ENDPOINT"
"--access-key" "$ACCESS_KEY"
"--secret-key" "$SECRET_KEY"
"--bucket" "$BUCKET"
"--region" "$REGION"
"--concurrency" "$CONCURRENCY"
"--sizes" "$sizes"
"--rounds" "$ROUNDS"
"--retry-per-round" "$RETRY_PER_ROUND"
"--retry-sleep-secs" "$RETRY_SLEEP_SECS"
"--out-dir" "$out_dir"
)
if [[ -n "$baseline_csv" ]]; then
cmd+=("--baseline-csv" "$baseline_csv")
fi
if [[ "$INSECURE" == "true" ]]; then
cmd+=("--insecure")
fi
if [[ "$DRY_RUN" == "true" ]]; then
cmd+=("--dry-run")
fi
if [[ "$TOOL" == "warp" ]]; then
cmd+=("--warp-bin" "$WARP_BIN" "--warp-mode" "$WARP_MODE" "--duration" "$DURATION")
else
cmd+=("--s3bench-bin" "$S3BENCH_BIN" "--samples" "$SAMPLES")
fi
if [[ "${#EXTRA_ARGS[@]}" -gt 0 ]]; then
local joined
joined="$(printf '%s ' "${EXTRA_ARGS[@]}" | sed 's/[[:space:]]*$//')"
cmd+=("--extra-args" "$joined")
fi
echo
echo "===== Running group ${g} ====="
echo "Sizes: $sizes"
echo "Output: $out_dir"
if [[ "$DRY_RUN" == "true" ]]; then
printf '[DRY-RUN] %q ' "${cmd[@]}"
printf '\n'
else
"${cmd[@]}"
fi
}
main() {
parse_args "$@"
validate_args
require_cmd awk
require_cmd sed
if [[ ! -x "$ENHANCED_SCRIPT" ]]; then
echo "ERROR: enhanced script missing or not executable: $ENHANCED_SCRIPT" >&2
exit 1
fi
setup_out_root
echo "Controller output root: $OUT_ROOT"
echo "Tool=$TOOL Group=$GROUP Concurrency=$CONCURRENCY Rounds=$ROUNDS"
case "$GROUP" in
all)
run_group A
run_group B
run_group C
;;
A|B|C)
run_group "$GROUP"
;;
esac
echo
echo "Done. Group outputs are under: $OUT_ROOT"
}
main "$@"
+487
View File
@@ -0,0 +1,487 @@
#!/usr/bin/env bash
set -euo pipefail
# Enhanced batch object benchmark runner for warp/s3bench:
# - Multi-round execution (default 3 rounds)
# - Retry failed round attempts automatically
# - Median aggregation per object size
# - Optional baseline CSV comparison
DEFAULT_SIZES="1KiB,4KiB,8KiB,16KiB,32KiB,100KiB,512KiB,1MiB,2MiB,5MiB,10MiB"
TOOL="warp"
ENDPOINT=""
ACCESS_KEY=""
SECRET_KEY=""
BUCKET="rustfs-bench"
REGION="us-east-1"
CONCURRENCY=128
SIZES="$DEFAULT_SIZES"
OUT_DIR=""
INSECURE=false
DRY_RUN=false
# warp options
WARP_BIN="warp"
WARP_MODE="mixed"
DURATION="60s"
# s3bench options
S3BENCH_BIN="s3bench"
SAMPLES=20000
# enhancement options
ROUNDS=3
RETRY_PER_ROUND=2
RETRY_SLEEP_SECS=2
BASELINE_CSV=""
EXTRA_ARGS=()
usage() {
cat <<'USAGE'
Usage:
scripts/run_object_batch_bench_enhanced.sh --tool <warp|s3bench> --endpoint <host:port|url> \
--access-key <ak> --secret-key <sk> [options]
Required:
--tool warp | s3bench
--endpoint S3 endpoint
--access-key S3 access key
--secret-key S3 secret key
Core options:
--bucket Bucket name (default: rustfs-bench)
--region Region (default: us-east-1)
--concurrency Concurrency for all sizes (default: 128)
--sizes Comma-separated sizes (default: 1KiB..10MiB matrix)
--out-dir Output directory (default: target/bench/object-batch-enhanced-<timestamp>)
--insecure Allow insecure TLS
--dry-run Print commands only, do not execute
Warp options:
--warp-bin warp binary (default: warp)
--warp-mode get|put|mixed (default: mixed)
--duration e.g. 60s/2m (default: 60s)
s3bench options:
--s3bench-bin s3bench binary (default: s3bench)
--samples numSamples (default: 20000)
Enhanced options:
--rounds Benchmark rounds per size (default: 3)
--retry-per-round Retry count per failed round (default: 2)
--retry-sleep-secs Sleep seconds between retries (default: 2)
--baseline-csv Baseline median CSV to compare
--extra-args Extra args appended to tool command, quoted as one string
Output files:
round_results.csv One row per round attempt (with retry trace)
median_summary.csv Median metrics per object size
baseline_compare.csv Delta vs baseline (if --baseline-csv is set)
Example:
scripts/run_object_batch_bench_enhanced.sh \
--tool warp --endpoint http://127.0.0.1:9000 \
--access-key minioadmin --secret-key minioadmin \
--bucket bench-obj --concurrency 128 --duration 90s \
--rounds 3 --retry-per-round 2 --baseline-csv old/median_summary.csv
USAGE
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: command not found: $1" >&2
exit 1
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--tool) TOOL="$2"; shift 2 ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--access-key) ACCESS_KEY="$2"; shift 2 ;;
--secret-key) SECRET_KEY="$2"; shift 2 ;;
--bucket) BUCKET="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
--concurrency) CONCURRENCY="$2"; shift 2 ;;
--sizes) SIZES="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--insecure) INSECURE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--warp-bin) WARP_BIN="$2"; shift 2 ;;
--warp-mode) WARP_MODE="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--s3bench-bin) S3BENCH_BIN="$2"; shift 2 ;;
--samples) SAMPLES="$2"; shift 2 ;;
--rounds) ROUNDS="$2"; shift 2 ;;
--retry-per-round) RETRY_PER_ROUND="$2"; shift 2 ;;
--retry-sleep-secs) RETRY_SLEEP_SECS="$2"; shift 2 ;;
--baseline-csv) BASELINE_CSV="$2"; shift 2 ;;
--extra-args)
# shellcheck disable=SC2206
EXTRA_ARGS=($2)
shift 2
;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: unknown arg: $1" >&2
usage
exit 1
;;
esac
done
}
validate_positive_int() {
local v="$1"
local n="$2"
if ! [[ "$v" =~ ^[0-9]+$ ]] || [[ "$v" -le 0 ]]; then
echo "ERROR: $n must be a positive integer, got: $v" >&2
exit 1
fi
}
validate_args() {
if [[ "$TOOL" != "warp" && "$TOOL" != "s3bench" ]]; then
echo "ERROR: --tool must be warp or s3bench" >&2
exit 1
fi
if [[ -z "$ENDPOINT" || -z "$ACCESS_KEY" || -z "$SECRET_KEY" ]]; then
echo "ERROR: --endpoint/--access-key/--secret-key are required" >&2
exit 1
fi
validate_positive_int "$CONCURRENCY" "--concurrency"
validate_positive_int "$ROUNDS" "--rounds"
validate_positive_int "$RETRY_PER_ROUND" "--retry-per-round"
validate_positive_int "$RETRY_SLEEP_SECS" "--retry-sleep-secs"
if [[ "$TOOL" == "s3bench" ]]; then
validate_positive_int "$SAMPLES" "--samples"
fi
if [[ -n "$BASELINE_CSV" && ! -f "$BASELINE_CSV" ]]; then
echo "ERROR: --baseline-csv does not exist: $BASELINE_CSV" >&2
exit 1
fi
}
setup_output() {
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="target/bench/object-batch-enhanced-$(date +%Y%m%d-%H%M%S)"
fi
mkdir -p "$OUT_DIR/logs"
ROUND_CSV="$OUT_DIR/round_results.csv"
MEDIAN_CSV="$OUT_DIR/median_summary.csv"
COMPARE_CSV="$OUT_DIR/baseline_compare.csv"
echo "size,tool,round,attempt,concurrency,status,throughput_human,throughput_bps,reqps,latency_human,latency_ms,log_file" > "$ROUND_CSV"
echo "size,tool,concurrency,successful_rounds,failed_rounds,median_throughput_bps,median_reqps,median_latency_ms" > "$MEDIAN_CSV"
}
trim() {
echo "$1" | awk '{$1=$1;print}'
}
to_bps() {
local human="$1"
if [[ "$human" == "N/A" || -z "$human" ]]; then
echo "N/A"
return
fi
awk -v v="$human" '
function abs(x){return x<0?-x:x}
BEGIN{
if (match(v, /^([0-9]+(\.[0-9]+)?)\s*(GiB\/s|MiB\/s|KiB\/s|GB\/s|MB\/s|KB\/s|B\/s)$/, m)) {
n=m[1]; u=m[3];
if (u=="GiB/s") f=1024*1024*1024;
else if (u=="MiB/s") f=1024*1024;
else if (u=="KiB/s") f=1024;
else if (u=="GB/s") f=1000*1000*1000;
else if (u=="MB/s") f=1000*1000;
else if (u=="KB/s") f=1000;
else f=1;
printf "%.6f\n", n*f;
} else {
print "N/A";
}
}'
}
to_ms() {
local human="$1"
if [[ "$human" == "N/A" || -z "$human" ]]; then
echo "N/A"
return
fi
awk -v v="$human" '
BEGIN{
if (match(v, /^([0-9]+(\.[0-9]+)?)\s*(ms|us|µs|s)$/, m)) {
n=m[1]; u=m[3];
if (u=="s") f=1000;
else if (u=="ms") f=1;
else f=0.001;
printf "%.6f\n", n*f;
} else {
print "N/A";
}
}'
}
extract_first() {
local regex="$1"
local file="$2"
rg -o "$regex" "$file" | head -n1 || true
}
extract_metrics() {
local log_file="$1"
local throughput reqps latency
throughput="$(extract_first '[0-9]+(\.[0-9]+)?\s*(GiB/s|MiB/s|KiB/s|GB/s|MB/s|KB/s|B/s)' "$log_file")"
reqps="$(extract_first '[0-9]+(\.[0-9]+)?\s*(req/s|ops/s|requests/s)' "$log_file")"
latency="$(extract_first '[0-9]+(\.[0-9]+)?\s*(ms|us|µs|s)\s*(avg|mean)' "$log_file")"
if [[ -z "$latency" ]]; then
latency="$(extract_first '[0-9]+(\.[0-9]+)?\s*(ms|us|µs|s)' "$log_file")"
fi
throughput="$(trim "${throughput:-N/A}")"
reqps="$(trim "${reqps:-N/A}")"
latency="$(trim "${latency:-N/A}")"
# Keep only "<num> <unit>" for latency if suffix avg/mean exists.
latency="$(echo "$latency" | awk '{print $1" "$2}')"
reqps_num="$(echo "$reqps" | awk '{print $1}')"
echo "$throughput,${reqps_num:-N/A},$latency"
}
median_from_numbers() {
local values="$1"
local count
count="$(printf '%s\n' "$values" | awk 'NF{c++} END{print c+0}')"
if [[ "$count" -eq 0 ]]; then
echo "N/A"
return
fi
printf '%s\n' "$values" | awk 'NF' | sort -n | awk '
{a[NR]=$1}
END{
n=NR
if (n==0) { print "N/A"; exit }
if (n%2==1) {
printf "%.6f\n", a[(n+1)/2]
} else {
printf "%.6f\n", (a[n/2]+a[n/2+1])/2
}
}'
}
run_one_attempt() {
local size="$1"
local round="$2"
local attempt="$3"
local log_file="$OUT_DIR/logs/${TOOL}_${size}_r${round}_a${attempt}.log"
local status="ok"
if [[ "$TOOL" == "warp" ]]; then
local cmd=(
"$WARP_BIN" "$WARP_MODE"
"--host" "$ENDPOINT"
"--access-key" "$ACCESS_KEY"
"--secret-key" "$SECRET_KEY"
"--bucket" "$BUCKET"
"--obj.size" "$size"
"--concurrent" "$CONCURRENCY"
"--duration" "$DURATION"
"--region" "$REGION"
)
if [[ "$INSECURE" == "true" ]]; then
cmd+=("--insecure")
fi
cmd+=("${EXTRA_ARGS[@]}")
if [[ "$DRY_RUN" == "true" ]]; then
printf '[DRY-RUN] %q ' "${cmd[@]}"
printf '\n'
echo "dry run" > "$log_file"
else
if ! "${cmd[@]}" 2>&1 | tee "$log_file"; then
status="failed"
fi
fi
else
local cmd=(
"$S3BENCH_BIN"
"-accessKey=$ACCESS_KEY"
"-secretKey=$SECRET_KEY"
"-bucket=$BUCKET"
"-endpoint=$ENDPOINT"
"-region=$REGION"
"-numClients=$CONCURRENCY"
"-numSamples=$SAMPLES"
"-objectSize=$size"
)
if [[ "$INSECURE" == "true" ]]; then
cmd+=("-insecure")
fi
cmd+=("${EXTRA_ARGS[@]}")
if [[ "$DRY_RUN" == "true" ]]; then
printf '[DRY-RUN] %q ' "${cmd[@]}"
printf '\n'
echo "dry run" > "$log_file"
else
if ! "${cmd[@]}" 2>&1 | tee "$log_file"; then
status="failed"
fi
fi
fi
local metrics throughput_human reqps latency_human throughput_bps latency_ms
metrics="$(extract_metrics "$log_file")"
throughput_human="$(echo "$metrics" | cut -d',' -f1)"
reqps="$(echo "$metrics" | cut -d',' -f2)"
latency_human="$(echo "$metrics" | cut -d',' -f3)"
throughput_bps="$(to_bps "$throughput_human")"
latency_ms="$(to_ms "$latency_human")"
if [[ "$DRY_RUN" != "true" && "$status" == "ok" ]]; then
if [[ "$throughput_bps" == "N/A" && "$reqps" == "N/A" ]]; then
status="failed"
fi
fi
echo "$size,$TOOL,$round,$attempt,$CONCURRENCY,$status,$throughput_human,$throughput_bps,$reqps,$latency_human,$latency_ms,$log_file" >> "$ROUND_CSV"
echo "$status"
}
run_size() {
local size="$1"
local round success attempt rc
for ((round=1; round<=ROUNDS; round++)); do
success="no"
for ((attempt=1; attempt<=RETRY_PER_ROUND+1; attempt++)); do
echo "==== size=$size round=$round attempt=$attempt/${RETRY_PER_ROUND+1} ===="
rc="$(run_one_attempt "$size" "$round" "$attempt")"
if [[ "$rc" == "ok" || "$DRY_RUN" == "true" ]]; then
success="yes"
break
fi
if (( attempt < RETRY_PER_ROUND+1 )); then
echo "Round failed, retry in ${RETRY_SLEEP_SECS}s..."
sleep "$RETRY_SLEEP_SECS"
fi
done
if [[ "$success" == "no" ]]; then
echo "WARN: size=$size round=$round failed after retries."
fi
done
}
build_median_summary() {
local sizes_arr size
IFS=',' read -r -a sizes_arr <<< "$SIZES"
for raw in "${sizes_arr[@]}"; do
size="$(trim "$raw")"
[[ -z "$size" ]] && continue
local ok_rounds fail_rounds t_vals r_vals l_vals
ok_rounds="$(awk -F',' -v s="$size" 'NR>1 && $1==s && $6=="ok" {c++} END{print c+0}' "$ROUND_CSV")"
fail_rounds="$(awk -F',' -v s="$size" 'NR>1 && $1==s && $6!="ok" {c++} END{print c+0}' "$ROUND_CSV")"
t_vals="$(awk -F',' -v s="$size" 'NR>1 && $1==s && $6=="ok" && $8!="N/A" {print $8}' "$ROUND_CSV")"
r_vals="$(awk -F',' -v s="$size" 'NR>1 && $1==s && $6=="ok" && $9!="N/A" {print $9}' "$ROUND_CSV")"
l_vals="$(awk -F',' -v s="$size" 'NR>1 && $1==s && $6=="ok" && $11!="N/A" {print $11}' "$ROUND_CSV")"
local m_t m_r m_l
m_t="$(median_from_numbers "$t_vals")"
m_r="$(median_from_numbers "$r_vals")"
m_l="$(median_from_numbers "$l_vals")"
echo "$size,$TOOL,$CONCURRENCY,$ok_rounds,$fail_rounds,$m_t,$m_r,$m_l" >> "$MEDIAN_CSV"
done
}
compare_baseline() {
if [[ -z "$BASELINE_CSV" ]]; then
return
fi
echo "size,tool,concurrency,new_median_reqps,baseline_median_reqps,delta_reqps_pct,new_median_latency_ms,baseline_median_latency_ms,delta_latency_pct,new_median_throughput_bps,baseline_median_throughput_bps,delta_throughput_pct" > "$COMPARE_CSV"
awk -F',' '
NR==FNR {
if (FNR==1) next
key=$1
b_req[key]=$7
b_lat[key]=$8
b_thr[key]=$6
next
}
FNR==1 {next}
{
key=$1
n_thr=$6; n_req=$7; n_lat=$8
br=(key in b_req)?b_req[key]:"N/A"
bl=(key in b_lat)?b_lat[key]:"N/A"
bt=(key in b_thr)?b_thr[key]:"N/A"
dr="N/A"; dl="N/A"; dt="N/A"
if (br!="N/A" && n_req!="N/A" && br+0!=0) dr=sprintf("%.2f", ((n_req-br)/br)*100)
if (bl!="N/A" && n_lat!="N/A" && bl+0!=0) dl=sprintf("%.2f", ((n_lat-bl)/bl)*100)
if (bt!="N/A" && n_thr!="N/A" && bt+0!=0) dt=sprintf("%.2f", ((n_thr-bt)/bt)*100)
print key "," $2 "," $3 "," n_req "," br "," dr "," n_lat "," bl "," dl "," n_thr "," bt "," dt
}
' "$BASELINE_CSV" "$MEDIAN_CSV" >> "$COMPARE_CSV"
}
main() {
parse_args "$@"
validate_args
require_cmd rg
require_cmd awk
require_cmd sort
if [[ "$TOOL" == "warp" ]]; then
require_cmd "$WARP_BIN"
else
require_cmd "$S3BENCH_BIN"
fi
setup_output
echo "Output dir: $OUT_DIR"
echo "Tool: $TOOL"
echo "Sizes: $SIZES"
echo "Concurrency: $CONCURRENCY"
echo "Rounds: $ROUNDS"
echo "Retry per round: $RETRY_PER_ROUND"
IFS=',' read -r -a size_arr <<< "$SIZES"
for raw in "${size_arr[@]}"; do
size="$(trim "$raw")"
[[ -z "$size" ]] && continue
run_size "$size"
done
build_median_summary
compare_baseline
echo
echo "=== Median Summary ==="
cat "$MEDIAN_CSV"
if [[ -n "$BASELINE_CSV" ]]; then
echo
echo "=== Baseline Compare ==="
cat "$COMPARE_CSV"
fi
}
main "$@"