refactor(targets): unify queue/connectivity handling and coverage (#2953)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: marshawcoco <marshawcoco@gmail.com>
This commit is contained in:
houseme
2026-05-14 12:31:23 +08:00
committed by GitHub
parent bdb98598d2
commit 81754d80b3
64 changed files with 9613 additions and 2800 deletions
+426
View File
@@ -0,0 +1,426 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::plugin::{
BuiltinTargetAdminDescriptor, BuiltinTargetDescriptor, TargetAdminMetadata, TargetPluginDescriptor, TargetRequestValidator,
boxed_target,
};
use crate::target::{ChannelTargetType, TargetType};
use crate::{Target, TargetError};
use rustfs_config::audit::{
AUDIT_AMQP_KEYS, AUDIT_KAFKA_KEYS, AUDIT_MQTT_KEYS, AUDIT_MYSQL_KEYS, AUDIT_NATS_KEYS, AUDIT_POSTGRES_KEYS,
AUDIT_PULSAR_KEYS, AUDIT_REDIS_DEFAULT_CHANNEL, AUDIT_REDIS_KEYS, AUDIT_WEBHOOK_KEYS,
};
use rustfs_config::notify::{
NOTIFY_AMQP_KEYS, NOTIFY_AMQP_SUB_SYS, NOTIFY_KAFKA_KEYS, NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS,
NOTIFY_MYSQL_KEYS, NOTIFY_MYSQL_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_POSTGRES_KEYS,
NOTIFY_POSTGRES_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_REDIS_DEFAULT_CHANNEL, NOTIFY_REDIS_KEYS,
NOTIFY_REDIS_SUB_SYS, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_config::{
AUDIT_DEFAULT_DIR, EVENT_DEFAULT_DIR,
audit::{
AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_MYSQL_SUB_SYS, AUDIT_NATS_SUB_SYS,
AUDIT_POSTGRES_SUB_SYS, AUDIT_PULSAR_SUB_SYS, AUDIT_REDIS_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS,
},
};
use rustfs_ecstore::config::KVS;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::config::{
build_amqp_args, build_kafka_args, build_mqtt_args, build_mysql_args, build_nats_args, build_postgres_args,
build_pulsar_args, build_redis_args, build_webhook_args, validate_amqp_config, validate_kafka_config, validate_mqtt_config,
validate_mysql_config, validate_nats_config, validate_postgres_config, validate_pulsar_config, validate_redis_config,
validate_webhook_config,
};
type BoxedTarget<E> = Box<dyn Target<E> + Send + Sync>;
fn build_descriptor<E, Create, Validate>(
subsystem: &'static str,
request_validator: TargetRequestValidator,
target_type: &'static str,
valid_fields: &'static [&'static str],
validate_config: Validate,
create_target: Create,
) -> BuiltinTargetDescriptor<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
Create: Fn(String, &KVS) -> Result<BoxedTarget<E>, TargetError> + Send + Sync + 'static,
Validate: Fn(&KVS) -> Result<(), TargetError> + Send + Sync + 'static,
{
BuiltinTargetDescriptor::new(
subsystem,
request_validator,
TargetPluginDescriptor::new(target_type, valid_fields, validate_config, create_target),
)
}
fn build_admin_descriptor(
subsystem: &'static str,
request_validator: TargetRequestValidator,
target_type: &'static str,
valid_fields: &'static [&'static str],
) -> BuiltinTargetAdminDescriptor {
BuiltinTargetAdminDescriptor::new(
crate::manifest::builtin_target_manifest(target_type),
valid_fields,
TargetAdminMetadata::new(subsystem, request_validator),
)
}
pub fn builtin_audit_target_admin_descriptors() -> Vec<BuiltinTargetAdminDescriptor> {
vec![
build_admin_descriptor(
AUDIT_AMQP_SUB_SYS,
TargetRequestValidator::Amqp(TargetType::AuditLog),
ChannelTargetType::Amqp.as_str(),
AUDIT_AMQP_KEYS,
),
build_admin_descriptor(
AUDIT_WEBHOOK_SUB_SYS,
TargetRequestValidator::Webhook,
ChannelTargetType::Webhook.as_str(),
AUDIT_WEBHOOK_KEYS,
),
build_admin_descriptor(
AUDIT_MQTT_SUB_SYS,
TargetRequestValidator::Mqtt,
ChannelTargetType::Mqtt.as_str(),
AUDIT_MQTT_KEYS,
),
build_admin_descriptor(
AUDIT_NATS_SUB_SYS,
TargetRequestValidator::Nats(TargetType::AuditLog),
ChannelTargetType::Nats.as_str(),
AUDIT_NATS_KEYS,
),
build_admin_descriptor(
AUDIT_PULSAR_SUB_SYS,
TargetRequestValidator::Pulsar(TargetType::AuditLog),
ChannelTargetType::Pulsar.as_str(),
AUDIT_PULSAR_KEYS,
),
build_admin_descriptor(
AUDIT_KAFKA_SUB_SYS,
TargetRequestValidator::Kafka(TargetType::AuditLog),
ChannelTargetType::Kafka.as_str(),
AUDIT_KAFKA_KEYS,
),
build_admin_descriptor(
AUDIT_REDIS_SUB_SYS,
TargetRequestValidator::Redis {
default_channel: AUDIT_REDIS_DEFAULT_CHANNEL,
target_type: TargetType::AuditLog,
},
ChannelTargetType::Redis.as_str(),
AUDIT_REDIS_KEYS,
),
build_admin_descriptor(
AUDIT_MYSQL_SUB_SYS,
TargetRequestValidator::MySql(TargetType::AuditLog),
ChannelTargetType::MySql.as_str(),
AUDIT_MYSQL_KEYS,
),
build_admin_descriptor(
AUDIT_POSTGRES_SUB_SYS,
TargetRequestValidator::Postgres(TargetType::AuditLog),
ChannelTargetType::Postgres.as_str(),
AUDIT_POSTGRES_KEYS,
),
]
}
pub fn builtin_audit_target_descriptors<E>() -> Vec<BuiltinTargetDescriptor<E>>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
vec![
build_descriptor(
AUDIT_AMQP_SUB_SYS,
TargetRequestValidator::Amqp(TargetType::AuditLog),
ChannelTargetType::Amqp.as_str(),
AUDIT_AMQP_KEYS,
|config| validate_amqp_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_amqp_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::amqp::AMQPTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_WEBHOOK_SUB_SYS,
TargetRequestValidator::Webhook,
ChannelTargetType::Webhook.as_str(),
AUDIT_WEBHOOK_KEYS,
|config| validate_webhook_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_webhook_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::webhook::WebhookTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_MQTT_SUB_SYS,
TargetRequestValidator::Mqtt,
ChannelTargetType::Mqtt.as_str(),
AUDIT_MQTT_KEYS,
validate_mqtt_config,
|id, config| {
let args = build_mqtt_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::mqtt::MQTTTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_NATS_SUB_SYS,
TargetRequestValidator::Nats(TargetType::AuditLog),
ChannelTargetType::Nats.as_str(),
AUDIT_NATS_KEYS,
|config| validate_nats_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_nats_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::nats::NATSTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_PULSAR_SUB_SYS,
TargetRequestValidator::Pulsar(TargetType::AuditLog),
ChannelTargetType::Pulsar.as_str(),
AUDIT_PULSAR_KEYS,
|config| validate_pulsar_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_pulsar_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::pulsar::PulsarTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_KAFKA_SUB_SYS,
TargetRequestValidator::Kafka(TargetType::AuditLog),
ChannelTargetType::Kafka.as_str(),
AUDIT_KAFKA_KEYS,
|config| validate_kafka_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_kafka_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::kafka::KafkaTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_REDIS_SUB_SYS,
TargetRequestValidator::Redis {
default_channel: AUDIT_REDIS_DEFAULT_CHANNEL,
target_type: TargetType::AuditLog,
},
ChannelTargetType::Redis.as_str(),
AUDIT_REDIS_KEYS,
|config| validate_redis_config(config, AUDIT_DEFAULT_DIR, AUDIT_REDIS_DEFAULT_CHANNEL),
|id, config| {
let args = build_redis_args(config, AUDIT_DEFAULT_DIR, AUDIT_REDIS_DEFAULT_CHANNEL, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::redis::RedisTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_MYSQL_SUB_SYS,
TargetRequestValidator::MySql(TargetType::AuditLog),
ChannelTargetType::MySql.as_str(),
AUDIT_MYSQL_KEYS,
|config| validate_mysql_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_mysql_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::mysql::MySqlTarget::<E>::new(id, args)?))
},
),
build_descriptor(
AUDIT_POSTGRES_SUB_SYS,
TargetRequestValidator::Postgres(TargetType::AuditLog),
ChannelTargetType::Postgres.as_str(),
AUDIT_POSTGRES_KEYS,
|config| validate_postgres_config(config, AUDIT_DEFAULT_DIR),
|id, config| {
let args = build_postgres_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
Ok(boxed_target(crate::target::postgres::PostgresTarget::<E>::new(id, args)?))
},
),
]
}
pub fn builtin_notify_target_admin_descriptors() -> Vec<BuiltinTargetAdminDescriptor> {
vec![
build_admin_descriptor(
NOTIFY_WEBHOOK_SUB_SYS,
TargetRequestValidator::Webhook,
ChannelTargetType::Webhook.as_str(),
NOTIFY_WEBHOOK_KEYS,
),
build_admin_descriptor(
NOTIFY_AMQP_SUB_SYS,
TargetRequestValidator::Amqp(TargetType::NotifyEvent),
ChannelTargetType::Amqp.as_str(),
NOTIFY_AMQP_KEYS,
),
build_admin_descriptor(
NOTIFY_KAFKA_SUB_SYS,
TargetRequestValidator::Kafka(TargetType::NotifyEvent),
ChannelTargetType::Kafka.as_str(),
NOTIFY_KAFKA_KEYS,
),
build_admin_descriptor(
NOTIFY_MQTT_SUB_SYS,
TargetRequestValidator::Mqtt,
ChannelTargetType::Mqtt.as_str(),
NOTIFY_MQTT_KEYS,
),
build_admin_descriptor(
NOTIFY_MYSQL_SUB_SYS,
TargetRequestValidator::MySql(TargetType::NotifyEvent),
ChannelTargetType::MySql.as_str(),
NOTIFY_MYSQL_KEYS,
),
build_admin_descriptor(
NOTIFY_NATS_SUB_SYS,
TargetRequestValidator::Nats(TargetType::NotifyEvent),
ChannelTargetType::Nats.as_str(),
NOTIFY_NATS_KEYS,
),
build_admin_descriptor(
NOTIFY_POSTGRES_SUB_SYS,
TargetRequestValidator::Postgres(TargetType::NotifyEvent),
ChannelTargetType::Postgres.as_str(),
NOTIFY_POSTGRES_KEYS,
),
build_admin_descriptor(
NOTIFY_REDIS_SUB_SYS,
TargetRequestValidator::Redis {
default_channel: NOTIFY_REDIS_DEFAULT_CHANNEL,
target_type: TargetType::NotifyEvent,
},
ChannelTargetType::Redis.as_str(),
NOTIFY_REDIS_KEYS,
),
build_admin_descriptor(
NOTIFY_PULSAR_SUB_SYS,
TargetRequestValidator::Pulsar(TargetType::NotifyEvent),
ChannelTargetType::Pulsar.as_str(),
NOTIFY_PULSAR_KEYS,
),
]
}
pub fn builtin_notify_target_descriptors<E>() -> Vec<BuiltinTargetDescriptor<E>>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
vec![
build_descriptor(
NOTIFY_WEBHOOK_SUB_SYS,
TargetRequestValidator::Webhook,
ChannelTargetType::Webhook.as_str(),
NOTIFY_WEBHOOK_KEYS,
|config| validate_webhook_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_webhook_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::webhook::WebhookTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_AMQP_SUB_SYS,
TargetRequestValidator::Amqp(TargetType::NotifyEvent),
ChannelTargetType::Amqp.as_str(),
NOTIFY_AMQP_KEYS,
|config| validate_amqp_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_amqp_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::amqp::AMQPTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_KAFKA_SUB_SYS,
TargetRequestValidator::Kafka(TargetType::NotifyEvent),
ChannelTargetType::Kafka.as_str(),
NOTIFY_KAFKA_KEYS,
|config| validate_kafka_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_kafka_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::kafka::KafkaTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_MQTT_SUB_SYS,
TargetRequestValidator::Mqtt,
ChannelTargetType::Mqtt.as_str(),
NOTIFY_MQTT_KEYS,
validate_mqtt_config,
|id, config| {
let args = build_mqtt_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::mqtt::MQTTTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_MYSQL_SUB_SYS,
TargetRequestValidator::MySql(TargetType::NotifyEvent),
ChannelTargetType::MySql.as_str(),
NOTIFY_MYSQL_KEYS,
|config| validate_mysql_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_mysql_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::mysql::MySqlTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_NATS_SUB_SYS,
TargetRequestValidator::Nats(TargetType::NotifyEvent),
ChannelTargetType::Nats.as_str(),
NOTIFY_NATS_KEYS,
|config| validate_nats_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_nats_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::nats::NATSTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_POSTGRES_SUB_SYS,
TargetRequestValidator::Postgres(TargetType::NotifyEvent),
ChannelTargetType::Postgres.as_str(),
NOTIFY_POSTGRES_KEYS,
|config| validate_postgres_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_postgres_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::postgres::PostgresTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_REDIS_SUB_SYS,
TargetRequestValidator::Redis {
default_channel: NOTIFY_REDIS_DEFAULT_CHANNEL,
target_type: TargetType::NotifyEvent,
},
ChannelTargetType::Redis.as_str(),
NOTIFY_REDIS_KEYS,
|config| validate_redis_config(config, EVENT_DEFAULT_DIR, NOTIFY_REDIS_DEFAULT_CHANNEL),
|id, config| {
let args = build_redis_args(config, EVENT_DEFAULT_DIR, NOTIFY_REDIS_DEFAULT_CHANNEL, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::redis::RedisTarget::<E>::new(id, args)?))
},
),
build_descriptor(
NOTIFY_PULSAR_SUB_SYS,
TargetRequestValidator::Pulsar(TargetType::NotifyEvent),
ChannelTargetType::Pulsar.as_str(),
NOTIFY_PULSAR_KEYS,
|config| validate_pulsar_config(config, EVENT_DEFAULT_DIR),
|id, config| {
let args = build_pulsar_args(config, EVENT_DEFAULT_DIR, TargetType::NotifyEvent)?;
Ok(boxed_target(crate::target::pulsar::PulsarTarget::<E>::new(id, args)?))
},
),
]
}
+105
View File
@@ -0,0 +1,105 @@
// 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 mod builtin;
use crate::control_plane::external_target_plugin_installation;
use crate::domain::TargetDomain;
use crate::manifest::{
TargetPluginArtifactManifest, TargetPluginDistributionManifest, TargetPluginEntrypointKind,
TargetPluginExternalRuntimeContract, TargetPluginManifest, TargetPluginMarketplaceManifest, TargetPluginRuntimeTransport,
installable_target_marketplace_manifest,
};
use crate::runtime::sidecar::SidecarPluginRuntime;
use crate::runtime::sidecar_protocol::{SIDECAR_RUNTIME_PROTOCOL_VERSION, SidecarHandshake, SidecarPluginCapability};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExampleInstallableTargetPlugin {
pub manifest: TargetPluginMarketplaceManifest,
pub installation: crate::TargetPluginInstallation,
pub runtime: SidecarPluginRuntime,
pub valid_fields: Vec<String>,
}
pub fn example_external_webhook_plugin() -> ExampleInstallableTargetPlugin {
let base = TargetPluginManifest {
plugin_id: "external:webhook-sidecar",
display_name: "Webhook Sidecar",
provider: "rustfs-labs",
version: "1.0.0",
target_type: "webhook",
supported_domains: &[TargetDomain::Notify],
secret_fields: &["auth_token"],
};
let manifest = installable_target_marketplace_manifest(
base,
TargetPluginEntrypointKind::Sidecar,
TargetPluginExternalRuntimeContract {
protocol_version: SIDECAR_RUNTIME_PROTOCOL_VERSION,
transport: TargetPluginRuntimeTransport::Grpc,
},
TargetPluginDistributionManifest {
artifacts: &[TargetPluginArtifactManifest {
artifact_id: "sidecar-linux-amd64",
target_triple: "x86_64-unknown-linux-gnu",
download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
digest_sha256: "0123456789abcdef0123456789abcdef",
size_bytes: 8192,
}],
},
);
let handshake = SidecarHandshake {
protocol_version: SIDECAR_RUNTIME_PROTOCOL_VERSION.to_string(),
plugin_id: base.plugin_id.to_string(),
plugin_version: base.version.to_string(),
supported_domains: vec![TargetDomain::Notify],
capabilities: vec![
SidecarPluginCapability::HealthCheck,
SidecarPluginCapability::SendEvent,
SidecarPluginCapability::Shutdown,
],
};
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", handshake);
runtime
.enable(base.plugin_id, TargetDomain::Notify)
.expect("example sidecar plugin handshake should validate");
ExampleInstallableTargetPlugin {
manifest,
installation: external_target_plugin_installation(
base.version,
"0123456789abcdef0123456789abcdef",
"sidecar-linux-amd64",
Some("2026-05-13T20:00:00Z".to_string()),
),
runtime,
valid_fields: vec!["endpoint".to_string(), "auth_token".to_string()],
}
}
#[cfg(test)]
mod tests {
use super::example_external_webhook_plugin;
#[test]
fn example_external_plugin_exposes_installation_and_runtime_metadata() {
let example = example_external_webhook_plugin();
assert_eq!(example.manifest.plugin_id, "external:webhook-sidecar");
assert_eq!(example.installation.install_state, crate::TargetPluginInstallState::Installed);
assert!(example.runtime.healthy);
assert_eq!(example.valid_fields, vec!["endpoint".to_string(), "auth_token".to_string()]);
}
}
+346
View File
@@ -0,0 +1,346 @@
// 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::loader::collect_merged_target_configs_from_env;
use crate::domain::TargetDomain;
use rustfs_ecstore::config::{Config, KVS};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginInstanceCompatDescriptor<'a> {
pub domain: TargetDomain,
pub plugin_id: &'a str,
pub target_type: &'a str,
pub subsystem: &'a str,
pub route_prefix: &'a str,
pub valid_fields: &'a [&'a str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetInstanceSourceClass {
Config,
Env,
Mixed,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TargetInstanceSourceHints {
pub has_file_default: bool,
pub has_file_instance: bool,
pub has_env_default: bool,
pub has_env_instance: bool,
}
impl TargetInstanceSourceHints {
#[inline]
pub fn has_config_source(self) -> bool {
self.has_file_default || self.has_file_instance
}
#[inline]
pub fn has_env_source(self) -> bool {
self.has_env_default || self.has_env_instance
}
#[inline]
pub fn classification(self) -> TargetInstanceSourceClass {
match (self.has_config_source(), self.has_env_source()) {
(true, true) => TargetInstanceSourceClass::Mixed,
(true, false) => TargetInstanceSourceClass::Config,
(false, true) => TargetInstanceSourceClass::Env,
(false, false) => TargetInstanceSourceClass::Config,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetPluginInstanceRecord {
pub domain: TargetDomain,
pub plugin_id: String,
pub target_type: String,
pub subsystem: String,
pub instance_id: String,
pub enabled: bool,
pub source_hints: TargetInstanceSourceHints,
pub effective_config: KVS,
}
pub type LegacyTargetInstanceDescriptor<'a> = TargetPluginInstanceCompatDescriptor<'a>;
pub type TargetPluginInstance = TargetPluginInstanceRecord;
pub fn normalize_target_plugin_instances(
config: &Config,
descriptor: &TargetPluginInstanceCompatDescriptor<'_>,
) -> Vec<TargetPluginInstanceRecord> {
normalize_target_plugin_instances_from_env(config, descriptor, std::env::vars())
}
pub fn normalize_target_plugin_instances_from_env<I>(
config: &Config,
descriptor: &TargetPluginInstanceCompatDescriptor<'_>,
env_vars: I,
) -> Vec<TargetPluginInstanceRecord>
where
I: IntoIterator<Item = (String, String)>,
{
let valid_fields = descriptor
.valid_fields
.iter()
.map(|field| (*field).to_string())
.collect::<HashSet<_>>();
collect_merged_target_configs_from_env(
config,
descriptor.subsystem,
descriptor.route_prefix,
descriptor.target_type,
&valid_fields,
env_vars,
)
.into_iter()
.map(|record| TargetPluginInstanceRecord {
domain: descriptor.domain,
plugin_id: descriptor.plugin_id.to_string(),
target_type: descriptor.target_type.to_string(),
subsystem: descriptor.subsystem.to_string(),
instance_id: record.instance_id,
enabled: record.enabled,
source_hints: TargetInstanceSourceHints {
has_file_default: record.has_file_default,
has_file_instance: record.has_file_instance,
has_env_default: record.has_env_default,
has_env_instance: record.has_env_instance,
},
effective_config: record.effective_config,
})
.collect()
}
pub fn normalize_legacy_target_instances(
config: &Config,
descriptor: &LegacyTargetInstanceDescriptor<'_>,
) -> Vec<TargetPluginInstance> {
normalize_target_plugin_instances(config, descriptor)
}
pub fn normalize_legacy_target_instances_from_env<I>(
config: &Config,
descriptor: &LegacyTargetInstanceDescriptor<'_>,
env_vars: I,
) -> Vec<TargetPluginInstance>
where
I: IntoIterator<Item = (String, String)>,
{
normalize_target_plugin_instances_from_env(config, descriptor, env_vars)
}
#[cfg(test)]
mod tests {
use super::{
TargetInstanceSourceClass, TargetPluginInstanceCompatDescriptor, normalize_legacy_target_instances_from_env,
normalize_target_plugin_instances_from_env,
};
use crate::domain::TargetDomain;
use crate::manifest::builtin_target_manifest;
use rustfs_config::audit::{AUDIT_ROUTE_PREFIX, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_ROUTE_PREFIX, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::{ENABLE_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_LIMIT};
use rustfs_ecstore::config::{Config, KVS};
use std::collections::HashMap;
fn notify_webhook_descriptor() -> TargetPluginInstanceCompatDescriptor<'static> {
TargetPluginInstanceCompatDescriptor {
domain: TargetDomain::Notify,
plugin_id: builtin_target_manifest("webhook").plugin_id,
target_type: "webhook",
subsystem: NOTIFY_WEBHOOK_SUB_SYS,
route_prefix: NOTIFY_ROUTE_PREFIX,
valid_fields: NOTIFY_WEBHOOK_KEYS,
}
}
fn audit_webhook_descriptor() -> TargetPluginInstanceCompatDescriptor<'static> {
TargetPluginInstanceCompatDescriptor {
domain: TargetDomain::Audit,
plugin_id: builtin_target_manifest("webhook").plugin_id,
target_type: "webhook",
subsystem: AUDIT_WEBHOOK_SUB_SYS,
route_prefix: AUDIT_ROUTE_PREFIX,
valid_fields: AUDIT_WEBHOOK_KEYS,
}
}
#[test]
fn normalize_notify_instances_merges_file_and_env_sources() {
let mut cfg = Config(HashMap::new());
let mut subsystem = HashMap::new();
let mut default_kvs = KVS::new();
default_kvs.insert(ENABLE_KEY.to_string(), "on".to_string());
default_kvs.insert(WEBHOOK_QUEUE_LIMIT.to_string(), "10".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);
cfg.0.insert(NOTIFY_WEBHOOK_SUB_SYS.to_string(), subsystem);
let instances = normalize_legacy_target_instances_from_env(
&cfg,
&notify_webhook_descriptor(),
vec![
("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT".to_string(), "42".to_string()),
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_SECONDARY".to_string(), "on".to_string()),
(
"RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_SECONDARY".to_string(),
"https://example.com/secondary".to_string(),
),
],
);
assert_eq!(instances.len(), 2);
let primary = instances
.iter()
.find(|instance| instance.instance_id == "primary")
.expect("primary notify instance should be normalized");
assert_eq!(primary.domain, TargetDomain::Notify);
assert_eq!(primary.plugin_id, "builtin:webhook");
assert!(primary.enabled);
assert_eq!(primary.effective_config.lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42"));
assert_eq!(
primary.effective_config.lookup(WEBHOOK_ENDPOINT).as_deref(),
Some("https://example.com/primary")
);
assert_eq!(primary.source_hints.classification(), TargetInstanceSourceClass::Mixed);
assert!(primary.source_hints.has_file_default);
assert!(primary.source_hints.has_file_instance);
assert!(primary.source_hints.has_env_default);
assert!(!primary.source_hints.has_env_instance);
let secondary = instances
.iter()
.find(|instance| instance.instance_id == "secondary")
.expect("secondary env notify instance should be normalized");
assert!(secondary.enabled);
assert_eq!(
secondary.effective_config.lookup(WEBHOOK_ENDPOINT).as_deref(),
Some("https://example.com/secondary")
);
assert_eq!(secondary.effective_config.lookup(WEBHOOK_QUEUE_LIMIT).as_deref(), Some("42"));
assert_eq!(secondary.source_hints.classification(), TargetInstanceSourceClass::Mixed);
assert!(secondary.source_hints.has_file_default);
assert!(!secondary.source_hints.has_file_instance);
assert!(secondary.source_hints.has_env_default);
assert!(secondary.source_hints.has_env_instance);
}
#[test]
fn normalize_audit_instances_preserves_domain_and_subsystem() {
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(ENABLE_KEY.to_string(), "on".to_string());
primary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/audit".to_string());
subsystem.insert("primary".to_string(), primary);
cfg.0.insert(AUDIT_WEBHOOK_SUB_SYS.to_string(), subsystem);
let instances = normalize_legacy_target_instances_from_env(&cfg, &audit_webhook_descriptor(), Vec::new());
assert_eq!(instances.len(), 1);
let primary = &instances[0];
assert_eq!(primary.domain, TargetDomain::Audit);
assert_eq!(primary.target_type, "webhook");
assert_eq!(primary.subsystem, AUDIT_WEBHOOK_SUB_SYS);
assert_eq!(primary.instance_id, "primary");
assert!(primary.enabled);
assert_eq!(
primary.effective_config.lookup(WEBHOOK_ENDPOINT).as_deref(),
Some("https://example.com/audit")
);
assert_eq!(primary.source_hints.classification(), TargetInstanceSourceClass::Config);
}
#[test]
fn normalize_instances_keeps_disabled_records() {
let cfg = Config(HashMap::new());
let instances = normalize_legacy_target_instances_from_env(
&cfg,
&notify_webhook_descriptor(),
vec![
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_DISABLED".to_string(), "off".to_string()),
(
"RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_DISABLED".to_string(),
"https://example.com/disabled".to_string(),
),
],
);
assert_eq!(instances.len(), 1);
let disabled = &instances[0];
assert_eq!(disabled.instance_id, "disabled");
assert!(!disabled.enabled);
assert_eq!(disabled.source_hints.classification(), TargetInstanceSourceClass::Env);
assert!(disabled.source_hints.has_env_instance);
}
#[test]
fn normalize_instances_excludes_default_only_entries() {
let mut cfg = Config(HashMap::new());
let mut subsystem = HashMap::new();
let mut default_kvs = KVS::new();
default_kvs.insert(ENABLE_KEY.to_string(), "on".to_string());
default_kvs.insert(WEBHOOK_QUEUE_LIMIT.to_string(), "99".to_string());
subsystem.insert("_".to_string(), default_kvs);
cfg.0.insert(NOTIFY_WEBHOOK_SUB_SYS.to_string(), subsystem);
let instances = normalize_legacy_target_instances_from_env(
&cfg,
&notify_webhook_descriptor(),
vec![("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT".to_string(), "100".to_string())],
);
assert!(instances.is_empty());
}
#[test]
fn compatibility_wrapper_matches_canonical_instance_model() {
let mut cfg = Config(HashMap::new());
let mut subsystem = HashMap::new();
let mut primary = KVS::new();
primary.insert(ENABLE_KEY.to_string(), "on".to_string());
primary.insert(WEBHOOK_ENDPOINT.to_string(), "https://example.com/primary".to_string());
subsystem.insert("primary".to_string(), primary);
cfg.0.insert(NOTIFY_WEBHOOK_SUB_SYS.to_string(), subsystem);
let descriptor = notify_webhook_descriptor();
let env = vec![("RUSTFS_NOTIFY_WEBHOOK_QUEUE_LIMIT".to_string(), "7".to_string())];
let canonical = normalize_target_plugin_instances_from_env(&cfg, &descriptor, env.clone());
let compatibility = normalize_legacy_target_instances_from_env(&cfg, &descriptor, env);
assert_eq!(canonical, compatibility);
}
}
+84 -20
View File
@@ -13,10 +13,9 @@
// 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_config::{DEFAULT_DELIMITER, ENV_PREFIX};
use rustfs_ecstore::config::{Config, KVS};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use tracing::{debug, warn};
pub fn collect_target_configs(
@@ -72,6 +71,17 @@ fn redacted_target_config(config: &KVS) -> Vec<(String, String)> {
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MergedTargetConfigRecord {
pub instance_id: String,
pub effective_config: KVS,
pub enabled: bool,
pub has_file_default: bool,
pub has_file_instance: bool,
pub has_env_default: bool,
pub has_env_instance: bool,
}
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())
}
@@ -113,25 +123,40 @@ pub fn collect_target_configs_from_env<I>(
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();
collect_merged_target_configs_from_env(
config,
&format!("{route_prefix}{target_type}").to_lowercase(),
route_prefix,
target_type,
valid_fields,
env_vars,
)
.into_iter()
.filter(|record| record.enabled)
.map(|record| (record.instance_id, record.effective_config))
.collect()
}
pub(crate) fn collect_merged_target_configs_from_env<I>(
config: &Config,
section_name: &str,
route_prefix: &str,
target_type: &str,
valid_fields: &HashSet<String>,
env_vars: I,
) -> Vec<MergedTargetConfigRecord>
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 file_configs = config.0.get(section_name).cloned().unwrap_or_default();
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
let has_file_default = file_configs.contains_key(DEFAULT_DELIMITER);
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;
};
@@ -158,6 +183,7 @@ where
}
let mut effective_default = default_cfg;
let has_env_default = env_overrides.contains_key(DEFAULT_DELIMITER);
if let Some(default_env_cfg) = env_overrides.remove(DEFAULT_DELIMITER) {
effective_default.extend(default_env_cfg);
}
@@ -167,16 +193,25 @@ where
.filter(|key| key.as_str() != DEFAULT_DELIMITER)
.cloned()
.collect();
all_instance_ids.extend(instance_ids_from_env);
all_instance_ids.extend(
env_overrides
.iter()
.filter(|(instance_id, env_cfg)| {
instance_id.as_str() != DEFAULT_DELIMITER && env_cfg.lookup(rustfs_config::ENABLE_KEY).is_some()
})
.map(|(instance_id, _)| instance_id.clone()),
);
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();
let has_file_instance = file_configs.contains_key(&id);
if let Some(file_instance_cfg) = file_configs.get(&id) {
merged_config.extend(file_instance_cfg.clone());
}
let has_env_instance = env_overrides.contains_key(&id);
if let Some(env_instance_cfg) = env_overrides.get(&id) {
merged_config.extend(env_instance_cfg.clone());
}
@@ -185,9 +220,15 @@ where
let redacted_config = redacted_target_config(&merged_config);
debug!(instance_id = %id, ?redacted_config, "Merged target configuration");
}
if is_target_enabled(&merged_config) {
merged_configs.push((id, merged_config));
}
merged_configs.push(MergedTargetConfigRecord {
instance_id: id,
enabled: is_target_enabled(&merged_config),
effective_config: merged_config,
has_file_default,
has_file_instance,
has_env_default,
has_env_instance,
});
}
merged_configs
@@ -273,6 +314,29 @@ mod tests {
assert_eq!(configs[0].1.lookup(WEBHOOK_ENDPOINT).as_deref(), Some("https://example.com/from-env"));
}
#[test]
fn collect_target_configs_does_not_materialize_env_only_instance_without_enable_flag() {
let mut cfg = Config(HashMap::new());
let mut subsystem = HashMap::new();
let mut default_kvs = KVS::new();
default_kvs.insert(ENABLE_KEY.to_string(), "on".to_string());
subsystem.insert("_".to_string(), default_kvs);
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()]),
vec![(
"RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_SECONDARY".to_string(),
"https://example.com/secondary".to_string(),
)],
);
assert!(configs.is_empty());
}
#[test]
fn collect_env_target_instance_ids_handles_keys_with_internal_underscores() {
let ids = collect_env_target_instance_ids_from_env(
+6
View File
@@ -13,9 +13,15 @@
// limitations under the License.
mod common;
mod instance;
mod loader;
mod target_args;
pub use instance::{
LegacyTargetInstanceDescriptor, TargetInstanceSourceClass, TargetInstanceSourceHints, TargetPluginInstance,
TargetPluginInstanceCompatDescriptor, TargetPluginInstanceRecord, normalize_legacy_target_instances,
normalize_legacy_target_instances_from_env, normalize_target_plugin_instances, normalize_target_plugin_instances_from_env,
};
pub use loader::{
collect_env_target_instance_ids, collect_env_target_instance_ids_from_env, collect_target_configs,
collect_target_configs_from_env,
+424
View File
@@ -0,0 +1,424 @@
// 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::manifest::{
TargetPluginDistributionManifest, TargetPluginExternalRuntimeContract, TargetPluginManifest, TargetPluginRuntimeTransport,
};
use crate::runtime::sidecar_protocol::SIDECAR_RUNTIME_PROTOCOL_VERSION;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetPluginInstallState {
NotInstalled,
Installed,
InstallFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetPluginEnableState {
Enabled,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetPluginRuntimeState {
Running,
Offline,
Error,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TargetPluginRevision {
pub version: String,
pub digest_sha256: Option<String>,
pub source: String,
pub installed_at: Option<String>,
pub artifact_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TargetPluginInstallation {
pub install_state: TargetPluginInstallState,
pub current_revision: Option<TargetPluginRevision>,
pub previous_revision: Option<TargetPluginRevision>,
pub validation_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TargetPluginOperationalState {
pub install_state: TargetPluginInstallState,
pub enable_state: TargetPluginEnableState,
pub runtime_state: TargetPluginRuntimeState,
}
pub fn builtin_target_plugin_installation(manifest: &TargetPluginManifest) -> TargetPluginInstallation {
TargetPluginInstallation {
install_state: TargetPluginInstallState::Installed,
current_revision: Some(TargetPluginRevision {
version: manifest.version.to_string(),
digest_sha256: None,
source: "builtin".to_string(),
installed_at: None,
artifact_id: None,
}),
previous_revision: None,
validation_error: None,
}
}
pub fn external_target_plugin_installation(
version: impl Into<String>,
digest_sha256: impl Into<String>,
artifact_id: impl Into<String>,
installed_at: Option<String>,
) -> TargetPluginInstallation {
TargetPluginInstallation {
install_state: TargetPluginInstallState::Installed,
current_revision: Some(TargetPluginRevision {
version: version.into(),
digest_sha256: Some(digest_sha256.into()),
source: "external".to_string(),
installed_at,
artifact_id: Some(artifact_id.into()),
}),
previous_revision: None,
validation_error: None,
}
}
pub fn failed_external_target_plugin_installation(
version: impl Into<String>,
artifact_id: impl Into<String>,
validation_error: impl Into<String>,
) -> TargetPluginInstallation {
TargetPluginInstallation {
install_state: TargetPluginInstallState::InstallFailed,
current_revision: Some(TargetPluginRevision {
version: version.into(),
digest_sha256: None,
source: "external".to_string(),
installed_at: None,
artifact_id: Some(artifact_id.into()),
}),
previous_revision: None,
validation_error: Some(validation_error.into()),
}
}
pub fn rollback_target_plugin_installation(
current: TargetPluginRevision,
previous: TargetPluginRevision,
) -> TargetPluginInstallation {
TargetPluginInstallation {
install_state: TargetPluginInstallState::Installed,
current_revision: Some(previous),
previous_revision: Some(current),
validation_error: None,
}
}
pub fn builtin_target_plugin_operational_state(
enabled: bool,
runtime_state: TargetPluginRuntimeState,
) -> TargetPluginOperationalState {
TargetPluginOperationalState {
install_state: TargetPluginInstallState::Installed,
enable_state: if enabled {
TargetPluginEnableState::Enabled
} else {
TargetPluginEnableState::Disabled
},
runtime_state,
}
}
pub fn runtime_state_from_status_label(status: &str) -> TargetPluginRuntimeState {
if status.eq_ignore_ascii_case("online") {
TargetPluginRuntimeState::Running
} else if status.eq_ignore_ascii_case("offline") {
TargetPluginRuntimeState::Offline
} else if status.eq_ignore_ascii_case("error") {
TargetPluginRuntimeState::Error
} else {
TargetPluginRuntimeState::Unknown
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetPluginInstallPolicy {
pub allowed_providers: Vec<String>,
pub allowed_download_hosts: Vec<String>,
pub require_https: bool,
pub require_signature: bool,
}
impl Default for TargetPluginInstallPolicy {
fn default() -> Self {
Self {
allowed_providers: vec!["rustfs".to_string(), "rustfs-labs".to_string()],
allowed_download_hosts: vec!["plugins.example.test".to_string()],
require_https: true,
require_signature: false,
}
}
}
pub fn validate_external_plugin_installation(
manifest: &TargetPluginManifest,
runtime_contract: &TargetPluginExternalRuntimeContract,
distribution: Option<TargetPluginDistributionManifest>,
policy: &TargetPluginInstallPolicy,
) -> Result<(), String> {
if !policy.allowed_providers.iter().any(|provider| provider == manifest.provider) {
return Err(format!("provider {} is not allowed by install policy", manifest.provider));
}
if runtime_contract.transport == TargetPluginRuntimeTransport::Grpc
&& runtime_contract.protocol_version != SIDECAR_RUNTIME_PROTOCOL_VERSION
{
return Err(format!(
"sidecar runtime protocol mismatch: expected {}, got {}",
SIDECAR_RUNTIME_PROTOCOL_VERSION, runtime_contract.protocol_version
));
}
if policy.require_signature {
return Err(
"signature verification is required by install policy but manifests do not expose signatures yet".to_string(),
);
}
let distribution = distribution.ok_or_else(|| "external plugin is missing distribution metadata".to_string())?;
if distribution.artifacts.is_empty() {
return Err("external plugin distribution has no artifacts".to_string());
}
for artifact in distribution.artifacts {
let parsed_uri = Url::parse(artifact.download_uri)
.map_err(|err| format!("invalid artifact download uri {}: {}", artifact.download_uri, err))?;
if policy.require_https && parsed_uri.scheme() != "https" {
return Err(format!(
"artifact {} must use https download uri, got {}",
artifact.artifact_id, artifact.download_uri
));
}
let host = parsed_uri
.host_str()
.ok_or_else(|| format!("artifact {} download uri has no host", artifact.artifact_id))?;
if !policy.allowed_download_hosts.iter().any(|allowed| allowed == host) {
return Err(format!("artifact {} download host {} is not allowed", artifact.artifact_id, host));
}
if artifact.size_bytes == 0 {
return Err(format!("artifact {} must declare a non-zero size", artifact.artifact_id));
}
if artifact.digest_sha256.len() < 16 || !artifact.digest_sha256.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(format!(
"artifact {} has invalid digest_sha256 {}",
artifact.artifact_id, artifact.digest_sha256
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
TargetPluginEnableState, TargetPluginInstallPolicy, TargetPluginInstallState, TargetPluginRevision,
TargetPluginRuntimeState, builtin_target_plugin_installation, builtin_target_plugin_operational_state,
external_target_plugin_installation, failed_external_target_plugin_installation, rollback_target_plugin_installation,
runtime_state_from_status_label, validate_external_plugin_installation,
};
use crate::manifest::{
TargetPluginArtifactManifest, TargetPluginDistributionManifest, TargetPluginExternalRuntimeContract,
TargetPluginManifest, TargetPluginRuntimeTransport, builtin_target_manifest,
};
#[test]
fn builtin_installation_maps_to_virtual_installed_revision() {
let installation = builtin_target_plugin_installation(&builtin_target_manifest("webhook"));
assert_eq!(installation.install_state, TargetPluginInstallState::Installed);
assert_eq!(
installation
.current_revision
.as_ref()
.expect("builtin installation should expose current revision")
.source,
"builtin"
);
assert_eq!(
installation
.current_revision
.as_ref()
.expect("builtin installation should expose current revision")
.artifact_id,
None
);
assert!(installation.previous_revision.is_none());
assert_eq!(installation.validation_error, None);
}
#[test]
fn builtin_operational_state_tracks_enablement_and_runtime() {
let enabled = builtin_target_plugin_operational_state(true, TargetPluginRuntimeState::Running);
let disabled = builtin_target_plugin_operational_state(false, TargetPluginRuntimeState::Offline);
assert_eq!(enabled.install_state, TargetPluginInstallState::Installed);
assert_eq!(enabled.enable_state, TargetPluginEnableState::Enabled);
assert_eq!(enabled.runtime_state, TargetPluginRuntimeState::Running);
assert_eq!(disabled.enable_state, TargetPluginEnableState::Disabled);
assert_eq!(disabled.runtime_state, TargetPluginRuntimeState::Offline);
}
#[test]
fn runtime_state_from_status_maps_known_labels() {
assert_eq!(runtime_state_from_status_label("online"), TargetPluginRuntimeState::Running);
assert_eq!(runtime_state_from_status_label("offline"), TargetPluginRuntimeState::Offline);
assert_eq!(runtime_state_from_status_label("error"), TargetPluginRuntimeState::Error);
assert_eq!(runtime_state_from_status_label("unexpected"), TargetPluginRuntimeState::Unknown);
}
#[test]
fn external_installation_captures_revision_metadata() {
let installation = external_target_plugin_installation(
"1.2.3",
"0123456789abcdef",
"sidecar-linux-amd64",
Some("2026-05-13T12:00:00Z".to_string()),
);
let revision = installation
.current_revision
.as_ref()
.expect("external installation should expose current revision");
assert_eq!(installation.install_state, TargetPluginInstallState::Installed);
assert_eq!(revision.source, "external");
assert_eq!(revision.digest_sha256.as_deref(), Some("0123456789abcdef"));
assert_eq!(revision.artifact_id.as_deref(), Some("sidecar-linux-amd64"));
assert_eq!(installation.validation_error, None);
}
#[test]
fn rollback_swaps_current_and_previous_revisions() {
let current = TargetPluginRevision {
version: "2.0.0".to_string(),
digest_sha256: Some("new-digest".to_string()),
source: "external".to_string(),
installed_at: Some("2026-05-13T12:05:00Z".to_string()),
artifact_id: Some("sidecar-linux-amd64-v2".to_string()),
};
let previous = TargetPluginRevision {
version: "1.9.0".to_string(),
digest_sha256: Some("old-digest".to_string()),
source: "external".to_string(),
installed_at: Some("2026-05-13T11:55:00Z".to_string()),
artifact_id: Some("sidecar-linux-amd64-v1".to_string()),
};
let installation = rollback_target_plugin_installation(current.clone(), previous.clone());
assert_eq!(installation.current_revision, Some(previous));
assert_eq!(installation.previous_revision, Some(current));
assert_eq!(installation.validation_error, None);
}
#[test]
fn failed_external_installation_preserves_error_context() {
let installation =
failed_external_target_plugin_installation("1.2.3", "sidecar-linux-amd64", "digest mismatch during install");
assert_eq!(installation.install_state, TargetPluginInstallState::InstallFailed);
assert_eq!(installation.validation_error.as_deref(), Some("digest mismatch during install"));
}
#[test]
fn validate_external_installation_accepts_allowed_https_artifact() {
let manifest = TargetPluginManifest {
plugin_id: "external:webhook-sidecar",
display_name: "Webhook Sidecar",
provider: "rustfs-labs",
version: "1.0.0",
target_type: "webhook",
supported_domains: &[],
secret_fields: &[],
};
let distribution = TargetPluginDistributionManifest {
artifacts: &[TargetPluginArtifactManifest {
artifact_id: "sidecar-linux-amd64",
target_triple: "x86_64-unknown-linux-gnu",
download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
digest_sha256: "0123456789abcdef0123456789abcdef",
size_bytes: 8192,
}],
};
let policy = TargetPluginInstallPolicy::default();
let result = validate_external_plugin_installation(
&manifest,
&TargetPluginExternalRuntimeContract {
protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
transport: TargetPluginRuntimeTransport::Grpc,
},
Some(distribution),
&policy,
);
assert!(result.is_ok());
}
#[test]
fn validate_external_installation_rejects_disallowed_provider() {
let manifest = TargetPluginManifest {
plugin_id: "external:webhook-sidecar",
display_name: "Webhook Sidecar",
provider: "unknown-vendor",
version: "1.0.0",
target_type: "webhook",
supported_domains: &[],
secret_fields: &[],
};
let policy = TargetPluginInstallPolicy::default();
let result = validate_external_plugin_installation(
&manifest,
&TargetPluginExternalRuntimeContract {
protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
transport: TargetPluginRuntimeTransport::Grpc,
},
Some(TargetPluginDistributionManifest {
artifacts: &[TargetPluginArtifactManifest {
artifact_id: "sidecar-linux-amd64",
target_triple: "x86_64-unknown-linux-gnu",
download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
digest_sha256: "0123456789abcdef0123456789abcdef",
size_bytes: 8192,
}],
}),
&policy,
);
assert!(result.is_err());
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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::target::TargetType;
use serde::{Deserialize, Serialize};
/// Logical target domains supported by RustFS target plugins.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetDomain {
Notify,
Audit,
}
impl TargetDomain {
#[inline]
pub fn runtime_target_type(self) -> TargetType {
match self {
TargetDomain::Notify => TargetType::NotifyEvent,
TargetDomain::Audit => TargetType::AuditLog,
}
}
}
impl From<TargetType> for TargetDomain {
fn from(value: TargetType) -> Self {
match value {
TargetType::NotifyEvent => TargetDomain::Notify,
TargetType::AuditLog => TargetDomain::Audit,
}
}
}
+34 -1
View File
@@ -13,10 +13,15 @@
// limitations under the License.
pub mod arn;
pub mod catalog;
mod check;
pub mod config;
pub mod control_plane;
pub mod domain;
pub mod error;
pub mod manifest;
pub mod plugin;
pub mod runtime;
pub mod store;
pub mod sys;
pub mod target;
@@ -26,8 +31,36 @@ pub use check::{
check_mysql_server_available, check_nats_server_available, check_postgres_server_available, check_pulsar_broker_available,
check_redis_server_available,
};
pub use config::{
LegacyTargetInstanceDescriptor, TargetInstanceSourceClass, TargetInstanceSourceHints, TargetPluginInstance,
TargetPluginInstanceCompatDescriptor, TargetPluginInstanceRecord, normalize_legacy_target_instances,
normalize_legacy_target_instances_from_env, normalize_target_plugin_instances, normalize_target_plugin_instances_from_env,
};
pub use control_plane::{
TargetPluginEnableState, TargetPluginInstallState, TargetPluginInstallation, TargetPluginOperationalState,
TargetPluginRevision, TargetPluginRuntimeState, builtin_target_plugin_installation, builtin_target_plugin_operational_state,
external_target_plugin_installation, rollback_target_plugin_installation, runtime_state_from_status_label,
};
pub use domain::TargetDomain;
pub use error::{StoreError, TargetError};
pub use plugin::{BuiltinTargetDescriptor, TargetPluginDescriptor, TargetPluginRegistry, TargetRequestValidator, boxed_target};
pub use manifest::{
TargetPluginArtifactManifest, TargetPluginDistributionManifest, TargetPluginEntrypointKind,
TargetPluginExternalRuntimeContract, TargetPluginManifest, TargetPluginMarketplaceManifest, TargetPluginPackaging,
TargetPluginRuntimeTransport, builtin_target_marketplace_manifest, installable_target_marketplace_manifest,
};
pub use plugin::{
BuiltinTargetAdminDescriptor, BuiltinTargetDescriptor, TargetAdminMetadata, TargetPluginDescriptor, TargetPluginRegistry,
TargetRequestValidator, boxed_target,
};
pub use runtime::{
ReplayEvent, ReplayWorkerManager, RuntimeActivation, RuntimeStatusSnapshot, RuntimeTargetHealthSnapshot,
RuntimeTargetHealthState, RuntimeTargetSnapshot, SharedTarget, TargetRuntimeManager, activate_targets_with_replay,
adapter::{BuiltinPluginRuntimeAdapter, PluginRuntimeAdapter},
init_target_and_optionally_start_replay,
sidecar::SidecarPluginRuntime,
sidecar_protocol::{SIDECAR_RUNTIME_PROTOCOL_VERSION, SidecarHandshake, SidecarPluginCapability},
start_replay_worker,
};
pub use rustfs_s3_common::EventName;
use serde::{Deserialize, Serialize};
pub use sys::user_agent::*;
+314
View File
@@ -0,0 +1,314 @@
// 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::domain::TargetDomain;
use rustfs_config::{
AMQP_PASSWORD, AMQP_TLS_CLIENT_CERT, AMQP_TLS_CLIENT_KEY, KAFKA_TLS_CLIENT_CERT, KAFKA_TLS_CLIENT_KEY, MQTT_PASSWORD,
MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MYSQL_DSN_STRING, MYSQL_TLS_CLIENT_CERT, MYSQL_TLS_CLIENT_KEY,
NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TOKEN, POSTGRES_DSN_STRING,
POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY, PULSAR_AUTH_TOKEN, PULSAR_PASSWORD, REDIS_PASSWORD, REDIS_TLS_CLIENT_CERT,
REDIS_TLS_CLIENT_KEY, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY,
};
/// Shared plugin manifest metadata for a target implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginManifest {
pub plugin_id: &'static str,
pub display_name: &'static str,
pub provider: &'static str,
pub version: &'static str,
pub target_type: &'static str,
pub supported_domains: &'static [TargetDomain],
pub secret_fields: &'static [&'static str],
}
/// Declares how a plugin is packaged relative to the RustFS process boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetPluginPackaging {
Builtin,
External,
}
/// Declares what kind of entrypoint a plugin would use when instantiated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetPluginEntrypointKind {
Builtin,
Sidecar,
Wasm,
}
/// Declares the transport boundary RustFS would use to communicate with a
/// plugin runtime without committing to any concrete loader implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetPluginRuntimeTransport {
InProcess,
Grpc,
WasmHost,
}
/// Declarative external runtime contract for future installable plugins.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginExternalRuntimeContract {
pub protocol_version: &'static str,
pub transport: TargetPluginRuntimeTransport,
}
/// Declarative distribution metadata for an installable target plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginArtifactManifest {
pub artifact_id: &'static str,
pub target_triple: &'static str,
pub download_uri: &'static str,
pub digest_sha256: &'static str,
pub size_bytes: u64,
}
/// Declarative distribution metadata for an installable target plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginDistributionManifest {
pub artifacts: &'static [TargetPluginArtifactManifest],
}
/// Marketplace-oriented manifest metadata that is explicit about future
/// installable plugin boundaries without introducing any loading behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TargetPluginMarketplaceManifest {
pub plugin_id: &'static str,
pub display_name: &'static str,
pub provider: &'static str,
pub version: &'static str,
pub target_type: &'static str,
pub supported_domains: &'static [TargetDomain],
pub secret_fields: &'static [&'static str],
pub packaging: TargetPluginPackaging,
pub entrypoint_kind: TargetPluginEntrypointKind,
pub api_compatibility_version: &'static str,
pub runtime_contract: TargetPluginExternalRuntimeContract,
pub distribution: Option<TargetPluginDistributionManifest>,
}
const BUILTIN_PLUGIN_API_COMPATIBILITY_VERSION: &str = "rustfs.target-plugin.v1";
const BUILTIN_PLUGIN_RUNTIME_PROTOCOL_VERSION: &str = "rustfs.target-runtime.v1";
const SUPPORTED_BUILTIN_DOMAINS: &[TargetDomain] = &[TargetDomain::Audit, TargetDomain::Notify];
const NO_SECRET_FIELDS: &[&str] = &[];
const WEBHOOK_SECRET_FIELDS: &[&str] = &[WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY];
const MQTT_SECRET_FIELDS: &[&str] = &[MQTT_PASSWORD, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY];
const KAFKA_SECRET_FIELDS: &[&str] = &[KAFKA_TLS_CLIENT_CERT, KAFKA_TLS_CLIENT_KEY];
const AMQP_SECRET_FIELDS: &[&str] = &[AMQP_PASSWORD, AMQP_TLS_CLIENT_CERT, AMQP_TLS_CLIENT_KEY];
const NATS_SECRET_FIELDS: &[&str] = &[
NATS_PASSWORD,
NATS_TOKEN,
NATS_CREDENTIALS_FILE,
NATS_TLS_CLIENT_CERT,
NATS_TLS_CLIENT_KEY,
];
const PULSAR_SECRET_FIELDS: &[&str] = &[PULSAR_AUTH_TOKEN, PULSAR_PASSWORD];
const MYSQL_SECRET_FIELDS: &[&str] = &[MYSQL_DSN_STRING, MYSQL_TLS_CLIENT_CERT, MYSQL_TLS_CLIENT_KEY];
const REDIS_SECRET_FIELDS: &[&str] = &[REDIS_PASSWORD, REDIS_TLS_CLIENT_CERT, REDIS_TLS_CLIENT_KEY];
const POSTGRES_SECRET_FIELDS: &[&str] = &[POSTGRES_DSN_STRING, POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY];
#[inline]
pub fn builtin_target_manifest(target_type: &'static str) -> TargetPluginManifest {
let (display_name, secret_fields) = match target_type {
"webhook" => ("Webhook", WEBHOOK_SECRET_FIELDS),
"mqtt" => ("MQTT", MQTT_SECRET_FIELDS),
"kafka" => ("Kafka", KAFKA_SECRET_FIELDS),
"amqp" => ("AMQP", AMQP_SECRET_FIELDS),
"nats" => ("NATS", NATS_SECRET_FIELDS),
"pulsar" => ("Pulsar", PULSAR_SECRET_FIELDS),
"mysql" => ("MySQL", MYSQL_SECRET_FIELDS),
"redis" => ("Redis", REDIS_SECRET_FIELDS),
"postgres" => ("Postgres", POSTGRES_SECRET_FIELDS),
_ => ("Custom Target", NO_SECRET_FIELDS),
};
TargetPluginManifest {
plugin_id: builtin_plugin_id(target_type),
display_name,
provider: "rustfs",
version: env!("CARGO_PKG_VERSION"),
target_type,
supported_domains: SUPPORTED_BUILTIN_DOMAINS,
secret_fields,
}
}
#[inline]
pub fn builtin_target_marketplace_manifest(target_type: &'static str) -> TargetPluginMarketplaceManifest {
TargetPluginMarketplaceManifest::from(builtin_target_manifest(target_type))
}
impl From<TargetPluginManifest> for TargetPluginMarketplaceManifest {
fn from(value: TargetPluginManifest) -> Self {
Self {
plugin_id: value.plugin_id,
display_name: value.display_name,
provider: value.provider,
version: value.version,
target_type: value.target_type,
supported_domains: value.supported_domains,
secret_fields: value.secret_fields,
packaging: TargetPluginPackaging::Builtin,
entrypoint_kind: TargetPluginEntrypointKind::Builtin,
api_compatibility_version: BUILTIN_PLUGIN_API_COMPATIBILITY_VERSION,
runtime_contract: TargetPluginExternalRuntimeContract {
protocol_version: BUILTIN_PLUGIN_RUNTIME_PROTOCOL_VERSION,
transport: TargetPluginRuntimeTransport::InProcess,
},
distribution: None,
}
}
}
#[inline]
pub fn installable_target_marketplace_manifest(
base: TargetPluginManifest,
entrypoint_kind: TargetPluginEntrypointKind,
runtime_contract: TargetPluginExternalRuntimeContract,
distribution: TargetPluginDistributionManifest,
) -> TargetPluginMarketplaceManifest {
TargetPluginMarketplaceManifest {
plugin_id: base.plugin_id,
display_name: base.display_name,
provider: base.provider,
version: base.version,
target_type: base.target_type,
supported_domains: base.supported_domains,
secret_fields: base.secret_fields,
packaging: TargetPluginPackaging::External,
entrypoint_kind,
api_compatibility_version: BUILTIN_PLUGIN_API_COMPATIBILITY_VERSION,
runtime_contract,
distribution: Some(distribution),
}
}
#[inline]
fn builtin_plugin_id(target_type: &'static str) -> &'static str {
match target_type {
"webhook" => "builtin:webhook",
"mqtt" => "builtin:mqtt",
"kafka" => "builtin:kafka",
"amqp" => "builtin:amqp",
"nats" => "builtin:nats",
"pulsar" => "builtin:pulsar",
"mysql" => "builtin:mysql",
"redis" => "builtin:redis",
"postgres" => "builtin:postgres",
_ => "custom:target",
}
}
#[cfg(test)]
mod tests {
use super::{
TargetPluginArtifactManifest, TargetPluginDistributionManifest, TargetPluginEntrypointKind,
TargetPluginExternalRuntimeContract, TargetPluginMarketplaceManifest, TargetPluginPackaging,
TargetPluginRuntimeTransport, builtin_target_manifest, builtin_target_marketplace_manifest,
installable_target_marketplace_manifest,
};
use crate::domain::TargetDomain;
use rustfs_config::{WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY};
#[test]
fn builtin_webhook_manifest_marks_secret_fields() {
let manifest = builtin_target_manifest("webhook");
assert_eq!(manifest.plugin_id, "builtin:webhook");
assert_eq!(manifest.display_name, "Webhook");
assert!(manifest.secret_fields.contains(&WEBHOOK_AUTH_TOKEN));
assert!(manifest.secret_fields.contains(&WEBHOOK_CLIENT_CERT));
assert!(manifest.secret_fields.contains(&WEBHOOK_CLIENT_KEY));
}
#[test]
fn builtin_manifest_derives_marketplace_boundary_metadata() {
let manifest = builtin_target_marketplace_manifest("webhook");
assert_eq!(manifest.plugin_id, "builtin:webhook");
assert_eq!(manifest.display_name, "Webhook");
assert_eq!(manifest.target_type, "webhook");
assert_eq!(manifest.packaging, TargetPluginPackaging::Builtin);
assert_eq!(manifest.entrypoint_kind, TargetPluginEntrypointKind::Builtin);
assert_eq!(manifest.api_compatibility_version, "rustfs.target-plugin.v1");
assert_eq!(
manifest.runtime_contract,
TargetPluginExternalRuntimeContract {
protocol_version: "rustfs.target-runtime.v1",
transport: TargetPluginRuntimeTransport::InProcess,
}
);
assert_eq!(manifest.distribution, None);
}
#[test]
fn marketplace_manifest_preserves_supported_domains() {
let manifest = builtin_target_marketplace_manifest("kafka");
assert_eq!(manifest.supported_domains, &[TargetDomain::Audit, TargetDomain::Notify]);
}
#[test]
fn marketplace_manifest_from_builtin_manifest_is_stable() {
let base = builtin_target_manifest("redis");
let derived = TargetPluginMarketplaceManifest::from(base);
assert_eq!(derived.plugin_id, "builtin:redis");
assert_eq!(derived.target_type, "redis");
assert_eq!(derived.packaging, TargetPluginPackaging::Builtin);
assert_eq!(derived.entrypoint_kind, TargetPluginEntrypointKind::Builtin);
assert_eq!(derived.runtime_contract.transport, TargetPluginRuntimeTransport::InProcess);
assert_eq!(derived.distribution, None);
}
#[test]
fn installable_manifest_expresses_external_boundary_declaratively() {
let base = builtin_target_manifest("webhook");
let manifest = installable_target_marketplace_manifest(
base,
TargetPluginEntrypointKind::Sidecar,
TargetPluginExternalRuntimeContract {
protocol_version: "rustfs.target-runtime.v1",
transport: TargetPluginRuntimeTransport::Grpc,
},
TargetPluginDistributionManifest {
artifacts: &[TargetPluginArtifactManifest {
artifact_id: "sidecar-linux-amd64",
target_triple: "x86_64-unknown-linux-gnu",
download_uri: "https://plugins.example.test/webhook-plugin.tar.zst",
digest_sha256: "0123456789abcdef",
size_bytes: 4096,
}],
},
);
assert_eq!(manifest.packaging, TargetPluginPackaging::External);
assert_eq!(manifest.entrypoint_kind, TargetPluginEntrypointKind::Sidecar);
assert_eq!(manifest.runtime_contract.transport, TargetPluginRuntimeTransport::Grpc);
assert_eq!(
manifest.distribution,
Some(TargetPluginDistributionManifest {
artifacts: &[TargetPluginArtifactManifest {
artifact_id: "sidecar-linux-amd64",
target_triple: "x86_64-unknown-linux-gnu",
download_uri: "https://plugins.example.test/webhook-plugin.tar.zst",
digest_sha256: "0123456789abcdef",
size_bytes: 4096,
}],
})
);
}
}
+221 -8
View File
@@ -12,7 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{Target, TargetError, config::collect_target_configs};
use crate::{
PluginRuntimeAdapter, RuntimeActivation, Target, TargetError,
config::collect_target_configs,
manifest::{TargetPluginManifest, builtin_target_manifest},
};
use hashbrown::HashMap;
use rustfs_ecstore::config::{Config, KVS};
use serde::Serialize;
@@ -41,12 +45,70 @@ pub enum TargetRequestValidator {
},
}
#[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: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
create_target: TargetCreateFn<E>,
manifest: TargetPluginManifest,
target_type: &'static str,
valid_fields: &'static [&'static str],
valid_fields_set: Arc<HashSet<String>>,
@@ -63,13 +125,27 @@ where
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),
target_type,
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),
@@ -81,6 +157,11 @@ where
self.target_type
}
#[inline]
pub fn manifest(&self) -> &TargetPluginManifest {
&self.manifest
}
#[inline]
pub fn valid_fields(&self) -> &'static [&'static str] {
self.valid_fields
@@ -108,8 +189,7 @@ where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
plugin: TargetPluginDescriptor<E>,
request_validator: TargetRequestValidator,
subsystem: &'static str,
admin: TargetAdminMetadata,
}
impl<E> BuiltinTargetDescriptor<E>
@@ -119,8 +199,7 @@ where
pub fn new(subsystem: &'static str, request_validator: TargetRequestValidator, plugin: TargetPluginDescriptor<E>) -> Self {
Self {
plugin,
request_validator,
subsystem,
admin: TargetAdminMetadata::new(subsystem, request_validator),
}
}
@@ -129,14 +208,32 @@ where
&self.plugin
}
#[inline]
pub fn admin_metadata(&self) -> TargetAdminMetadata {
self.admin
}
#[inline]
pub fn request_validator(&self) -> TargetRequestValidator {
self.request_validator
self.admin.request_validator()
}
#[inline]
pub fn subsystem(&self) -> &'static str {
self.subsystem
self.admin.subsystem()
}
}
impl<E> From<BuiltinTargetDescriptor<E>> for BuiltinTargetAdminDescriptor
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn from(descriptor: BuiltinTargetDescriptor<E>) -> Self {
Self::new(
*descriptor.plugin().manifest(),
descriptor.plugin().valid_fields(),
descriptor.admin_metadata(),
)
}
}
@@ -220,6 +317,19 @@ where
info!(count = successful_targets.len(), "All target processing completed");
Ok(successful_targets)
}
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>
@@ -229,3 +339,106 @@ where
{
Box::new(target)
}
#[cfg(test)]
mod tests {
use super::{TargetPluginDescriptor, TargetPluginRegistry};
use crate::runtime::adapter::BuiltinPluginRuntimeAdapter;
use crate::store::{Key, Store};
use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use crate::{StoreError, Target, TargetError};
use async_trait::async_trait;
use rustfs_config::ENABLE_KEY;
use rustfs_ecstore::config::{Config, KVS};
use serde::{Serialize, de::DeserializeOwned};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
struct TestTarget {
id: crate::arn::TargetID,
}
#[async_trait]
impl<E> Target<E> for TestTarget
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> crate::arn::TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
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(TestTarget {
id: crate::arn::TargetID::new(id, "test".to_string()),
}))
},
));
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());
}
}
+346
View File
@@ -0,0 +1,346 @@
// 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::{
ReplayEvent, ReplayWorkerManager, RuntimeActivation, RuntimeStatusSnapshot, RuntimeTargetHealthSnapshot,
TargetRuntimeManager, activate_targets_with_replay, init_target_and_optionally_start_replay, start_replay_worker,
};
use crate::{Target, TargetError};
use async_trait::async_trait;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
type ReplayHook<E> = Arc<dyn Fn(ReplayEvent<E>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
type ReplayStartObserver = Arc<dyn Fn(&str, bool) + Send + Sync>;
/// Shared runtime contract for target plugins.
#[async_trait]
pub trait PluginRuntimeAdapter<E>: Send + Sync
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
async fn activate_with_replay(&self, targets: Vec<Box<dyn Target<E> + Send + Sync>>) -> RuntimeActivation<E>;
async fn replace_runtime_targets(
&self,
runtime: &mut TargetRuntimeManager<E>,
replay_workers: &mut ReplayWorkerManager,
activation: RuntimeActivation<E>,
) -> Result<(), TargetError>;
async fn stop_replay_workers(&self, replay_workers: &mut ReplayWorkerManager);
fn snapshot_runtime_status(
&self,
runtime: &TargetRuntimeManager<E>,
replay_workers: &ReplayWorkerManager,
) -> RuntimeStatusSnapshot;
async fn snapshot_runtime_health(&self, runtime: &TargetRuntimeManager<E>) -> Vec<RuntimeTargetHealthSnapshot>;
async fn shutdown(
&self,
runtime: &mut TargetRuntimeManager<E>,
replay_workers: &mut ReplayWorkerManager,
) -> Result<(), TargetError>;
}
/// Built-in in-process runtime adapter that preserves the current replay and
/// activation behavior while presenting a stable runtime contract to callers.
#[derive(Clone)]
pub struct BuiltinPluginRuntimeAdapter<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
replay_hook: ReplayHook<E>,
replay_start_observer: ReplayStartObserver,
replay_semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
stop_log_prefix: Arc<str>,
}
impl<E> BuiltinPluginRuntimeAdapter<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
pub fn new(
replay_hook: ReplayHook<E>,
replay_start_observer: ReplayStartObserver,
replay_semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
stop_log_prefix: impl Into<Arc<str>>,
) -> Self {
Self {
replay_hook,
replay_start_observer,
replay_semaphore,
batch_timeout,
idle_sleep,
stop_log_prefix: stop_log_prefix.into(),
}
}
}
#[async_trait]
impl<E> PluginRuntimeAdapter<E> for BuiltinPluginRuntimeAdapter<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
async fn activate_with_replay(&self, targets: Vec<Box<dyn Target<E> + Send + Sync>>) -> RuntimeActivation<E> {
let replay_hook = Arc::clone(&self.replay_hook);
let replay_start_observer = Arc::clone(&self.replay_start_observer);
let replay_semaphore = self.replay_semaphore.clone();
let batch_timeout = self.batch_timeout;
let idle_sleep = self.idle_sleep;
activate_targets_with_replay(targets, move |target| {
let replay_hook = Arc::clone(&replay_hook);
let replay_start_observer = Arc::clone(&replay_start_observer);
let replay_semaphore = replay_semaphore.clone();
async move {
init_target_and_optionally_start_replay(
target,
move |target_id, has_replay| replay_start_observer(target_id, has_replay),
move |store, target| {
start_replay_worker(
store,
target,
Arc::clone(&replay_hook),
replay_semaphore.clone(),
batch_timeout,
idle_sleep,
)
},
)
.await
}
})
.await
}
async fn replace_runtime_targets(
&self,
runtime: &mut TargetRuntimeManager<E>,
replay_workers: &mut ReplayWorkerManager,
activation: RuntimeActivation<E>,
) -> Result<(), TargetError> {
self.stop_replay_workers(replay_workers).await;
runtime.clear_and_close().await;
for target in activation.targets {
runtime.add_arc(target);
}
*replay_workers = activation.replay_workers;
Ok(())
}
async fn stop_replay_workers(&self, replay_workers: &mut ReplayWorkerManager) {
replay_workers.stop_all(&self.stop_log_prefix).await;
}
fn snapshot_runtime_status(
&self,
runtime: &TargetRuntimeManager<E>,
replay_workers: &ReplayWorkerManager,
) -> RuntimeStatusSnapshot {
runtime.status_snapshot(replay_workers)
}
async fn snapshot_runtime_health(&self, runtime: &TargetRuntimeManager<E>) -> Vec<RuntimeTargetHealthSnapshot> {
runtime.health_snapshots().await
}
async fn shutdown(
&self,
runtime: &mut TargetRuntimeManager<E>,
replay_workers: &mut ReplayWorkerManager,
) -> Result<(), TargetError> {
self.stop_replay_workers(replay_workers).await;
runtime.clear_and_close().await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{BuiltinPluginRuntimeAdapter, PluginRuntimeAdapter};
use crate::arn::TargetID;
use crate::store::{Key, QueueStore, Store};
use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use crate::{StoreError, Target, TargetError};
use async_trait::async_trait;
use serde::{Serialize, de::DeserializeOwned};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tempfile::tempdir;
#[derive(Clone)]
struct TestTarget {
close_calls: Arc<AtomicUsize>,
id: TargetID,
init_fails: bool,
store: Option<QueueStore<QueuedPayload>>,
}
impl TestTarget {
fn new(id: &str, name: &str) -> Self {
Self {
close_calls: Arc::new(AtomicUsize::new(0)),
id: TargetID::new(id.to_string(), name.to_string()),
init_fails: false,
store: None,
}
}
fn with_failed_init(mut self) -> Self {
self.init_fails = true;
self
}
fn with_store(mut self) -> Self {
let dir = tempdir().expect("tempdir should be created for queue store tests");
let store = QueueStore::<QueuedPayload>::new(dir.path(), 16, ".queue");
store.open().expect("queue store should open");
self.store = Some(store);
self
}
}
#[async_trait]
impl<E> Target<E> for TestTarget
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
self.store
.as_ref()
.map(|store| store as &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync))
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
async fn init(&self) -> Result<(), TargetError> {
if self.init_fails {
return Err(TargetError::Configuration("forced init failure".to_string()));
}
Ok(())
}
fn is_enabled(&self) -> bool {
true
}
}
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 test replay worker",
)
}
#[tokio::test]
async fn builtin_adapter_handles_empty_target_activation() {
let adapter = builtin_adapter();
let activation = adapter.activate_with_replay(Vec::new()).await;
assert!(activation.targets.is_empty());
assert!(activation.replay_workers.is_empty());
}
#[tokio::test]
async fn builtin_adapter_skips_non_store_target_when_init_fails() {
let adapter = builtin_adapter();
let target = TestTarget::new("primary", "webhook").with_failed_init();
let activation = adapter.activate_with_replay(vec![Box::new(target)]).await;
assert!(activation.targets.is_empty());
assert!(activation.replay_workers.is_empty());
}
#[tokio::test]
async fn builtin_adapter_keeps_store_backed_target_when_init_fails() {
let adapter = builtin_adapter();
let target = TestTarget::new("primary", "webhook").with_failed_init().with_store();
let activation = adapter.activate_with_replay(vec![Box::new(target)]).await;
assert_eq!(activation.targets.len(), 1);
assert_eq!(activation.replay_workers.len(), 1);
}
#[tokio::test]
async fn builtin_adapter_shutdown_clears_runtime_and_replay_workers() {
let adapter = builtin_adapter();
let target = TestTarget::new("primary", "webhook");
let close_calls = Arc::clone(&target.close_calls);
let mut runtime = crate::runtime::TargetRuntimeManager::new();
let mut replay_workers = crate::runtime::ReplayWorkerManager::new();
let activation = adapter.activate_with_replay(vec![Box::new(target)]).await;
adapter
.replace_runtime_targets(&mut runtime, &mut replay_workers, activation)
.await
.expect("replace_runtime_targets should succeed");
assert_eq!(runtime.len(), 1);
assert_eq!(replay_workers.len(), 0);
adapter
.shutdown(&mut runtime, &mut replay_workers)
.await
.expect("shutdown should succeed");
assert!(runtime.is_empty());
assert!(replay_workers.is_empty());
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
}
}
+641
View File
@@ -0,0 +1,641 @@
// 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 mod adapter;
pub mod sidecar;
pub mod sidecar_protocol;
use crate::Target;
use crate::arn::TargetID;
use crate::store::{Key, Store, ensure_store_entry_raw_readable};
use crate::target::QueuedPayload;
use crate::target::TargetDeliverySnapshot;
use crate::{StoreError, TargetError};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::sync::Arc;
use std::{collections::HashMap, fmt::Debug};
use std::{future::Future, pin::Pin, time::Duration};
use tokio::sync::{Semaphore, mpsc};
/// Shared target trait object used by the runtime manager.
pub type SharedTarget<E> = Arc<dyn Target<E> + Send + Sync>;
type ReplayHook<E> = Arc<dyn Fn(ReplayEvent<E>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[derive(Debug, Default)]
pub struct ReplayWorkerManager {
cancellers: HashMap<String, mpsc::Sender<()>>,
}
impl ReplayWorkerManager {
pub fn new() -> Self {
Self {
cancellers: HashMap::new(),
}
}
pub fn insert(&mut self, target_id: String, cancel_tx: mpsc::Sender<()>) {
self.cancellers.insert(target_id, cancel_tx);
}
pub fn len(&self) -> usize {
self.cancellers.len()
}
pub fn is_empty(&self) -> bool {
self.cancellers.is_empty()
}
pub fn snapshot(&self, target_count: usize) -> RuntimeStatusSnapshot {
RuntimeStatusSnapshot {
replay_worker_count: self.len(),
target_count,
}
}
pub async fn stop_all(&mut self, log_prefix: &str) {
for (target_id, cancel_tx) in self.cancellers.drain() {
tracing::info!(target_id = %target_id, "{log_prefix}");
let _ = cancel_tx.send(()).await;
}
}
}
pub struct RuntimeActivation<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
pub replay_workers: ReplayWorkerManager,
pub targets: Vec<SharedTarget<E>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeStatusSnapshot {
pub replay_worker_count: usize,
pub target_count: usize,
}
/// A read-only runtime snapshot for a target instance.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeTargetSnapshot {
pub failed_messages: u64,
pub queue_length: u64,
pub target_id: String,
pub target_type: String,
pub total_messages: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeTargetHealthState {
Disabled,
Error,
Offline,
Online,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeTargetHealthSnapshot {
pub enabled: bool,
pub error_message: Option<String>,
pub state: RuntimeTargetHealthState,
pub target_id: String,
pub target_type: String,
}
pub enum ReplayEvent<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
Delivered {
key: Key,
target: SharedTarget<E>,
},
RetryableError {
error: TargetError,
key: Key,
retry_count: usize,
target: SharedTarget<E>,
},
Dropped {
key: Key,
reason: String,
target: SharedTarget<E>,
},
PermanentFailure {
error: TargetError,
key: Key,
target: SharedTarget<E>,
},
RetryExhausted {
key: Key,
target: SharedTarget<E>,
},
UnreadableEntry {
error: StoreError,
key: Key,
target: SharedTarget<E>,
},
}
/// Shared runtime container for managing instantiated targets.
///
/// This intentionally focuses on low-risk shared lifecycle primitives first:
/// add/remove/close/list/snapshot. Replay workers and reload orchestration can
/// be layered on top in later phases.
pub struct TargetRuntimeManager<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
targets: HashMap<String, SharedTarget<E>>,
}
impl<E> Default for TargetRuntimeManager<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn default() -> Self {
Self::new()
}
}
impl<E> Debug for TargetRuntimeManager<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TargetRuntimeManager")
.field("target_count", &self.targets.len())
.finish()
}
}
impl<E> TargetRuntimeManager<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
pub fn new() -> Self {
Self { targets: HashMap::new() }
}
pub fn add_arc(&mut self, target: SharedTarget<E>) -> Option<SharedTarget<E>> {
let key = target.id().to_string();
self.targets.insert(key, target)
}
pub fn add_boxed(&mut self, target: Box<dyn Target<E> + Send + Sync>) -> Option<SharedTarget<E>> {
self.add_arc(Arc::from(target))
}
pub fn get(&self, key: &str) -> Option<SharedTarget<E>> {
self.targets.get(key).cloned()
}
pub fn get_by_target_id(&self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.get(&target_id.to_string())
}
pub fn remove(&mut self, key: &str) -> Option<SharedTarget<E>> {
self.targets.remove(key)
}
pub fn remove_by_target_id(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.remove(&target_id.to_string())
}
pub fn clear(&mut self) {
self.targets.clear();
}
pub async fn remove_and_close(&mut self, key: &str) -> Option<SharedTarget<E>> {
let target = self.targets.remove(key)?;
if let Err(err) = target.close().await {
tracing::error!(target_id = %key, error = %err, "Failed to close target during removal");
}
Some(target)
}
pub async fn remove_by_target_id_and_close(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.remove_and_close(&target_id.to_string()).await
}
pub async fn clear_and_close(&mut self) {
let target_ids: Vec<String> = self.targets.keys().cloned().collect();
for target_id in target_ids {
let _ = self.remove_and_close(&target_id).await;
}
self.targets.clear();
}
pub fn target_ids(&self) -> Vec<TargetID> {
self.targets.values().map(|target| target.id()).collect()
}
pub fn keys(&self) -> Vec<String> {
self.targets.keys().cloned().collect()
}
pub fn values(&self) -> Vec<SharedTarget<E>> {
self.targets.values().cloned().collect()
}
pub fn len(&self) -> usize {
self.targets.len()
}
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
pub fn snapshots(&self) -> Vec<RuntimeTargetSnapshot> {
let mut snapshots = Vec::with_capacity(self.targets.len());
for target in self.targets.values() {
let delivery = target.delivery_snapshot();
let target_id = target.id();
snapshots.push(snapshot_from_delivery(target_id, delivery));
}
snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
snapshots
}
pub fn status_snapshot(&self, replay_workers: &ReplayWorkerManager) -> RuntimeStatusSnapshot {
replay_workers.snapshot(self.len())
}
pub async fn health_snapshots(&self) -> Vec<RuntimeTargetHealthSnapshot> {
let mut snapshots = Vec::with_capacity(self.targets.len());
for target in self.targets.values() {
let enabled = target.is_enabled();
let target_id = target.id();
let (state, error_message) = if !enabled {
(RuntimeTargetHealthState::Disabled, None)
} else {
match target.is_active().await {
Ok(true) => (RuntimeTargetHealthState::Online, None),
Ok(false) => (RuntimeTargetHealthState::Offline, None),
Err(err) => (RuntimeTargetHealthState::Error, Some(err.to_string())),
}
};
snapshots.push(RuntimeTargetHealthSnapshot {
enabled,
error_message,
state,
target_id: target_id.to_string(),
target_type: target_id.name,
});
}
snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
snapshots
}
}
fn snapshot_from_delivery(target_id: TargetID, delivery: TargetDeliverySnapshot) -> RuntimeTargetSnapshot {
RuntimeTargetSnapshot {
failed_messages: delivery.failed_messages,
queue_length: delivery.queue_length,
target_id: target_id.to_string(),
target_type: target_id.name,
total_messages: delivery.total_messages,
}
}
pub async fn init_target_and_optionally_start_replay<E, F, G>(
target: Box<dyn Target<E> + Send + Sync>,
on_replay_start: F,
start_replay: G,
) -> Option<(SharedTarget<E>, Option<mpsc::Sender<()>>)>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
F: FnOnce(&str, bool),
G: FnOnce(Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>, SharedTarget<E>) -> mpsc::Sender<()>,
{
let target_id = target.id().to_string();
let has_store = target.store().is_some();
if let Err(err) = target.init().await {
tracing::error!(target_id = %target_id, error = %err, "Failed to initialize target");
if !has_store {
return None;
}
tracing::warn!(
target_id = %target_id,
"Proceeding with store-backed target despite init failure"
);
}
let shared: SharedTarget<E> = Arc::from(target);
if !shared.is_enabled() {
on_replay_start(&target_id, false);
return Some((shared, None));
}
let cancel = shared
.store()
.map(|store| start_replay(store.boxed_clone(), Arc::clone(&shared)));
on_replay_start(&target_id, cancel.is_some());
Some((shared, cancel))
}
pub async fn activate_targets_with_replay<E, F, Fut>(
targets: Vec<Box<dyn Target<E> + Send + Sync>>,
mut activate_one: F,
) -> RuntimeActivation<E>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
F: FnMut(Box<dyn Target<E> + Send + Sync>) -> Fut,
Fut: Future<Output = Option<(SharedTarget<E>, Option<mpsc::Sender<()>>)>>,
{
let mut replay_workers = ReplayWorkerManager::new();
let mut shared_targets = Vec::new();
for target in targets {
if let Some((shared_target, cancel_tx)) = activate_one(target).await {
let target_id = shared_target.id().to_string();
if let Some(cancel_tx) = cancel_tx {
replay_workers.insert(target_id, cancel_tx);
}
shared_targets.push(shared_target);
}
}
RuntimeActivation {
replay_workers,
targets: shared_targets,
}
}
pub fn start_replay_worker<E>(
mut store: Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>,
target: SharedTarget<E>,
hook: ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
) -> mpsc::Sender<()>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
let (cancel_tx, cancel_rx) = mpsc::channel(1);
tokio::spawn(async move {
stream_replay_worker(&mut *store, target, cancel_rx, hook, semaphore, batch_timeout, idle_sleep).await;
});
cancel_tx
}
async fn stream_replay_worker<E>(
store: &mut (dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
target: SharedTarget<E>,
mut cancel_rx: mpsc::Receiver<()>,
hook: ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
) where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
const MAX_RETRIES: usize = 5;
const BASE_RETRY_DELAY: Duration = Duration::from_secs(2);
let mut batch_keys = Vec::with_capacity(1);
let mut last_flush = tokio::time::Instant::now();
loop {
if cancel_rx.try_recv().is_ok() {
return;
}
let keys = store.list();
if keys.is_empty() {
if !batch_keys.is_empty() && last_flush.elapsed() >= batch_timeout {
process_replay_batch(&mut batch_keys, target.clone(), &hook, semaphore.clone()).await;
last_flush = tokio::time::Instant::now();
}
tokio::time::sleep(idle_sleep).await;
continue;
}
for key in keys {
if cancel_rx.try_recv().is_ok() {
if !batch_keys.is_empty() {
process_replay_batch(&mut batch_keys, target.clone(), &hook, semaphore.clone()).await;
}
return;
}
match ensure_store_entry_raw_readable(&*store, &key) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
hook(ReplayEvent::UnreadableEntry {
error: err,
key,
target: target.clone(),
})
.await;
continue;
}
}
batch_keys.push(key);
if !batch_keys.is_empty() || last_flush.elapsed() >= batch_timeout {
process_replay_batch(&mut batch_keys, target.clone(), &hook, semaphore.clone()).await;
last_flush = tokio::time::Instant::now();
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
async fn process_replay_batch<E>(
batch_keys: &mut Vec<Key>,
target: SharedTarget<E>,
hook: &ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
) where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
if batch_keys.is_empty() {
return;
}
let _permit = match semaphore {
Some(ref semaphore) => match semaphore.clone().acquire_owned().await {
Ok(permit) => Some(permit),
Err(err) => {
tracing::error!(error = %err, "Failed to acquire replay semaphore permit");
return;
}
},
None => None,
};
for key in batch_keys.iter() {
let mut retry_count = 0usize;
let mut success = false;
while retry_count < MAX_RETRIES && !success {
match target.send_from_store(key.clone()).await {
Ok(_) => {
hook(ReplayEvent::Delivered {
key: key.clone(),
target: target.clone(),
})
.await;
success = true;
}
Err(err) => match err {
TargetError::NotConnected | TargetError::Timeout(_) => {
retry_count += 1;
hook(ReplayEvent::RetryableError {
error: err,
key: key.clone(),
retry_count,
target: target.clone(),
})
.await;
let jitter = Duration::from_millis(key.to_string().len() as u64 % 500);
let backoff = 1u32 << retry_count as u32;
tokio::time::sleep(BASE_RETRY_DELAY * backoff + jitter).await;
}
TargetError::Dropped(reason) => {
hook(ReplayEvent::Dropped {
key: key.clone(),
reason,
target: target.clone(),
})
.await;
break;
}
other => {
hook(ReplayEvent::PermanentFailure {
error: other,
key: key.clone(),
target: target.clone(),
})
.await;
break;
}
},
}
}
if retry_count >= MAX_RETRIES && !success {
hook(ReplayEvent::RetryExhausted {
key: key.clone(),
target: target.clone(),
})
.await;
}
}
batch_keys.clear();
}
}
#[cfg(test)]
mod tests {
use super::TargetRuntimeManager;
use crate::StoreError;
use crate::arn::TargetID;
use crate::store::{Key, Store};
use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use crate::{Target, TargetError};
use async_trait::async_trait;
use serde::{Serialize, de::DeserializeOwned};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct TestTarget {
id: TargetID,
close_calls: Arc<AtomicUsize>,
}
impl TestTarget {
fn new(id: &str, name: &str) -> Self {
Self {
id: TargetID::new(id.to_string(), name.to_string()),
close_calls: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl<E> Target<E> for TestTarget
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
#[tokio::test]
async fn runtime_manager_removes_and_closes_target() {
let mut manager = TargetRuntimeManager::<String>::new();
let target = TestTarget::new("primary", "webhook");
let close_calls = Arc::clone(&target.close_calls);
manager.add_boxed(Box::new(target));
assert_eq!(manager.len(), 1);
let removed = manager.remove_and_close("primary:webhook").await;
assert!(removed.is_some());
assert_eq!(manager.len(), 0);
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
}
#[test]
fn runtime_manager_snapshots_targets() {
let mut manager = TargetRuntimeManager::<String>::new();
manager.add_boxed(Box::new(TestTarget::new("primary", "webhook")));
let snapshots = manager.snapshots();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].target_id, "primary:webhook");
assert_eq!(snapshots[0].target_type, "webhook");
}
}
+171
View File
@@ -0,0 +1,171 @@
// 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::TargetDomain;
use crate::runtime::sidecar_protocol::SidecarHandshake;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const DEFAULT_FAILURE_THRESHOLD: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SidecarPluginRuntime {
pub endpoint: String,
pub handshake: SidecarHandshake,
pub healthy: bool,
pub failure_count: usize,
pub degraded_to_builtin: bool,
pub last_error: Option<String>,
}
impl SidecarPluginRuntime {
pub fn new(endpoint: impl Into<String>, handshake: SidecarHandshake) -> Self {
Self {
endpoint: endpoint.into(),
handshake,
healthy: false,
failure_count: 0,
degraded_to_builtin: false,
last_error: None,
}
}
pub fn enable(&mut self, expected_plugin_id: &str, required_domain: TargetDomain) -> Result<(), String> {
self.handshake.validate(expected_plugin_id)?;
if !self.handshake.supported_domains.contains(&required_domain) {
return Err(format!(
"sidecar plugin {} does not support required domain {:?}",
self.handshake.plugin_id, required_domain
));
}
self.healthy = true;
self.degraded_to_builtin = false;
self.last_error = None;
self.failure_count = 0;
Ok(())
}
pub fn mark_unhealthy(&mut self) {
self.healthy = false;
}
pub fn record_failure(&mut self, error: impl Into<String>) {
self.failure_count = self.failure_count.saturating_add(1);
self.healthy = false;
self.last_error = Some(error.into());
if self.failure_count >= DEFAULT_FAILURE_THRESHOLD {
self.degraded_to_builtin = true;
}
}
pub fn send_with_timeout(&mut self, operation_timeout: Duration, simulated_latency: Duration) -> Result<(), String> {
if simulated_latency > operation_timeout {
self.record_failure(format!(
"sidecar send timeout after {:?} (budget {:?})",
simulated_latency, operation_timeout
));
return Err(self
.last_error
.clone()
.unwrap_or_else(|| "sidecar timeout without recorded error".to_string()));
}
self.healthy = true;
self.last_error = None;
Ok(())
}
pub fn shutdown(&mut self) {
self.healthy = false;
}
}
#[cfg(test)]
mod tests {
use super::SidecarPluginRuntime;
use crate::TargetDomain;
use crate::runtime::sidecar_protocol::{SIDECAR_RUNTIME_PROTOCOL_VERSION, SidecarHandshake, SidecarPluginCapability};
use std::time::Duration;
fn notify_sidecar_handshake() -> SidecarHandshake {
SidecarHandshake {
protocol_version: SIDECAR_RUNTIME_PROTOCOL_VERSION.to_string(),
plugin_id: "external:webhook".to_string(),
plugin_version: "1.2.3".to_string(),
supported_domains: vec![TargetDomain::Notify],
capabilities: vec![
SidecarPluginCapability::HealthCheck,
SidecarPluginCapability::SendEvent,
SidecarPluginCapability::Shutdown,
],
}
}
#[test]
fn sidecar_runtime_enable_marks_runtime_healthy() {
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", notify_sidecar_handshake());
runtime
.enable("external:webhook", TargetDomain::Notify)
.expect("sidecar runtime should enable");
assert!(runtime.healthy);
}
#[test]
fn sidecar_runtime_enable_rejects_domain_mismatch() {
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", notify_sidecar_handshake());
let result = runtime.enable("external:webhook", TargetDomain::Audit);
assert!(result.is_err());
assert!(!runtime.healthy);
}
#[test]
fn sidecar_runtime_shutdown_marks_runtime_unhealthy() {
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", notify_sidecar_handshake());
runtime
.enable("external:webhook", TargetDomain::Notify)
.expect("sidecar runtime should enable");
runtime.shutdown();
assert!(!runtime.healthy);
}
#[test]
fn sidecar_runtime_degrades_to_builtin_after_failure_threshold() {
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", notify_sidecar_handshake());
runtime.record_failure("send failed");
runtime.record_failure("send failed again");
runtime.record_failure("send failed third time");
assert!(runtime.degraded_to_builtin);
assert!(!runtime.healthy);
assert_eq!(runtime.failure_count, 3);
}
#[test]
fn sidecar_runtime_send_timeout_records_last_error() {
let mut runtime = SidecarPluginRuntime::new("grpc://127.0.0.1:50051", notify_sidecar_handshake());
let result = runtime.send_with_timeout(Duration::from_millis(50), Duration::from_millis(75));
assert!(result.is_err());
assert_eq!(runtime.last_error.as_deref(), Some("sidecar send timeout after 75ms (budget 50ms)"));
}
}
@@ -0,0 +1,106 @@
// 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::TargetDomain;
use serde::{Deserialize, Serialize};
pub const SIDECAR_RUNTIME_PROTOCOL_VERSION: &str = "rustfs.target-runtime.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SidecarPluginCapability {
HealthCheck,
SendEvent,
Shutdown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SidecarHandshake {
pub protocol_version: String,
pub plugin_id: String,
pub plugin_version: String,
pub supported_domains: Vec<TargetDomain>,
pub capabilities: Vec<SidecarPluginCapability>,
}
impl SidecarHandshake {
pub fn validate(&self, expected_plugin_id: &str) -> Result<(), String> {
if self.protocol_version != SIDECAR_RUNTIME_PROTOCOL_VERSION {
return Err(format!(
"unsupported sidecar protocol version: expected {}, got {}",
SIDECAR_RUNTIME_PROTOCOL_VERSION, self.protocol_version
));
}
if self.plugin_id != expected_plugin_id {
return Err(format!(
"sidecar plugin id mismatch: expected {}, got {}",
expected_plugin_id, self.plugin_id
));
}
for capability in [
SidecarPluginCapability::HealthCheck,
SidecarPluginCapability::SendEvent,
SidecarPluginCapability::Shutdown,
] {
if !self.capabilities.contains(&capability) {
return Err(format!("sidecar handshake missing required capability: {:?}", capability));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{SIDECAR_RUNTIME_PROTOCOL_VERSION, SidecarHandshake, SidecarPluginCapability};
use crate::TargetDomain;
#[test]
fn sidecar_handshake_accepts_expected_contract() {
let handshake = SidecarHandshake {
protocol_version: SIDECAR_RUNTIME_PROTOCOL_VERSION.to_string(),
plugin_id: "external:webhook".to_string(),
plugin_version: "1.2.3".to_string(),
supported_domains: vec![TargetDomain::Notify],
capabilities: vec![
SidecarPluginCapability::HealthCheck,
SidecarPluginCapability::SendEvent,
SidecarPluginCapability::Shutdown,
],
};
assert!(handshake.validate("external:webhook").is_ok());
}
#[test]
fn sidecar_handshake_rejects_protocol_mismatch() {
let handshake = SidecarHandshake {
protocol_version: "rustfs.target-runtime.v0".to_string(),
plugin_id: "external:webhook".to_string(),
plugin_version: "1.2.3".to_string(),
supported_domains: vec![TargetDomain::Notify],
capabilities: vec![
SidecarPluginCapability::HealthCheck,
SidecarPluginCapability::SendEvent,
SidecarPluginCapability::Shutdown,
],
};
assert!(handshake.validate("external:webhook").is_err());
}
}
+19 -50
View File
@@ -19,13 +19,14 @@
//! body through `send_raw_from_store`.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload_with_records, is_connectivity_error, open_target_queue_store,
persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
@@ -39,11 +40,11 @@ use rustfs_config::{AMQP_TLS_CA, AMQP_TLS_CLIENT_CERT, AMQP_TLS_CLIENT_KEY};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{error, info, instrument, warn};
use tracing::{info, instrument, warn};
use url::Url;
#[derive(Clone)]
@@ -315,22 +316,14 @@ where
pub fn new(id: String, args: AMQPArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Amqp.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(queue_store_subdir_name(ChannelTargetType::Amqp.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 AMQP target");
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Amqp.as_str(),
&target_id,
"Failed to open store for AMQP target",
)?;
Ok(Self {
id: target_id,
@@ -344,22 +337,7 @@ where
}
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))
build_queued_payload_with_records(event, vec![event.clone()])
}
async fn get_or_connect(&self) -> Result<Arc<AMQPConnection>, TargetError> {
@@ -453,16 +431,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
Ok(())
} else {
@@ -505,10 +476,7 @@ where
}
match self.get_or_connect().await {
Ok(_) => Ok(()),
Err(err)
if self.store.is_some()
&& matches!(err, TargetError::Network(_) | TargetError::Timeout(_) | TargetError::NotConnected) =>
{
Err(err) if self.store.is_some() && is_connectivity_error(&err) => {
warn!(target_id = %self.id, error = %err, "AMQP init failed; events will buffer in store");
Ok(())
}
@@ -535,6 +503,7 @@ mod tests {
use super::*;
use rustfs_s3_common::EventName;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;
+17 -61
View File
@@ -13,23 +13,22 @@
// limitations under the License.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload, invalidate_cache_on_connectivity_error, open_target_queue_store,
persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
use rustfs_config::audit::AUDIT_STORE_EXTENSION;
use rustfs_config::notify::NOTIFY_STORE_EXTENSION;
use rustfs_kafka_async::error::{ConnectionError, Error as KafkaError};
use rustfs_kafka_async::{AsyncProducer, AsyncProducerConfig, Record, RequiredAcks, SecurityConfig};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::{marker::PhantomData, path::PathBuf, sync::Arc, time::Duration};
use std::{marker::PhantomData, sync::Arc, time::Duration};
use tokio::sync::Mutex;
use tracing::{debug, error, info, instrument, warn};
@@ -123,10 +122,6 @@ where
}
}
fn is_connection_error(err: &TargetError) -> bool {
matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_))
}
/// Creates a new KafkaTarget
#[instrument(skip(args), fields(target_id = %id))]
pub fn new(id: String, args: KafkaArgs) -> Result<Self, TargetError> {
@@ -134,25 +129,14 @@ where
let target_id = TargetID::new(id, ChannelTargetType::Kafka.as_str().to_string());
let queue_store = if !args.queue_dir.is_empty() {
let queue_dir =
PathBuf::from(&args.queue_dir).join(queue_store_subdir_name(ChannelTargetType::Kafka.as_str(), &target_id.id));
let extension = match args.target_type {
TargetType::AuditLog => AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(queue_dir, args.queue_limit, extension);
if let Err(e) = store.open() {
error!("Failed to open store for Kafka target {}: {}", target_id.id, e);
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Kafka.as_str(),
&target_id,
"Failed to open store for Kafka target",
)?;
info!(target_id = %target_id.id, "Kafka target created");
Ok(KafkaTarget {
@@ -211,26 +195,7 @@ where
/// Serializes the event and builds a QueuedPayload
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.data.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))
build_queued_payload(event)
}
/// Sends the raw body to Kafka
@@ -249,9 +214,7 @@ where
if let Err(err) = producer.send(&Record::from_value(&self.args.topic, body.as_slice())).await {
let mapped = Self::map_kafka_error(err, "Failed to send message to Kafka");
if Self::is_connection_error(&mapped) {
self.invalidate_cached_producer().await;
}
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_producer()).await;
return Err(mapped);
}
@@ -297,16 +260,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
debug!("Event saved to store for Kafka target: {}", self.id);
Ok(())
+297 -3
View File
@@ -13,15 +13,17 @@
// limitations under the License.
use crate::arn::TargetID;
use crate::store::{Key, Store};
use crate::store::{Key, QueueStore, Store};
use crate::{StoreError, TargetError, TargetLog};
use async_trait::async_trait;
use rustfs_s3_common::EventName;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::fmt::Formatter;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, warn};
@@ -50,6 +52,8 @@ pub struct TargetDeliveryCounters {
total_messages: AtomicU64,
}
pub(crate) type BoxedQueuedStore = Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>;
impl TargetDeliveryCounters {
#[inline]
pub fn record_success(&self) {
@@ -408,6 +412,17 @@ pub fn decode_object_name(encoded: &str) -> Result<String, TargetError> {
pub(crate) fn build_queued_payload<E>(event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
build_queued_payload_with_records(event, vec![event.data.clone()])
}
pub(crate) fn build_queued_payload_with_records<E, R>(
event: &EntityTarget<E>,
records: Vec<R>,
) -> Result<QueuedPayload, TargetError>
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
R: Serialize,
{
let object_name = decode_object_name(&event.object_name)?;
let key = format!("{}/{}", event.bucket_name, object_name);
@@ -415,7 +430,7 @@ where
let log = TargetLog {
event_name: event.event_name,
key,
records: vec![event.data.clone()],
records,
};
let body = serde_json::to_vec(&log).map_err(|err| TargetError::Serialization(format!("Failed to serialize event: {err}")))?;
@@ -430,6 +445,68 @@ where
Ok(QueuedPayload::new(meta, body))
}
pub(crate) fn open_target_queue_store(
queue_dir: &str,
queue_limit: u64,
target_type: TargetType,
target_type_label: &str,
target_id: &TargetID,
open_context: &str,
) -> Result<Option<BoxedQueuedStore>, TargetError> {
fn boxed_queue_store(store: QueueStore<QueuedPayload>) -> BoxedQueuedStore {
Box::new(store)
}
if queue_dir.is_empty() {
return Ok(None);
}
let queue_dir = PathBuf::from(queue_dir).join(queue_store_subdir_name(target_type_label, &target_id.id));
let extension = match target_type {
TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(queue_dir, queue_limit, extension);
store
.open()
.map_err(|err| TargetError::Storage(format!("{open_context}: {err}")))?;
Ok(Some(boxed_queue_store(store)))
}
pub(crate) fn persist_queued_payload_to_store(
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync),
queued: &QueuedPayload,
) -> Result<(), TargetError> {
let encoded = queued
.encode()
.map_err(|err| TargetError::Storage(format!("Failed to encode queued payload: {err}")))?;
store
.put_raw(&encoded)
.map(|_| ())
.map_err(|err| TargetError::Storage(format!("Failed to save event to store: {err}")))
}
pub(crate) fn is_connectivity_error(err: &TargetError) -> bool {
matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_))
}
pub(crate) async fn invalidate_cache_on_connectivity_error<F, Fut>(err: &TargetError, invalidate: F)
where
F: FnOnce() -> Fut,
Fut: Future<Output = ()>,
{
if is_connectivity_error(err) {
invalidate().await;
}
}
pub(crate) fn mark_target_disconnected_on_connectivity_error(connected: &AtomicBool, err: &TargetError) {
if is_connectivity_error(err) {
connected.store(false, Ordering::SeqCst);
}
}
pub(crate) fn delete_stored_payload(
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync),
key: &Key,
@@ -457,6 +534,90 @@ pub(crate) fn ensure_rustls_provider_installed() {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
use uuid::Uuid;
#[derive(Clone)]
struct MockQueuedStore {
fail_put_raw: bool,
writes: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl MockQueuedStore {
fn new(fail_put_raw: bool) -> Self {
Self {
fail_put_raw,
writes: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl Store<QueuedPayload> for MockQueuedStore {
type Error = StoreError;
type Key = Key;
fn open(&self) -> Result<(), Self::Error> {
Ok(())
}
fn put(&self, _item: Arc<QueuedPayload>) -> Result<Self::Key, Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn put_multiple(&self, _items: Vec<QueuedPayload>) -> Result<Self::Key, Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
if self.fail_put_raw {
return Err(StoreError::Internal("mock put_raw failed".to_string()));
}
self.writes.lock().expect("mock writes lock poisoned").push(data.to_vec());
Ok(Key {
name: "mock".to_string(),
extension: ".json".to_string(),
item_count: 1,
compress: false,
})
}
fn get(&self, _key: &Self::Key) -> Result<QueuedPayload, Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn get_multiple(&self, _key: &Self::Key) -> Result<Vec<QueuedPayload>, Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn get_raw(&self, _key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn del(&self, _key: &Self::Key) -> Result<(), Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn delete(&self) -> Result<(), Self::Error> {
Err(StoreError::Internal("not implemented in mock".to_string()))
}
fn list(&self) -> Vec<Self::Key> {
Vec::new()
}
fn len(&self) -> usize {
0
}
fn is_empty(&self) -> bool {
true
}
fn boxed_clone(&self) -> Box<dyn Store<QueuedPayload, Error = Self::Error, Key = Self::Key> + Send + Sync> {
Box::new(self.clone())
}
}
#[test]
fn channel_target_type_amqp_uses_runtime_name() {
@@ -501,6 +662,139 @@ mod tests {
assert_eq!(value["Records"][0], "payload-data");
}
#[test]
fn build_queued_payload_with_records_preserves_custom_record_shape() {
let event = EntityTarget {
object_name: "object.txt".to_string(),
bucket_name: "bucket-a".to_string(),
event_name: EventName::ObjectCreatedPut,
data: "ignored".to_string(),
};
let payload = build_queued_payload_with_records(&event, vec![event.clone()]).unwrap();
let value: serde_json::Value = serde_json::from_slice(&payload.body).unwrap();
assert_eq!(value["Records"][0]["bucket_name"], "bucket-a");
assert_eq!(value["Records"][0]["object_name"], "object.txt");
assert_eq!(value["Records"][0]["data"], "ignored");
}
#[test]
fn open_target_queue_store_returns_none_when_queue_dir_empty() {
let target_id = TargetID::new("target-a".to_string(), ChannelTargetType::Webhook.as_str().to_string());
let store = open_target_queue_store(
"",
100,
TargetType::NotifyEvent,
ChannelTargetType::Webhook.as_str(),
&target_id,
"open failed",
)
.unwrap();
assert!(store.is_none());
}
#[test]
fn open_target_queue_store_adds_context_on_open_error() {
let base = std::env::temp_dir().join(format!("rustfs-target-store-file-{}", Uuid::new_v4()));
fs::write(&base, b"not-a-directory").expect("failed to create file base");
let target_id = TargetID::new("target-a".to_string(), ChannelTargetType::Kafka.as_str().to_string());
let result = open_target_queue_store(
base.to_str().unwrap(),
100,
TargetType::NotifyEvent,
ChannelTargetType::Kafka.as_str(),
&target_id,
"custom open context",
);
match result {
Ok(_) => panic!("expected open_target_queue_store to fail on file base path"),
Err(err) => assert!(err.to_string().contains("custom open context")),
}
let _ = fs::remove_file(base);
}
#[test]
fn persist_queued_payload_to_store_writes_encoded_payload() {
let store = MockQueuedStore::new(false);
let meta = QueuedPayloadMeta::new(
EventName::ObjectCreatedPut,
"bucket-a".to_string(),
"obj.txt".to_string(),
"application/json",
7,
);
let queued = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
persist_queued_payload_to_store(&store, &queued).unwrap();
let writes = store.writes.lock().expect("mock writes lock poisoned");
assert_eq!(writes.len(), 1);
let decoded = QueuedPayload::decode(&writes[0]).unwrap();
assert_eq!(decoded.body, br#"{"x":1}"#);
}
#[test]
fn persist_queued_payload_to_store_maps_store_error() {
let store = MockQueuedStore::new(true);
let meta = QueuedPayloadMeta::new(
EventName::ObjectCreatedPut,
"bucket-a".to_string(),
"obj.txt".to_string(),
"application/json",
7,
);
let queued = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
let err = persist_queued_payload_to_store(&store, &queued).expect_err("expected put_raw failure");
assert!(err.to_string().contains("Failed to save event to store"));
}
#[test]
fn is_connectivity_error_classifies_target_errors() {
assert!(is_connectivity_error(&TargetError::NotConnected));
assert!(is_connectivity_error(&TargetError::Timeout("timeout".to_string())));
assert!(is_connectivity_error(&TargetError::Network("network".to_string())));
assert!(!is_connectivity_error(&TargetError::Storage("storage".to_string())));
assert!(!is_connectivity_error(&TargetError::Serialization("serialization".to_string())));
}
#[tokio::test]
async fn invalidate_cache_on_connectivity_error_only_runs_for_connectivity_failures() {
let marker = Arc::new(AtomicBool::new(false));
invalidate_cache_on_connectivity_error(&TargetError::NotConnected, {
let marker = Arc::clone(&marker);
move || async move {
marker.store(true, Ordering::SeqCst);
}
})
.await;
assert!(marker.load(Ordering::SeqCst));
marker.store(false, Ordering::SeqCst);
invalidate_cache_on_connectivity_error(&TargetError::Request("request failed".to_string()), {
let marker = Arc::clone(&marker);
move || async move {
marker.store(true, Ordering::SeqCst);
}
})
.await;
assert!(!marker.load(Ordering::SeqCst));
}
#[test]
fn mark_target_disconnected_on_connectivity_error_only_marks_connectivity_failures() {
let connected = AtomicBool::new(true);
mark_target_disconnected_on_connectivity_error(&connected, &TargetError::Timeout("timeout".to_string()));
assert!(!connected.load(Ordering::SeqCst));
connected.store(true, Ordering::SeqCst);
mark_target_disconnected_on_connectivity_error(&connected, &TargetError::Request("request failed".to_string()));
assert!(connected.load(Ordering::SeqCst));
}
#[test]
fn queued_payload_decode_rejects_invalid_magic() {
let err = QueuedPayload::decode(b"bad-payload").unwrap_err();
+19 -58
View File
@@ -13,13 +13,14 @@
// limitations under the License.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload_with_records, mark_target_disconnected_on_connectivity_error, open_target_queue_store,
persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
@@ -37,7 +38,7 @@ use serde::de::DeserializeOwned;
use std::sync::Arc;
use std::{
marker::PhantomData,
path::{Path, PathBuf},
path::Path,
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
@@ -502,30 +503,14 @@ where
pub fn new(id: String, args: MQTTArgs) -> Result<Self, TargetError> {
args.validate()?;
let target_id = TargetID::new(id, ChannelTargetType::Mqtt.as_str().to_string());
let queue_store = if !args.queue_dir.is_empty() {
let base_path = PathBuf::from(&args.queue_dir);
let unique_dir_name = queue_store_subdir_name(ChannelTargetType::Mqtt.as_str(), &target_id.id);
// Ensure the directory name is valid for filesystem
let specific_queue_path = base_path.join(unique_dir_name);
debug!(target_id = %target_id, path = %specific_queue_path.display(), "Initializing queue store for MQTT target");
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 MQTT target"
);
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Mqtt.as_str(),
&target_id,
"Failed to open store for MQTT target",
)?;
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let bg_task_manager = Arc::new(BgTaskManager {
@@ -631,25 +616,7 @@ where
}
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))
build_queued_payload_with_records(event, vec![event.clone()])
}
#[instrument(skip(self, body, meta), fields(target_id = %self.id))]
@@ -673,9 +640,10 @@ where
.await
.map_err(|e| {
if e.to_string().contains("Connection") || e.to_string().contains("Timeout") {
self.connected.store(false, Ordering::SeqCst);
warn!(target_id = %self.id, error = %e, "Publish failed due to connection issue, marking as not connected.");
TargetError::NotConnected
let err = TargetError::NotConnected;
mark_target_disconnected_on_connectivity_error(&self.connected, &err);
err
} else {
TargetError::Request(format!("Failed to publish message: {e}"))
}
@@ -899,14 +867,7 @@ where
if let Some(store) = &self.store {
debug!(target_id = %self.id, "Event saved to store start");
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}")));
}
};
match store.put_raw(&encoded) {
match persist_queued_payload_to_store(store.as_ref(), &queued) {
Ok(_) => {
debug!(target_id = %self.id, "Event saved to store for MQTT target successfully.");
Ok(())
@@ -914,7 +875,7 @@ where
Err(e) => {
error!(target_id = %self.id, error = %e, "Failed to save event to store");
self.delivery_counters.record_final_failure();
Err(TargetError::Storage(format!("Failed to save event to store: {e}")))
Err(e)
}
}
} else {
+14 -38
View File
@@ -16,15 +16,15 @@ use crate::{
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, build_queued_payload, delete_stored_payload, queue_store_subdir_name,
TargetType, build_queued_payload, delete_stored_payload, is_connectivity_error, open_target_queue_store,
persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
use mysql_async::{Conn, Opts, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts, prelude::Queryable};
use rustfs_config::notify::NOTIFY_STORE_EXTENSION;
use rustfs_config::{MYSQL_TLS_CA, MYSQL_TLS_CLIENT_CERT, MYSQL_TLS_CLIENT_KEY};
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -494,25 +494,14 @@ where
let target_id = TargetID::new(id, ChannelTargetType::MySql.as_str().to_string());
// If `queue_dir` is non-empty, a `QueueStore` is created for persistent at-least-once delivery.
let queue_store = if !args.queue_dir.is_empty() {
let queue_dir =
PathBuf::from(&args.queue_dir).join(queue_store_subdir_name(ChannelTargetType::MySql.as_str(), &target_id.id));
let extension = match args.target_type {
TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(queue_dir, args.queue_limit, extension);
if let Err(e) = store.open() {
return Err(TargetError::Storage(format!("Failed to open MySQL queue store: {e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::MySql.as_str(),
&target_id,
"Failed to open MySQL queue store",
)?;
info!(target_id = %target_id.id, table = %args.table, "MySQL target created");
@@ -733,18 +722,9 @@ where
};
if let Some(store) = &self.store {
// persist the event to a local queue before attempting to insert into MySQL. This will allow us to guarantee at-least-once delivery even if the database is temporarily unreachable or if the process crashes after acknowledging receipt but before writing to the database.
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
debug!("Event saved to queue store for MySQL target: {}", self.id);
@@ -789,12 +769,8 @@ where
}
if let Err(e) = self.insert_event(&body, &meta).await {
if matches!(e, TargetError::NotConnected) {
if is_connectivity_error(&e) {
warn!(target_id = %self.id, "MySQL not reachable, event remains in queue store");
return Err(TargetError::NotConnected);
}
if matches!(e, TargetError::Timeout(_)) {
warn!(target_id = %self.id, "MySQL timeout, event remains in queue store");
return Err(e);
}
error!(target_id = %self.id, error = %e, "Failed to send event from store");
+15 -45
View File
@@ -13,13 +13,13 @@
// limitations under the License.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload_with_records, open_target_queue_store, persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
@@ -30,7 +30,7 @@ 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};
use tracing::{info, instrument};
#[derive(Debug, Clone)]
pub struct NATSArgs {
@@ -195,22 +195,14 @@ where
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(queue_store_subdir_name(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
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Nats.as_str(),
&target_id,
"Failed to open store for NATS target",
)?;
Ok(Self {
id: target_id,
@@ -241,22 +233,7 @@ where
}
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))
build_queued_payload_with_records(event, vec![event.clone()])
}
async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
@@ -298,16 +275,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
Ok(())
} else {
+14 -30
View File
@@ -29,10 +29,10 @@ use crate::{
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, build_queued_payload, queue_store_subdir_name,
TargetType, build_queued_payload, open_target_queue_store, persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
@@ -44,11 +44,11 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use tokio_postgres::Config;
use tokio_postgres_rustls::MakeRustlsConnect;
use tracing::{error, info, instrument, warn};
use tracing::{info, instrument, warn};
use url::Url;
use uuid::Uuid;
@@ -585,23 +585,14 @@ where
let target_id = TargetID::new(id, ChannelTargetType::Postgres.as_str().to_string());
let pool = build_pool(&args)?;
let queue_store = if !args.queue_dir.is_empty() {
let base_path = PathBuf::from(&args.queue_dir);
let specific_queue_path =
base_path.join(queue_store_subdir_name(ChannelTargetType::Postgres.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 PostgreSQL target");
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Postgres.as_str(),
&target_id,
"Failed to open store for PostgreSQL target",
)?;
Ok(Self {
id: target_id,
@@ -712,16 +703,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
Ok(())
} else {
+16 -46
View File
@@ -13,24 +13,24 @@
// limitations under the License.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload_with_records, open_target_queue_store, persist_queued_payload_to_store,
},
};
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::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
use tracing::{error, info, instrument};
use tracing::{info, instrument};
use url::Url;
#[derive(Debug, Clone)]
@@ -186,22 +186,14 @@ where
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(queue_store_subdir_name(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
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Pulsar.as_str(),
&target_id,
"Failed to open store for Pulsar target",
)?;
Ok(Self {
id: target_id,
@@ -249,22 +241,7 @@ where
}
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))
build_queued_payload_with_records(event, vec![event.clone()])
}
async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
@@ -317,16 +294,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
Ok(())
} else {
+26 -48
View File
@@ -16,10 +16,11 @@ use crate::{
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, build_queued_payload, queue_store_subdir_name,
TargetType, build_queued_payload, invalidate_cache_on_connectivity_error, is_connectivity_error,
mark_target_disconnected_on_connectivity_error, open_target_queue_store, persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
@@ -33,12 +34,12 @@ use rustfs_config::{REDIS_TLS_CA, REDIS_TLS_CLIENT_CERT, REDIS_TLS_CLIENT_KEY, R
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{debug, error, info, instrument, warn};
use tracing::{debug, info, instrument, warn};
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -320,22 +321,14 @@ where
let target_id = TargetID::new(id, ChannelTargetType::Redis.as_str().to_string());
let publisher_client = build_redis_client(&args)?;
let queue_store = if !args.queue_dir.is_empty() {
let base_path = PathBuf::from(&args.queue_dir);
let specific_queue_path = base_path.join(queue_store_subdir_name(ChannelTargetType::Redis.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 Redis target");
return Err(TargetError::Storage(format!("{e}")));
}
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Redis.as_str(),
&target_id,
"Failed to open store for Redis target",
)?;
info!(target_id = %target_id, "Redis target created");
Ok(Self {
@@ -391,9 +384,7 @@ where
Ok(_) => Ok(()),
Err(err) => {
let mapped = map_redis_error(err);
if is_retryable_target_error(&mapped) {
self.invalidate_cached_publisher().await;
}
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
Err(mapped)
}
}
@@ -437,9 +428,7 @@ where
}
Err(err) => {
let mapped = map_redis_error(err);
if is_retryable_target_error(&mapped) {
self.invalidate_cached_publisher().await;
}
invalidate_cache_on_connectivity_error(&mapped, || self.invalidate_cached_publisher()).await;
warn!(
target_id = %self.id,
@@ -450,7 +439,7 @@ where
"Redis publish attempt failed"
);
if !is_retryable_target_error(&mapped) || attempt >= self.args.max_retry_attempts {
if !is_connectivity_error(&mapped) || attempt >= self.args.max_retry_attempts {
last_error = Some(mapped);
break;
}
@@ -492,14 +481,15 @@ where
Ok(true)
}
Ok(Err(err)) => {
self.invalidate_cached_publisher().await;
self.connected.store(false, Ordering::SeqCst);
invalidate_cache_on_connectivity_error(&err, || self.invalidate_cached_publisher()).await;
mark_target_disconnected_on_connectivity_error(&self.connected, &err);
Err(err)
}
Err(_) => {
self.invalidate_cached_publisher().await;
self.connected.store(false, Ordering::SeqCst);
Err(TargetError::Timeout("Redis connection timed out".to_string()))
let timeout_err = TargetError::Timeout("Redis connection timed out".to_string());
invalidate_cache_on_connectivity_error(&timeout_err, || self.invalidate_cached_publisher()).await;
mark_target_disconnected_on_connectivity_error(&self.connected, &timeout_err);
Err(timeout_err)
}
}
}
@@ -514,17 +504,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
debug!(target_id = %self.id, "Event saved to store for Redis target");
@@ -556,14 +538,14 @@ where
}
if let Err(err) = self.init_inner().await {
if matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_)) {
if is_connectivity_error(&err) {
warn!(target_id = %self.id, error = %err, "Redis target not ready; queued event remains in store");
}
return Err(err);
}
if let Err(err) = self.send_body(body, &meta).await {
if matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_)) {
if is_connectivity_error(&err) {
warn!(target_id = %self.id, error = %err, "Failed to send Redis event from store: target not connected. Event remains queued.");
}
return Err(err);
@@ -734,10 +716,6 @@ fn map_redis_error(err: RedisError) -> TargetError {
}
}
fn is_retryable_target_error(err: &TargetError) -> bool {
matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_))
}
fn compute_retry_delay(attempt: usize, min_delay: Duration, max_delay: Duration) -> Duration {
let shift = attempt.saturating_sub(1).min(16) as u32;
let factor = 1u32 << shift;
+14 -53
View File
@@ -13,24 +13,21 @@
// limitations under the License.
use crate::{
StoreError, Target, TargetLog,
StoreError, Target,
arn::TargetID,
error::TargetError,
store::{Key, QueueStore, Store},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetType, queue_store_subdir_name,
TargetType, build_queued_payload, open_target_queue_store, persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
use reqwest::{Client, StatusCode, Url};
use rustfs_config::audit::AUDIT_STORE_EXTENSION;
use rustfs_config::notify::NOTIFY_STORE_EXTENSION;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::{
marker::PhantomData,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
@@ -151,28 +148,14 @@ where
// Build HTTP client using the helper function
let http_client = Arc::new(Self::build_http_client(&args)?);
// Build storage
let queue_store = if !args.queue_dir.is_empty() {
let queue_dir =
PathBuf::from(&args.queue_dir).join(queue_store_subdir_name(ChannelTargetType::Webhook.as_str(), &target_id.id));
let extension = match args.target_type {
TargetType::AuditLog => AUDIT_STORE_EXTENSION,
TargetType::NotifyEvent => NOTIFY_STORE_EXTENSION,
};
let store = QueueStore::<QueuedPayload>::new(queue_dir, args.queue_limit, extension);
if let Err(e) = store.open() {
error!("Failed to open store for Webhook target {}: {}", target_id.id, e);
return Err(TargetError::Storage(format!("{e}")));
}
// Make sure that the Store trait implemented by QueueStore matches the expected error type
Some(Box::new(store) as Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>)
} else {
None
};
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Webhook.as_str(),
&target_id,
"Failed to open store for Webhook target",
)?;
// Create a cancel channel
let (cancel_sender, _) = mpsc::channel(1);
@@ -302,22 +285,7 @@ where
}
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.data.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))
build_queued_payload(event)
}
async fn send_body(&self, body: Vec<u8>, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
@@ -408,16 +376,9 @@ where
};
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) {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(TargetError::Storage(format!("Failed to save event to store: {e}")));
return Err(e);
}
debug!("Event saved to store for target: {}", self.id);
Ok(())