mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 19:16:17 +00:00
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:
@@ -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)?))
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¬ify_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,
|
||||
¬ify_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,
|
||||
¬ify_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);
|
||||
}
|
||||
}
|
||||
@@ -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(§ion_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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user