use std::env;
use std::fs;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use hydracache_client_transport_axum::ClientSurfaceLimits;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServerRole {
#[default]
Local,
Member,
Client,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClusterStartMode {
#[default]
Bootstrap,
Join,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TlsConfig {
pub enabled: bool,
pub cert_path: Option<PathBuf>,
pub key_path: Option<PathBuf>,
pub ca_path: Option<PathBuf>,
pub acknowledge_insecure: bool,
}
impl TlsConfig {
pub fn has_complete_material(&self) -> bool {
!self.enabled
|| (self.cert_path.is_some() && self.key_path.is_some() && self.ca_path.is_some())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClusterAuthConfig {
pub key_id: Option<String>,
pub token_file: Option<PathBuf>,
pub previous_key_id: Option<String>,
pub previous_token_file: Option<PathBuf>,
}
impl ClusterAuthConfig {
pub fn is_configured(&self) -> bool {
self.key_id.as_deref().is_some_and(non_empty)
|| self.token_file.as_deref().is_some_and(non_empty_path)
}
fn validate(&self) -> Result<(), ServerConfigError> {
validate_cluster_auth_pair(
self.key_id.as_deref(),
self.token_file.as_deref(),
"cluster_auth",
)?;
validate_cluster_auth_pair(
self.previous_key_id.as_deref(),
self.previous_token_file.as_deref(),
"cluster_auth.previous",
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupConfig {
pub enabled: bool,
pub location: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientApiConfig {
pub enabled: bool,
pub limits: ClientSurfaceLimits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminApiConfig {
pub enabled: bool,
pub listen_addr: SocketAddr,
}
impl Default for AdminApiConfig {
fn default() -> Self {
Self {
enabled: true,
listen_addr: "127.0.0.1:9091"
.parse()
.expect("default admin listen address is valid"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub role: ServerRole,
pub listen_addr: SocketAddr,
pub cluster_addr: SocketAddr,
pub cluster_start: ClusterStartMode,
pub cluster_advertise_addr: Option<String>,
pub node_id: Option<String>,
pub seeds: Vec<String>,
pub storage_dir: Option<PathBuf>,
pub drain_timeout_ms: u64,
pub join_timeout_ms: u64,
pub tls: TlsConfig,
pub cluster_auth: ClusterAuthConfig,
pub backup: BackupConfig,
pub client_api: ClientApiConfig,
pub admin_api: AdminApiConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
role: ServerRole::Local,
listen_addr: "127.0.0.1:8080"
.parse()
.expect("default listen address is valid"),
cluster_addr: "127.0.0.1:7000"
.parse()
.expect("default cluster address is valid"),
cluster_start: ClusterStartMode::Bootstrap,
cluster_advertise_addr: None,
node_id: None,
seeds: Vec::new(),
storage_dir: None,
drain_timeout_ms: 30_000,
join_timeout_ms: 15_000,
tls: TlsConfig::default(),
cluster_auth: ClusterAuthConfig::default(),
backup: BackupConfig::default(),
client_api: ClientApiConfig::default(),
admin_api: AdminApiConfig::default(),
}
}
}
impl ServerConfig {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ServerConfigError> {
let path = path.as_ref();
let text = fs::read_to_string(path).map_err(|source| ServerConfigError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
Self::from_toml_str(&text)
}
pub fn from_toml_str(text: &str) -> Result<Self, ServerConfigError> {
let config = toml::from_str::<Self>(text).map_err(ServerConfigError::ConfigParse)?;
config.validate()?;
Ok(config)
}
pub fn from_env() -> Result<Self, ServerConfigError> {
let mut config = Self::default();
if let Ok(role) = env::var("HYDRACACHE_ROLE") {
config.role = parse_role(&role)?;
}
if let Ok(listen) = env::var("HYDRACACHE_LISTEN_ADDR") {
config.listen_addr = listen
.parse()
.map_err(|_| ServerConfigError::InvalidAddress(listen))?;
}
if let Ok(cluster) = env::var("HYDRACACHE_CLUSTER_ADDR") {
config.cluster_addr = cluster
.parse()
.map_err(|_| ServerConfigError::InvalidAddress(cluster))?;
}
let cluster_start_explicit = if let Ok(cluster_start) = env::var("HYDRACACHE_CLUSTER_START")
{
config.cluster_start = parse_cluster_start(&cluster_start)?;
true
} else {
false
};
if let Ok(advertise_addr) = env::var("HYDRACACHE_CLUSTER_ADVERTISE_ADDR") {
config.cluster_advertise_addr = Some(advertise_addr);
}
if let Ok(node_id) = env::var("HYDRACACHE_NODE_ID") {
config.node_id = Some(node_id);
}
if let Ok(storage_dir) = env::var("HYDRACACHE_STORAGE_DIR") {
config.storage_dir = Some(PathBuf::from(storage_dir));
}
if let Ok(seeds) = env::var("HYDRACACHE_SEEDS") {
config.seeds = seeds
.split(',')
.map(str::trim)
.filter(|seed| !seed.is_empty())
.map(ToOwned::to_owned)
.collect();
}
apply_statefulset_env(&mut config, cluster_start_explicit)?;
if let Ok(join_timeout) = env::var("HYDRACACHE_JOIN_TIMEOUT_MS") {
config.join_timeout_ms = join_timeout
.parse()
.map_err(|_| ServerConfigError::InvalidJoinTimeoutMs(join_timeout))?;
}
if env::var("HYDRACACHE_TLS_ACK_INSECURE").as_deref() == Ok("true") {
config.tls.acknowledge_insecure = true;
}
if env::var("HYDRACACHE_TLS_ENABLED").as_deref() == Ok("true") {
config.tls.enabled = true;
}
if let Ok(path) = env::var("HYDRACACHE_TLS_CERT_PATH") {
config.tls.cert_path = Some(PathBuf::from(path));
}
if let Ok(path) = env::var("HYDRACACHE_TLS_KEY_PATH") {
config.tls.key_path = Some(PathBuf::from(path));
}
if let Ok(path) = env::var("HYDRACACHE_TLS_CA_PATH") {
config.tls.ca_path = Some(PathBuf::from(path));
}
if let Ok(key_id) = env::var("HYDRACACHE_CLUSTER_AUTH_KEY_ID") {
config.cluster_auth.key_id = Some(key_id);
}
if let Ok(path) = env::var("HYDRACACHE_CLUSTER_AUTH_TOKEN_FILE") {
config.cluster_auth.token_file = Some(PathBuf::from(path));
}
if let Ok(key_id) = env::var("HYDRACACHE_CLUSTER_AUTH_PREVIOUS_KEY_ID") {
config.cluster_auth.previous_key_id = Some(key_id);
}
if let Ok(path) = env::var("HYDRACACHE_CLUSTER_AUTH_PREVIOUS_TOKEN_FILE") {
config.cluster_auth.previous_token_file = Some(PathBuf::from(path));
}
if env::var("HYDRACACHE_BACKUP_ENABLED").as_deref() == Ok("true") {
config.backup.enabled = true;
}
if let Ok(location) = env::var("HYDRACACHE_BACKUP_LOCATION") {
config.backup.location = Some(location);
}
if env::var("HYDRACACHE_CLIENT_API_ENABLED").as_deref() == Ok("true") {
config.client_api.enabled = true;
}
if let Ok(enabled) = env::var("HYDRACACHE_ADMIN_API_ENABLED") {
config.admin_api.enabled = enabled != "false";
}
if let Ok(listen) = env::var("HYDRACACHE_ADMIN_ADDR") {
config.admin_api.listen_addr = listen
.parse()
.map_err(|_| ServerConfigError::InvalidAddress(listen))?;
}
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ServerConfigError> {
if self.drain_timeout_ms == 0 {
return Err(ServerConfigError::DrainTimeoutZero);
}
if self.join_timeout_ms == 0 {
return Err(ServerConfigError::JoinTimeoutZero);
}
if matches!(self.cluster_start, ClusterStartMode::Join) {
if !matches!(self.role, ServerRole::Member) {
return Err(ServerConfigError::JoinRequiresMemberRole);
}
if self.seeds.is_empty() {
return Err(ServerConfigError::JoinRequiresSeeds);
}
}
if matches!(self.role, ServerRole::Member) && self.storage_dir.is_none() {
return Err(ServerConfigError::MissingStorageDir);
}
if matches!(self.role, ServerRole::Member | ServerRole::Client) && self.seeds.is_empty() {
return Err(ServerConfigError::MissingSeeds);
}
if self
.node_id
.as_deref()
.is_some_and(|node_id| node_id.trim().is_empty())
{
return Err(ServerConfigError::InvalidNodeId);
}
if self
.cluster_advertise_addr
.as_deref()
.is_some_and(invalid_cluster_advertise_addr)
{
return Err(ServerConfigError::InvalidClusterAdvertiseAddr);
}
if self.backup.enabled
&& self
.backup
.location
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(ServerConfigError::MissingBackupLocation);
}
if !self.tls.has_complete_material() {
return Err(ServerConfigError::IncompleteTlsMaterial);
}
self.cluster_auth.validate()?;
if self.exposes_non_loopback() && !self.tls.enabled && !self.tls.acknowledge_insecure {
return Err(ServerConfigError::NonLoopbackWithoutTls);
}
if self.client_api.enabled {
self.client_api
.limits
.validate()
.map_err(|error| ServerConfigError::InvalidClientApi(error.to_string()))?;
}
if self.admin_api.enabled && self.admin_api.listen_addr == self.listen_addr {
return Err(ServerConfigError::AdminAddressConflicts);
}
Ok(())
}
pub fn drain_timeout(&self) -> Duration {
Duration::from_millis(self.drain_timeout_ms)
}
pub fn join_timeout(&self) -> Duration {
Duration::from_millis(self.join_timeout_ms)
}
pub fn cluster_advertise_endpoint(&self) -> String {
self.cluster_advertise_addr
.clone()
.unwrap_or_else(|| self.cluster_addr.to_string())
}
pub fn exposes_non_loopback(&self) -> bool {
!is_loopback(self.listen_addr.ip())
|| !is_loopback(self.cluster_addr.ip())
|| (self.admin_api.enabled && !is_loopback(self.admin_api.listen_addr.ip()))
}
}
#[derive(Debug, Error)]
pub enum ServerConfigError {
#[error("failed to read config {path}: {source}")]
ConfigRead {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config: {0}")]
ConfigParse(toml::de::Error),
#[error("invalid server role: {0}")]
InvalidRole(String),
#[error("invalid cluster start mode: {0}")]
InvalidClusterStart(String),
#[error("invalid listen address: {0}")]
InvalidAddress(String),
#[error("invalid bootstrap_replicas: {0}")]
InvalidBootstrapReplicas(String),
#[error("invalid StatefulSet HOSTNAME: {0}")]
InvalidStatefulSetHostname(String),
#[error("invalid join_timeout_ms: {0}")]
InvalidJoinTimeoutMs(String),
#[error("drain_timeout_ms must be greater than zero")]
DrainTimeoutZero,
#[error("join_timeout_ms must be greater than zero")]
JoinTimeoutZero,
#[error("cluster_start=join requires role=member")]
JoinRequiresMemberRole,
#[error("cluster_start=join requires at least one seed")]
JoinRequiresSeeds,
#[error("member role requires storage_dir")]
MissingStorageDir,
#[error("member/client role requires at least one seed")]
MissingSeeds,
#[error("node_id must not be empty")]
InvalidNodeId,
#[error("cluster_advertise_addr must be non-empty and routable when set")]
InvalidClusterAdvertiseAddr,
#[error("backup.enabled requires backup.location")]
MissingBackupLocation,
#[error("tls.enabled requires cert_path, key_path, and ca_path")]
IncompleteTlsMaterial,
#[error("{section} requires key_id and readable token_file")]
IncompleteClusterAuth {
section: &'static str,
},
#[error("failed to read {section}.token_file {path}: {source}")]
ClusterAuthTokenRead {
section: &'static str,
path: PathBuf,
source: std::io::Error,
},
#[error("{section}.token_file {path} is empty")]
EmptyClusterAuthToken {
section: &'static str,
path: PathBuf,
},
#[error("non-loopback listeners require TLS or acknowledge_insecure=true")]
NonLoopbackWithoutTls,
#[error("invalid client_api config: {0}")]
InvalidClientApi(String),
#[error("failed to start member grid host: {0}")]
GridHostStart(String),
#[error("admin_api.listen_addr must differ from listen_addr")]
AdminAddressConflicts,
}
fn parse_role(value: &str) -> Result<ServerRole, ServerConfigError> {
match value.trim().to_ascii_lowercase().as_str() {
"local" => Ok(ServerRole::Local),
"member" => Ok(ServerRole::Member),
"client" => Ok(ServerRole::Client),
_ => Err(ServerConfigError::InvalidRole(value.to_owned())),
}
}
fn parse_cluster_start(value: &str) -> Result<ClusterStartMode, ServerConfigError> {
match value.trim().to_ascii_lowercase().as_str() {
"bootstrap" => Ok(ClusterStartMode::Bootstrap),
"join" => Ok(ClusterStartMode::Join),
_ => Err(ServerConfigError::InvalidClusterStart(value.to_owned())),
}
}
fn apply_statefulset_env(
config: &mut ServerConfig,
cluster_start_explicit: bool,
) -> Result<(), ServerConfigError> {
let bootstrap_replicas = match env::var("HYDRACACHE_BOOTSTRAP_REPLICAS") {
Ok(value) => {
let replicas = value
.parse::<u32>()
.map_err(|_| ServerConfigError::InvalidBootstrapReplicas(value.clone()))?;
if replicas == 0 {
return Err(ServerConfigError::InvalidBootstrapReplicas(value));
}
Some(replicas)
}
Err(_) => None,
};
let headless_service = env::var("HYDRACACHE_CLUSTER_HEADLESS_SERVICE")
.ok()
.filter(|value| non_empty(value));
if bootstrap_replicas.is_none() && headless_service.is_none() {
return Ok(());
}
let hostname = env::var("HOSTNAME")
.map_err(|_| ServerConfigError::InvalidStatefulSetHostname(String::new()))
.and_then(|value| {
if non_empty(&value) {
Ok(value)
} else {
Err(ServerConfigError::InvalidStatefulSetHostname(value))
}
})?;
if config.node_id.is_none() {
config.node_id = Some(hostname.clone());
}
if let Some(headless) = headless_service {
if config.cluster_advertise_addr.is_none() {
config.cluster_advertise_addr = Some(format!(
"{hostname}.{headless}:{}",
config.cluster_addr.port()
));
}
}
if let Some(replicas) = bootstrap_replicas {
if !cluster_start_explicit {
let ordinal = statefulset_ordinal(&hostname)
.ok_or_else(|| ServerConfigError::InvalidStatefulSetHostname(hostname.clone()))?;
config.cluster_start = if ordinal < replicas {
ClusterStartMode::Bootstrap
} else {
ClusterStartMode::Join
};
}
}
Ok(())
}
fn statefulset_ordinal(hostname: &str) -> Option<u32> {
let (_, ordinal) = hostname.rsplit_once('-')?;
ordinal.parse().ok()
}
fn validate_cluster_auth_pair(
key_id: Option<&str>,
token_file: Option<&Path>,
section: &'static str,
) -> Result<(), ServerConfigError> {
let has_key = key_id.is_some_and(non_empty);
let has_file = token_file.is_some_and(non_empty_path);
if has_key != has_file {
return Err(ServerConfigError::IncompleteClusterAuth { section });
}
let Some(path) = token_file else {
return Ok(());
};
let token =
fs::read_to_string(path).map_err(|source| ServerConfigError::ClusterAuthTokenRead {
section,
path: path.to_path_buf(),
source,
})?;
if token.trim().is_empty() {
return Err(ServerConfigError::EmptyClusterAuthToken {
section,
path: path.to_path_buf(),
});
}
Ok(())
}
fn non_empty(value: &str) -> bool {
!value.trim().is_empty()
}
fn non_empty_path(path: &Path) -> bool {
!path.as_os_str().is_empty()
}
fn invalid_cluster_advertise_addr(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return true;
}
value
.parse::<SocketAddr>()
.is_ok_and(|addr| addr.ip().is_unspecified())
}
fn is_loopback(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => ip.is_loopback(),
IpAddr::V6(ip) => ip.is_loopback(),
}
}