Files
rustfs/crates/targets/src/plugin.rs
T
Zhengchao An 8ddbf05924 test(targets,notify,audit): share a builder MockTarget testkit (#6717)
* test(targets): ship a builder MockTarget testkit and retire the in-crate Target mocks

Adds crates/targets/src/testkit.rs with a builder-style MockTarget implementing Target<E> for every E: PluginEvent, with orthogonal off-by-default knobs: disabled/active override, health delay plus health-started signal plus a drop-guard counter proving a cancelled probe future was dropped, an init failure budget (usize::MAX = always fail) plus blocking init plus an init counter, a close counter/signal/semaphore gate with a runtime block toggle, a save counter plus save failure budget, caller-supplied store and failed-store handles, and a shared final-failure counter. Clones and clone_dyn share all counters, so an observer clone keeps watching a target after it is boxed into a runtime.

The module is gated as #[cfg(any(test, feature = "test-support"))]: in-crate unit tests get it via cfg(test), and downstream test suites opt in through the new off-by-default test-support cargo feature (test-support = [], activating no dependencies).

Migrates the five in-crate duplicate mocks onto it: the plugin.rs registry-factory TestTarget, the runtime/adapter.rs lifecycle TestTarget (init/close/store knobs), the runtime/mod.rs TestTarget plus HealthDropGuard (close gating and health-probe tests), the target/mod.rs MoveTestTarget (folded into the test_support helper constructors used by the NATS JetStream failed-store tests), and the target/mod.rs StoreBackedTarget (the default send_from_store purge test; the mock deliberately does not override send_from_store or handle_terminal_failure). The forced init failure now uses TargetError::Initialization instead of the old adapter mock's Configuration; the adapter derives its redacted failure summary from the target id alone, so the migrated assertions are unchanged.

Leak guard: testkit unit tests assert the crate manifest still declares default = [] and that test-support = [] stays a pure cfg gate, complementing the compile-level cfg gate that keeps the mock out of production builds.

Part of rustfs/backlog#1846 (cluster 3, step 1).

* test(notify,audit): migrate the Target mocks onto the shared testkit

Retires the hand-written Target mocks in crates/notify and crates/audit in favor of rustfs_targets::testkit::MockTarget: notify's notifier.rs TestTarget/DeferredTestTarget/ClosableTestTarget, lifecycle.rs BlockingInitTarget/RetryInitTarget (rebuilt as observed MockTarget templates cloned by their plugin-descriptor factories, sharing the init signal, close counter, and single-failure init budget across generations), runtime_view.rs TestTarget, runtime_facade.rs TestTarget, and audit's pipeline.rs MockTarget, system.rs TestTarget, registry.rs CloseTestTarget, plus TestTarget and FailingTarget in audit/tests/pipeline_layer_test.rs. Removing the two integration-test mocks also removes their respelled PluginEvent bound (E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned), which the plugin-contract rules require to be spelled only via PluginEvent.

lifecycle.rs ReplayTarget stays bespoke on purpose: its generation tags, mpsc observation channels, gated send_raw delivery, and ObservedQueueStore model the replay pipeline itself and would contort a general-purpose mock. The other bespoke mocks named out of scope in PR-3a (ProgrammedTarget, ClassifyingTarget, the ReloadableTargetTls fakes) are likewise untouched.

New testkit knobs, each defaulted off and unit-tested: with_id (rename a clone while keeping the shared counters, for factory templates), with_first_save_gate (the first save notifies entered and waits on release; several mocks may share one pair), with_health_gate (is_active waits on a release handle after notifying health_started), with_delivery_snapshot (fixed snapshot overriding the store-derived default), with_close_failures (close-failure budget, default TargetError::Storage) with with_close_failure_error to shape the variant (audit's registry test pins TargetError::Unknown), and an always-on is_enabled call counter exposed as enabled_call_count (notifier's generation tests count dispatcher selections through it).

Both crates enable the testkit through a dev-dependency on rustfs-targets with the test-support feature; the feature stays out of default and activates no dependencies, so production builds are unchanged.

Part of rustfs/backlog#1846 (cluster 3, step 2).
2026-08-27 11:28:16 +08:00

573 lines
20 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{
PluginRuntimeAdapter, RuntimeActivation, Target, TargetError,
config::{collect_target_config_results, redact_error_detail_with_config},
manifest::{TargetPluginManifest, builtin_target_manifest},
target::with_deferred_queue_store_open,
};
use hashbrown::HashMap;
use rustfs_config::server_config::{Config, KVS};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
use std::sync::Arc;
use tracing::{error, info, warn};
type BoxedTarget<E> = Box<dyn Target<E> + Send + Sync>;
type TargetCreateFn<E> = Arc<dyn Fn(String, &KVS) -> Result<BoxedTarget<E>, TargetError> + Send + Sync>;
type TargetValidateFn = Arc<dyn Fn(&KVS) -> Result<(), TargetError> + Send + Sync>;
/// Event payload contract shared by all target plugin machinery.
///
/// Blanket-implemented for every type meeting the bounds; it exists solely to
/// keep this composite bound spelled in one place instead of on every generic.
pub trait PluginEvent: Send + Sync + Clone + Serialize + DeserializeOwned + 'static {}
impl<T> PluginEvent for T where T: Send + Sync + Clone + Serialize + DeserializeOwned + 'static {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetRequestValidator {
Webhook,
Mqtt,
Amqp(crate::target::TargetType),
Kafka(crate::target::TargetType),
MySql(crate::target::TargetType),
Nats(crate::target::TargetType),
Postgres(crate::target::TargetType),
Pulsar(crate::target::TargetType),
Redis {
default_channel: &'static str,
target_type: crate::target::TargetType,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetAdminMetadata {
subsystem: &'static str,
request_validator: TargetRequestValidator,
}
impl TargetAdminMetadata {
pub fn new(subsystem: &'static str, request_validator: TargetRequestValidator) -> Self {
Self {
subsystem,
request_validator,
}
}
#[inline]
pub fn subsystem(&self) -> &'static str {
self.subsystem
}
#[inline]
pub fn request_validator(&self) -> TargetRequestValidator {
self.request_validator
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinTargetAdminDescriptor {
manifest: TargetPluginManifest,
valid_fields: &'static [&'static str],
admin: TargetAdminMetadata,
}
impl BuiltinTargetAdminDescriptor {
pub fn new(manifest: TargetPluginManifest, valid_fields: &'static [&'static str], admin: TargetAdminMetadata) -> Self {
Self {
manifest,
valid_fields,
admin,
}
}
#[inline]
pub fn manifest(&self) -> &TargetPluginManifest {
&self.manifest
}
#[inline]
pub fn valid_fields(&self) -> &'static [&'static str] {
self.valid_fields
}
#[inline]
pub fn admin_metadata(&self) -> TargetAdminMetadata {
self.admin
}
}
#[derive(Clone)]
pub struct TargetPluginDescriptor<E>
where
E: PluginEvent,
{
create_target: TargetCreateFn<E>,
manifest: TargetPluginManifest,
target_type: &'static str,
valid_fields: &'static [&'static str],
valid_fields_set: Arc<HashSet<String>>,
validate_config: TargetValidateFn,
}
impl<E> TargetPluginDescriptor<E>
where
E: PluginEvent,
{
pub fn new<Create, Validate>(
target_type: &'static str,
valid_fields: &'static [&'static str],
validate_config: Validate,
create_target: Create,
) -> Self
where
Create: Fn(String, &KVS) -> Result<BoxedTarget<E>, TargetError> + Send + Sync + 'static,
Validate: Fn(&KVS) -> Result<(), TargetError> + Send + Sync + 'static,
{
Self::with_manifest(builtin_target_manifest(target_type), valid_fields, validate_config, create_target)
}
pub fn with_manifest<Create, Validate>(
manifest: TargetPluginManifest,
valid_fields: &'static [&'static str],
validate_config: Validate,
create_target: Create,
) -> Self
where
Create: Fn(String, &KVS) -> Result<BoxedTarget<E>, TargetError> + Send + Sync + 'static,
Validate: Fn(&KVS) -> Result<(), TargetError> + Send + Sync + 'static,
{
Self {
create_target: Arc::new(create_target),
manifest,
target_type: manifest.target_type,
valid_fields,
valid_fields_set: Arc::new(valid_fields.iter().map(|field| (*field).to_string()).collect()),
validate_config: Arc::new(validate_config),
}
}
#[inline]
pub fn target_type(&self) -> &'static str {
self.target_type
}
#[inline]
pub fn manifest(&self) -> &TargetPluginManifest {
&self.manifest
}
#[inline]
pub fn valid_fields(&self) -> &'static [&'static str] {
self.valid_fields
}
#[inline]
pub fn valid_fields_set(&self) -> &HashSet<String> {
self.valid_fields_set.as_ref()
}
#[inline]
pub fn validate_config(&self, config: &KVS) -> Result<(), TargetError> {
(self.validate_config)(config)
}
#[inline]
pub fn create_target(&self, id: String, config: &KVS) -> Result<BoxedTarget<E>, TargetError> {
(self.create_target)(id, config)
}
}
#[derive(Clone)]
pub struct BuiltinTargetDescriptor<E>
where
E: PluginEvent,
{
plugin: TargetPluginDescriptor<E>,
admin: TargetAdminMetadata,
}
impl<E> BuiltinTargetDescriptor<E>
where
E: PluginEvent,
{
pub fn new(subsystem: &'static str, request_validator: TargetRequestValidator, plugin: TargetPluginDescriptor<E>) -> Self {
Self {
plugin,
admin: TargetAdminMetadata::new(subsystem, request_validator),
}
}
#[inline]
pub fn plugin(&self) -> &TargetPluginDescriptor<E> {
&self.plugin
}
#[inline]
pub fn admin_metadata(&self) -> TargetAdminMetadata {
self.admin
}
#[inline]
pub fn request_validator(&self) -> TargetRequestValidator {
self.admin.request_validator()
}
#[inline]
pub fn subsystem(&self) -> &'static str {
self.admin.subsystem()
}
}
impl<E> From<BuiltinTargetDescriptor<E>> for BuiltinTargetAdminDescriptor
where
E: PluginEvent,
{
fn from(descriptor: BuiltinTargetDescriptor<E>) -> Self {
Self::new(
*descriptor.plugin().manifest(),
descriptor.plugin().valid_fields(),
descriptor.admin_metadata(),
)
}
}
pub struct TargetPluginRegistry<E>
where
E: PluginEvent,
{
plugins: HashMap<String, TargetPluginDescriptor<E>>,
}
impl<E> Default for TargetPluginRegistry<E>
where
E: PluginEvent,
{
fn default() -> Self {
Self::new()
}
}
impl<E> TargetPluginRegistry<E>
where
E: PluginEvent,
{
pub fn new() -> Self {
Self { plugins: HashMap::new() }
}
pub fn register(&mut self, plugin: TargetPluginDescriptor<E>) -> Option<TargetPluginDescriptor<E>> {
let replaced = self.plugins.insert(plugin.target_type().to_string(), plugin);
if let Some(previous) = &replaced {
warn!(
target_type = %previous.target_type(),
plugin_id = %previous.manifest().plugin_id,
"replacing previously registered target plugin descriptor"
);
}
replaced
}
pub fn register_all<I>(&mut self, plugins: I)
where
I: IntoIterator<Item = TargetPluginDescriptor<E>>,
{
for plugin in plugins {
self.register(plugin);
}
}
pub fn supports_target_type(&self, target_type: &str) -> bool {
self.plugins.contains_key(target_type)
}
pub fn registered_target_types(&self) -> Vec<String> {
self.plugins.keys().cloned().collect()
}
pub fn create_target(&self, target_type: &str, id: String, config: &KVS) -> Result<BoxedTarget<E>, TargetError> {
let plugin = self
.plugins
.get(target_type)
.ok_or_else(|| TargetError::Configuration(format!("Unknown target type: {target_type}")))?;
plugin.validate_config(config)?;
plugin.create_target(id, config)
}
/// Creates every enabled target instance found in `config`.
///
/// Creation is fault-isolated per instance: one broken target must not
/// prevent the remaining targets from activating, so failures are logged
/// and summarized instead of aborting the whole activation.
pub async fn create_targets_from_config(
&self,
config: &Config,
route_prefix: &str,
) -> Result<Vec<BoxedTarget<E>>, TargetError> {
self.create_targets_from_config_with_store_mode(config, route_prefix, false)
.await
.map(|(targets, _)| targets)
}
/// Creates targets while deferring queue-store open until runtime handoff.
/// Unlike the compatibility activation API, lifecycle preparation reports
/// any invalid or unconstructable configured instance so the originating
/// Admin request cannot report a false success.
pub async fn create_dormant_targets_from_config(
&self,
config: &Config,
route_prefix: &str,
) -> Result<(Vec<BoxedTarget<E>>, Vec<String>), TargetError> {
self.create_targets_from_config_with_store_mode(config, route_prefix, true)
.await
}
async fn create_targets_from_config_with_store_mode(
&self,
config: &Config,
route_prefix: &str,
defer_store_open: bool,
) -> Result<(Vec<BoxedTarget<E>>, Vec<String>), TargetError> {
let mut successful_targets = Vec::new();
let mut failures = Vec::new();
for (target_type, plugin) in &self.plugins {
info!(target_type = %target_type, "Start working on target type");
// Per-instance fault isolation: an invalid instance (e.g. an
// unparseable `enable` value) is recorded as a failure and skipped,
// never aborting the remaining instances or other target types.
let (collected, invalid_instances) =
collect_target_config_results(config, route_prefix, target_type, plugin.valid_fields_set());
for detail in invalid_instances {
error!(target_type = %target_type, reason = "invalid_config", detail = %detail, "Skipping target instance with invalid configuration");
failures.push(detail);
}
for (id, merged_config) in collected {
info!(target_type = %target_type, instance_id = %id, "Target is enabled, ready to create");
let created = if defer_store_open {
with_deferred_queue_store_open(|| self.create_target(target_type, id.clone(), &merged_config))
} else {
self.create_target(target_type, id.clone(), &merged_config)
};
match created {
Ok(target) => {
info!(target_type = %target.id().name, instance_id = %id, "Create target successfully");
successful_targets.push(target);
}
Err(err) => {
// The underlying error names the root cause (egress policy
// rejection, queue-store open failure, ...); scrub it against
// the instance config so credential-bearing values never
// reach the log or the Admin-visible failure summary.
let detail = redact_error_detail_with_config(&err.to_string(), &merged_config);
failures.push(format!("{target_type}/{id}: target construction failed: {detail}"));
error!(target_type = %target_type, instance_id = %id, reason = "construction_failed", detail = %detail, "Failed to create target");
}
}
}
}
if !failures.is_empty() {
warn!(
created = successful_targets.len(),
failed = failures.len(),
"Some configured targets failed to create and were skipped"
);
}
info!(
count = successful_targets.len(),
failed = failures.len(),
"All target processing completed"
);
Ok((successful_targets, failures))
}
pub async fn create_activation_from_config<A>(
&self,
config: &Config,
route_prefix: &str,
adapter: &A,
) -> Result<RuntimeActivation<E>, TargetError>
where
A: PluginRuntimeAdapter<E> + ?Sized,
{
let targets = self.create_targets_from_config(config, route_prefix).await?;
Ok(adapter.activate_with_replay(targets).await)
}
}
pub fn boxed_target<E, T>(target: T) -> BoxedTarget<E>
where
E: PluginEvent,
T: Target<E> + Send + Sync + 'static,
{
Box::new(target)
}
#[cfg(test)]
mod tests {
use super::{TargetPluginDescriptor, TargetPluginRegistry};
use crate::TargetError;
use crate::runtime::adapter::BuiltinPluginRuntimeAdapter;
use crate::testkit::MockTarget;
use rustfs_config::ENABLE_KEY;
use rustfs_config::server_config::{Config, KVS};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
fn builtin_adapter() -> BuiltinPluginRuntimeAdapter<String> {
BuiltinPluginRuntimeAdapter::new(
Arc::new(|_event| Box::pin(async {})),
Arc::new(|_target_id, _has_replay| {}),
None,
Duration::from_millis(10),
Duration::from_millis(10),
"stopping plugin registry test replay worker",
)
}
#[tokio::test]
async fn registry_creates_activation_from_config_via_runtime_adapter() {
let mut registry = TargetPluginRegistry::new();
registry.register(TargetPluginDescriptor::new(
"test",
&[ENABLE_KEY, "endpoint"],
|_config| Ok(()),
|id, _config| Ok(Box::new(MockTarget::new(&id, "test"))),
));
let mut cfg = Config(HashMap::new());
let mut section = HashMap::new();
let mut primary = KVS::new();
primary.insert(ENABLE_KEY.to_string(), "on".to_string());
primary.insert("endpoint".to_string(), "https://example.com/hook".to_string());
section.insert("primary".to_string(), primary);
cfg.0.insert("notify_test".to_string(), section);
let adapter = builtin_adapter();
let activation = registry
.create_activation_from_config(&cfg, "notify_", &adapter)
.await
.expect("activation should be created through runtime adapter");
assert_eq!(activation.targets.len(), 1);
assert_eq!(activation.targets[0].id().to_string(), "primary:test");
assert!(activation.replay_workers.is_empty());
}
// Regression: a single instance with a malformed `enable` value must not
// abort the remaining instances or unrelated target types. Before this fix
// the collector short-circuited the whole create path, so one typo took
// down every notify/audit target.
#[tokio::test]
async fn create_dormant_isolates_invalid_enable_and_still_loads_other_targets() {
let mut registry = TargetPluginRegistry::<String>::new();
for target_type in ["alpha", "beta"] {
registry.register(TargetPluginDescriptor::new(
target_type,
&[ENABLE_KEY, "endpoint"],
|_config| Ok(()),
move |id, _config| Ok(Box::new(MockTarget::new(&id, target_type))),
));
}
let mut cfg = Config(HashMap::new());
// alpha: one healthy instance plus one with a malformed `enable` value
// ("enable" is a typo -- EnableState accepts "enabled"/"on", not "enable").
let mut alpha = HashMap::new();
let mut alpha_good = KVS::new();
alpha_good.insert(ENABLE_KEY.to_string(), "on".to_string());
alpha_good.insert("endpoint".to_string(), "https://example.com/alpha".to_string());
alpha.insert("good".to_string(), alpha_good);
let mut alpha_bad = KVS::new();
alpha_bad.insert(ENABLE_KEY.to_string(), "enable".to_string());
alpha.insert("bad".to_string(), alpha_bad);
cfg.0.insert("notify_alpha".to_string(), alpha);
// beta: a healthy instance in a different target type must survive.
let mut beta = HashMap::new();
let mut beta_primary = KVS::new();
beta_primary.insert(ENABLE_KEY.to_string(), "on".to_string());
beta_primary.insert("endpoint".to_string(), "https://example.com/beta".to_string());
beta.insert("primary".to_string(), beta_primary);
cfg.0.insert("notify_beta".to_string(), beta);
let (targets, failures) = registry
.create_dormant_targets_from_config(&cfg, "notify_")
.await
.expect("a malformed instance must not abort target creation");
let mut created: Vec<String> = targets.iter().map(|target| target.id().to_string()).collect();
created.sort();
assert_eq!(created, vec!["good:alpha".to_string(), "primary:beta".to_string()]);
// The malformed instance is surfaced (so an Admin write can't report a
// false success) rather than silently dropped or fatally aborting.
assert_eq!(failures.len(), 1);
assert!(failures[0].contains("alpha/bad"), "unexpected failure summary: {}", failures[0]);
}
// Regression (#5115 debugging): a construction failure must carry the
// underlying error detail (e.g. an egress-policy rejection) in the failure
// summary instead of an opaque "target construction failed", while
// credential-bearing config values stay redacted.
#[tokio::test]
async fn construction_failure_surfaces_redacted_error_detail() {
let mut registry = TargetPluginRegistry::<String>::new();
registry.register(TargetPluginDescriptor::new(
"gamma",
&[ENABLE_KEY, "endpoint", "auth_token"],
|_config| Ok(()),
|_id, config| {
let endpoint = config.lookup("endpoint").unwrap_or_default();
let token = config.lookup("auth_token").unwrap_or_default();
Err(TargetError::Configuration(format!(
"webhook endpoint is not allowed: {endpoint} (auth_token {token})"
)))
},
));
let mut cfg = Config(HashMap::new());
let mut section = HashMap::new();
let mut primary = KVS::new();
primary.insert(ENABLE_KEY.to_string(), "on".to_string());
primary.insert("endpoint".to_string(), "https://example.com/private/hook?sig=hunter2".to_string());
primary.insert("auth_token".to_string(), "hook-secret-token".to_string());
section.insert("primary".to_string(), primary);
cfg.0.insert("notify_gamma".to_string(), section);
let (targets, failures) = registry
.create_dormant_targets_from_config(&cfg, "notify_")
.await
.expect("a failing instance must not abort target creation");
assert!(targets.is_empty());
assert_eq!(failures.len(), 1);
let failure = &failures[0];
assert!(failure.contains("gamma/primary"), "unexpected failure summary: {failure}");
// The root cause is surfaced instead of an opaque generic message.
assert!(failure.contains("webhook endpoint is not allowed"), "missing error detail: {failure}");
// The endpoint is reduced to its origin; path, query, and token are gone.
assert!(failure.contains("https://example.com"), "endpoint origin should stay visible: {failure}");
assert!(!failure.contains("/private/hook"), "endpoint path must be redacted: {failure}");
assert!(!failure.contains("hunter2"), "endpoint query must be redacted: {failure}");
assert!(!failure.contains("hook-secret-token"), "auth token must be redacted: {failure}");
}
}