use serde::Deserialize;
use std::path::{Path, PathBuf};
pub mod live_reload;
pub mod tenant_overrides;
pub mod versioned;
pub mod watch;
pub use live_reload::{ReloadError, ReloadOutcome, Reloadable};
pub use tenant_overrides::{
InMemoryTenantConfigStore, OverrideValue, TenantConfigError, TenantConfigOverride,
TenantConfigStore,
};
pub use versioned::{ConfigDelta, ConfigVersion, VersionedConfig};
pub use watch::{ConfigWatch, ConfigWatchSender};
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct ServerConfig {
pub server: ServerSection,
pub tls: TlsSection,
pub encryption: EncryptionSection,
pub embedding: EmbeddingSection,
pub background: BackgroundSection,
pub maintenance: MaintenanceSection,
pub limits: LimitsSection,
pub cluster: ClusterSection,
pub cluster_tls: crate::security::ClusterTlsConfig,
pub yrp: YrpSection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerSection {
pub wire_port: u16,
pub http_port: u16,
pub data_dir: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct EmbeddingSection {
pub strategy: EmbeddingStrategy,
pub dim: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct TlsSection {
pub cert_path: Option<PathBuf>,
pub key_path: Option<PathBuf>,
}
impl TlsSection {
pub fn is_enabled(&self) -> bool {
self.cert_path.is_some() && self.key_path.is_some()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct EncryptionSection {
pub key_path: Option<PathBuf>,
pub auto_generate: bool,
pub key_hex: Option<String>,
}
impl EncryptionSection {
#[allow(dead_code)]
pub fn is_enabled(&self) -> bool {
self.key_path.is_some() || self.key_hex.is_some() || self.auto_generate
}
pub fn resolve_key(&self, data_dir: &Path) -> anyhow::Result<Option<[u8; 32]>> {
if let Ok(hex_str) = std::env::var("YANTRIKDB_ENCRYPTION_KEY_HEX") {
let bytes = hex::decode(hex_str.trim())
.map_err(|e| anyhow::anyhow!("invalid YANTRIKDB_ENCRYPTION_KEY_HEX: {}", e))?;
if bytes.len() != 32 {
anyhow::bail!(
"YANTRIKDB_ENCRYPTION_KEY_HEX must decode to exactly 32 bytes (got {})",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
tracing::info!("encryption: enabled via YANTRIKDB_ENCRYPTION_KEY_HEX env var");
return Ok(Some(key));
}
if let Some(ref hex_str) = self.key_hex {
let bytes = hex::decode(hex_str)
.map_err(|e| anyhow::anyhow!("invalid encryption.key_hex: {}", e))?;
if bytes.len() != 32 {
anyhow::bail!("encryption.key_hex must decode to exactly 32 bytes");
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(Some(key));
}
let path = match &self.key_path {
Some(p) => p.clone(),
None if self.auto_generate => data_dir.join("master.key"),
None => return Ok(None),
};
if path.exists() {
let bytes = std::fs::read(&path)?;
if bytes.len() != 32 {
anyhow::bail!(
"key file at {} must be exactly 32 bytes (got {})",
path.display(),
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(Some(key));
}
if self.auto_generate {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
tracing::info!(
path = %path.display(),
"auto-generated encryption master key"
);
return Ok(Some(key));
}
Ok(None)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingStrategy {
Builtin,
Bundled,
ClientOnly,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BackgroundSection {
pub consolidation_interval_minutes: u64,
pub decay_sweep_interval_minutes: u64,
#[serde(default)]
pub enrichment_pause_threshold: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MaintenanceSection {
pub enabled: bool,
pub interval_secs: u64,
pub initial_delay_secs: u64,
pub pause_during_replication_catchup: bool,
pub run_split_oversized: bool,
pub run_repair_artifacts: bool,
pub max_pending_triggers: usize,
pub max_auto_relate_edges: usize,
pub split_min_chars: usize,
}
impl Default for MaintenanceSection {
fn default() -> Self {
Self {
enabled: true,
interval_secs: 600,
initial_delay_secs: 120,
pause_during_replication_catchup: true,
run_split_oversized: false,
run_repair_artifacts: false,
max_pending_triggers: 64,
max_auto_relate_edges: 500,
split_min_chars: 1500,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LimitsSection {
pub max_databases: usize,
pub max_connections: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ClusterSection {
pub node_id: u32,
pub role: NodeRole,
pub cluster_port: u16,
pub advertise_addr: Option<String>,
pub peers: Vec<PeerConfig>,
pub heartbeat_interval_ms: u64,
pub election_timeout_ms: u64,
pub cluster_secret: Option<String>,
pub replication_mode: ReplicationMode,
pub raft_mode: crate::raft::RaftClusterMode,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct YrpSection {
pub cluster_id: u64,
pub tick_ms: u64,
pub election_ticks_min: u32,
pub election_ticks_max: u32,
pub heartbeat_ticks: u32,
pub compact_after_entries: u64,
pub leader_retain_entries: u64,
pub peers: Vec<YrpPeerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct YrpPeerConfig {
pub node_id: u64,
pub addr: String,
#[serde(default)]
pub witness: bool,
}
impl Default for YrpSection {
fn default() -> Self {
Self {
cluster_id: 0,
tick_ms: 50,
election_ticks_min: 10,
election_ticks_max: 20,
heartbeat_ticks: 2,
compact_after_entries: 0,
leader_retain_entries: 512,
peers: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeRole {
Single,
Voter,
ReadReplica,
Witness,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplicationMode {
Async,
Sync,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PeerConfig {
pub addr: String,
pub role: NodeRole,
}
impl Default for ClusterSection {
fn default() -> Self {
Self {
node_id: 0,
role: NodeRole::Single,
cluster_port: 7440,
advertise_addr: None,
peers: Vec::new(),
heartbeat_interval_ms: 1000,
election_timeout_ms: 5000,
cluster_secret: None,
replication_mode: ReplicationMode::Async,
raft_mode: crate::raft::RaftClusterMode::Disabled,
}
}
}
impl ClusterSection {
pub fn is_clustered(&self) -> bool {
self.role != NodeRole::Single
}
#[allow(dead_code)]
pub fn voter_count(&self) -> usize {
let self_voter = matches!(self.role, NodeRole::Voter) as usize;
let peer_voters = self
.peers
.iter()
.filter(|p| p.role == NodeRole::Voter)
.count();
self_voter + peer_voters
}
pub fn quorum_members(&self) -> usize {
let self_member = matches!(self.role, NodeRole::Voter | NodeRole::Witness) as usize;
let peer_members = self
.peers
.iter()
.filter(|p| matches!(p.role, NodeRole::Voter | NodeRole::Witness))
.count();
self_member + peer_members
}
pub fn quorum_size(&self) -> usize {
let total = self.quorum_members();
total / 2 + 1
}
}
impl Default for ServerSection {
fn default() -> Self {
Self {
wire_port: 7437,
http_port: 7438,
data_dir: PathBuf::from("./data"),
}
}
}
impl Default for EmbeddingSection {
fn default() -> Self {
Self {
strategy: EmbeddingStrategy::Builtin,
dim: 384,
}
}
}
impl Default for BackgroundSection {
fn default() -> Self {
Self {
consolidation_interval_minutes: 30,
decay_sweep_interval_minutes: 60,
enrichment_pause_threshold: None, }
}
}
impl Default for LimitsSection {
fn default() -> Self {
Self {
max_databases: 100,
max_connections: 1000,
}
}
}
impl ServerConfig {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: ServerConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn data_dir(&self) -> &Path {
&self.server.data_dir
}
pub fn control_db_path(&self) -> PathBuf {
self.server.data_dir.join("control.db")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encryption_env_var_handling() {
const VAR: &str = "YANTRIKDB_ENCRYPTION_KEY_HEX";
let cfg_default = EncryptionSection {
key_path: None,
auto_generate: false,
key_hex: None,
};
let key_hex = "f".repeat(64);
std::env::set_var(VAR, &key_hex);
let resolved = cfg_default.resolve_key(Path::new("/tmp")).unwrap();
assert_eq!(resolved, Some([0xffu8; 32]), "env var should produce key");
std::env::set_var(VAR, "not-hex-at-all");
let err = cfg_default.resolve_key(Path::new("/tmp")).unwrap_err();
assert!(err.to_string().contains(VAR));
std::env::set_var(VAR, "ab"); let err = cfg_default.resolve_key(Path::new("/tmp")).unwrap_err();
assert!(err.to_string().contains("32 bytes"));
std::env::set_var(VAR, &key_hex);
let cfg_with_toml = EncryptionSection {
key_path: None,
auto_generate: false,
key_hex: Some("0".repeat(64)), };
let resolved = cfg_with_toml.resolve_key(Path::new("/tmp")).unwrap();
assert_eq!(
resolved,
Some([0xffu8; 32]),
"env var must beat TOML key_hex"
);
std::env::remove_var(VAR);
}
#[test]
fn embedding_strategy_variants_parse() {
let toml = r#"[embedding]
strategy = "builtin"
dim = 384
"#;
let cfg: EmbeddingSection = toml::from_str(toml)
.and_then(|v: toml::Value| v["embedding"].clone().try_into())
.unwrap();
assert!(matches!(cfg.strategy, EmbeddingStrategy::Builtin));
assert_eq!(cfg.dim, 384);
let toml = r#"[embedding]
strategy = "bundled"
dim = 64
"#;
let cfg: EmbeddingSection = toml::from_str(toml)
.and_then(|v: toml::Value| v["embedding"].clone().try_into())
.unwrap();
assert!(matches!(cfg.strategy, EmbeddingStrategy::Bundled));
assert_eq!(cfg.dim, 64);
let toml = r#"[embedding]
strategy = "client_only"
dim = 384
"#;
let cfg: EmbeddingSection = toml::from_str(toml)
.and_then(|v: toml::Value| v["embedding"].clone().try_into())
.unwrap();
assert!(matches!(cfg.strategy, EmbeddingStrategy::ClientOnly));
let default = EmbeddingSection::default();
assert!(matches!(default.strategy, EmbeddingStrategy::Builtin));
assert_eq!(default.dim, 384);
}
}