use serde::{Deserialize, Serialize};
use crate::error::{Result, RsearchError};
use crate::role::Role;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RsearchConfig {
pub node: NodeConfig,
pub http: HttpConfig,
pub storage: StorageConfig,
pub metastore: MetastoreConfig,
pub ingest: IngestConfig,
pub search: SearchConfig,
pub control: ControlConfig,
pub inputs: InputsConfig,
pub cluster: ClusterConfig,
}
impl Default for RsearchConfig {
fn default() -> Self {
Self {
node: NodeConfig::default(),
http: HttpConfig::default(),
storage: StorageConfig::default(),
metastore: MetastoreConfig::default(),
ingest: IngestConfig::default(),
search: SearchConfig::default(),
control: ControlConfig::default(),
inputs: InputsConfig::default(),
cluster: ClusterConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClusterConfig {
pub internal_token: String,
pub peer_ca_file: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct InputsConfig {
pub syslog: SyslogInputConfig,
pub gelf: GelfInputConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SyslogInputConfig {
pub enabled: bool,
pub bind_udp: String,
pub bind_tcp: String,
pub tls_cert_path: String,
pub tls_key_path: String,
pub stream: String,
}
impl Default for SyslogInputConfig {
fn default() -> Self {
Self {
enabled: false,
bind_udp: "0.0.0.0:5514".to_string(),
bind_tcp: "0.0.0.0:5514".to_string(),
tls_cert_path: String::new(),
tls_key_path: String::new(),
stream: "syslog".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GelfInputConfig {
pub enabled: bool,
pub bind_tcp: String,
pub tls_cert_path: String,
pub tls_key_path: String,
pub stream: String,
}
impl Default for GelfInputConfig {
fn default() -> Self {
Self {
enabled: false,
bind_tcp: "0.0.0.0:12201".to_string(),
tls_cert_path: String::new(),
tls_key_path: String::new(),
stream: "gelf".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ControlConfig {
pub interval_secs: u64,
pub merge_min_mb: i64,
pub merge_max_group: usize,
pub gc_grace_secs: f64,
pub allow_insecure_webhooks: bool,
pub staged_orphan_secs: f64,
pub repair_stale_secs: f64,
pub drain_warn_secs: f64,
}
impl Default for ControlConfig {
fn default() -> Self {
Self {
interval_secs: 15,
merge_min_mb: 100,
merge_max_group: 8,
gc_grace_secs: 600.0,
allow_insecure_webhooks: false,
staged_orphan_secs: 3600.0,
repair_stale_secs: 300.0,
drain_warn_secs: 3600.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SearchConfig {
pub cache_max_mb: u64,
}
impl Default for SearchConfig {
fn default() -> Self {
Self { cache_max_mb: 4096 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NodeConfig {
pub cluster_name: String,
pub id: Option<String>,
pub roles: Vec<Role>,
pub data_dir: String,
pub advertise_addr: String,
}
impl Default for NodeConfig {
fn default() -> Self {
Self {
cluster_name: "rsearch".to_string(),
id: None,
roles: Role::ALL.to_vec(),
data_dir: "./data".to_string(),
advertise_addr: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HttpConfig {
pub bind_addr: String,
pub tls: TlsConfig,
pub cors_allow_origin: String,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
bind_addr: "0.0.0.0:9200".to_string(),
tls: TlsConfig::default(),
cors_allow_origin: "*".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TlsConfig {
pub enabled: bool,
pub cert_path: String,
pub key_path: String,
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
enabled: false,
cert_path: String::new(),
key_path: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StorageConfig {
pub backend: String,
pub root: String,
pub bucket: String,
pub endpoint: String,
pub force_path_style: bool,
pub use_fips_endpoint: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub replication_factor: usize,
pub write_quorum: usize,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: "fs".to_string(),
root: "./data/storage".to_string(),
bucket: String::new(),
endpoint: String::new(),
force_path_style: false,
use_fips_endpoint: false,
region: String::new(),
access_key_id: String::new(),
secret_access_key: String::new(),
replication_factor: 2,
write_quorum: 0,
}
}
}
impl StorageConfig {
pub fn effective_write_quorum(&self) -> usize {
let quorum = if self.write_quorum == 0 {
self.replication_factor.min(2)
} else {
self.write_quorum
};
quorum.min(self.replication_factor).max(1)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetastoreConfig {
pub database_url: String,
pub max_connections: u32,
}
impl Default for MetastoreConfig {
fn default() -> Self {
Self {
database_url: String::new(),
max_connections: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IngestConfig {
pub max_batch_docs: usize,
pub max_batch_secs: u64,
pub queue_capacity: usize,
pub memory_budget_mb: usize,
pub wal_segment_mb: u64,
pub balance_bulk: bool,
}
impl Default for IngestConfig {
fn default() -> Self {
Self {
max_batch_docs: 500_000,
max_batch_secs: 30,
queue_capacity: 100_000,
memory_budget_mb: 256,
wal_segment_mb: 64,
balance_bulk: true,
}
}
}
impl RsearchConfig {
const SECTIONS: [&str; 9] = [
"NODE", "HTTP", "STORAGE", "METASTORE", "INGEST", "SEARCH", "CONTROL", "INPUTS",
"CLUSTER",
];
pub fn load(file: Option<&str>) -> Result<Self> {
let mut builder = ::config::Config::builder();
if let Some(path) = file {
builder = builder.add_source(::config::File::with_name(path).required(true));
}
builder = builder.add_source(
::config::Environment::with_prefix("RSEARCH")
.prefix_separator("_")
.separator("__")
.try_parsing(true)
.source(Some(filtered_rsearch_env())),
);
let cfg = builder
.build()
.map_err(|e| RsearchError::Config(e.to_string()))?;
let mut loaded: RsearchConfig = cfg
.try_deserialize()
.map_err(|e| RsearchError::Config(e.to_string()))?;
if loaded.node.id.is_none() {
loaded.node.id = hostname();
}
Ok(loaded)
}
pub fn node_id(&self) -> String {
self.node
.id
.clone()
.unwrap_or_else(|| "rsearch-node".to_string())
}
pub fn advertise_url(&self) -> String {
let addr = if self.node.advertise_addr.is_empty() {
&self.http.bind_addr
} else {
&self.node.advertise_addr
};
if addr.contains("://") {
addr.clone()
} else if self.http.tls.enabled {
format!("https://{addr}")
} else {
format!("http://{addr}")
}
}
}
fn filtered_rsearch_env() -> ::config::Map<String, String> {
let mut map = ::config::Map::new();
for (key, value) in std::env::vars() {
if let Some(rest) = key.strip_prefix("RSEARCH_") {
let section = rest.split("__").next().unwrap_or("");
if RsearchConfig::SECTIONS.contains(§ion) {
map.insert(key, value);
}
}
}
map
}
fn hostname() -> Option<String> {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_load_without_file() {
let cfg = RsearchConfig::load(None).unwrap();
assert_eq!(cfg.http.bind_addr, "0.0.0.0:9200");
assert_eq!(cfg.storage.backend, "fs");
assert!(!cfg.http.tls.enabled);
}
#[test]
fn advertise_url_falls_back_and_derives_scheme() {
let mut cfg = RsearchConfig::default();
assert_eq!(cfg.advertise_url(), "http://0.0.0.0:9200");
cfg.node.advertise_addr = "node1.internal:9200".to_string();
assert_eq!(cfg.advertise_url(), "http://node1.internal:9200");
cfg.http.tls.enabled = true;
assert_eq!(cfg.advertise_url(), "https://node1.internal:9200");
cfg.node.advertise_addr = "http://behind-proxy:8080".to_string();
assert_eq!(cfg.advertise_url(), "http://behind-proxy:8080");
}
#[test]
fn write_quorum_auto_and_clamping() {
let mut storage = StorageConfig::default();
assert_eq!(storage.effective_write_quorum(), 2);
storage.replication_factor = 1;
assert_eq!(storage.effective_write_quorum(), 1);
storage.replication_factor = 3;
assert_eq!(storage.effective_write_quorum(), 2);
storage.write_quorum = 5;
assert_eq!(storage.effective_write_quorum(), 3);
storage.write_quorum = 1;
assert_eq!(storage.effective_write_quorum(), 1);
}
#[test]
fn stray_rsearch_env_var_does_not_crash_load() {
temp_env::with_vars(
[
("RSEARCH_TEST_DATABASE_URL", Some("postgres://x")),
("RSEARCH_HOME", Some("/tmp")),
],
|| {
let cfg =
RsearchConfig::load(None).expect("stray RSEARCH_ vars must not crash load");
assert_eq!(cfg.http.bind_addr, "0.0.0.0:9200");
},
);
}
}