// 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_config::server_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 { normalize_target_plugin_instances_from_env(config, descriptor, std::env::vars()) } pub fn normalize_target_plugin_instances_from_env( config: &Config, descriptor: &TargetPluginInstanceCompatDescriptor<'_>, env_vars: I, ) -> Vec where I: IntoIterator, { let valid_fields = descriptor .valid_fields .iter() .map(|field| (*field).to_string()) .collect::>(); 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 { normalize_target_plugin_instances(config, descriptor) } pub fn normalize_legacy_target_instances_from_env( config: &Config, descriptor: &LegacyTargetInstanceDescriptor<'_>, env_vars: I, ) -> Vec where I: IntoIterator, { 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::server_config::{Config, KVS}; use rustfs_config::{ENABLE_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_LIMIT}; 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); } }