Files
pekoe/internal/config/config.go
2026-04-03 00:09:40 +03:00

170 lines
4.7 KiB
Go

// @tag config
// @tag core
package config
import (
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"time"
"github.com/eleksir/pekoe/internal/logger"
)
// Constants for default values - chosen to be production-ready defaults
const (
DefaultBind = "0.0.0.0"
DefaultPort = 6379
DefaultMaxClients = 10000
DefaultReadTimeout = 0 // 0 means no timeout (blocking I/O)
DefaultWriteTimeout = 0 // 0 means no timeout
DefaultMaxMsgBytes = 512 * 1024 * 1024 // 512 MB - allows large messages
DefaultLogLevel = "info"
DefaultLogFile = "" // empty = stdout only
DefaultPidFile = "" // empty = no PID file created
)
type Config struct {
Bind string `json:"bind"`
Port int `json:"port"`
MaxClients int `json:"max_clients"`
ReadTimeout int `json:"read_timeout"` // in seconds, 0 = no timeout
WriteTimeout int `json:"write_timeout"` // in seconds, 0 = no timeout
MaxMsgBytes int `json:"max_message_bytes"`
LogLevel string `json:"log_level"`
LogFile string `json:"log_file"` // empty = stdout only
PidFile string `json:"pid_file"` // empty = no PID file
}
// SetDefaults applies sensible defaults when config values are missing or invalid.
// This ensures the server can start even with incomplete configuration.
// nolint: cyclop
func (c *Config) SetDefaults() {
if c.Bind == "" {
c.Bind = DefaultBind
}
if c.Port <= 0 || c.Port > 65535 {
c.Port = DefaultPort
}
if c.MaxClients <= 0 {
c.MaxClients = DefaultMaxClients
}
if c.ReadTimeout < 0 {
c.ReadTimeout = 0
}
if c.WriteTimeout < 0 {
c.WriteTimeout = 0
}
if c.MaxMsgBytes <= 0 {
c.MaxMsgBytes = DefaultMaxMsgBytes
}
if c.LogLevel == "" {
c.LogLevel = DefaultLogLevel
}
if c.LogFile == "" {
c.LogFile = DefaultLogFile
}
if c.PidFile == "" {
c.PidFile = DefaultPidFile
}
}
// Validate checks that all configuration values are within acceptable bounds.
// Returns an error if validation fails, which should prevent server startup.
func (c *Config) Validate() error {
if c.Bind == "" {
return fmt.Errorf("bind address cannot be empty")
}
if net.ParseIP(c.Bind) == nil {
return fmt.Errorf("bind must be a valid IP address (e.g. 0.0.0.0, ::, ::1, 192.168.1.1)")
}
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
if c.MaxClients <= 0 {
return fmt.Errorf("max_clients must be positive")
}
if c.MaxMsgBytes <= 0 {
return fmt.Errorf("max_message_bytes must be positive")
}
// Validate log level against allowed values
validLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLevels[c.LogLevel] {
return fmt.Errorf("invalid log_level: %s (must be debug, info, warn, or error)", c.LogLevel)
}
return nil
}
// ReadTimeoutDuration converts the config's read timeout (in seconds) to a Duration.
// Zero means no timeout, allowing the server to block on reads indefinitely.
func (c *Config) ReadTimeoutDuration() time.Duration {
if c.ReadTimeout == 0 {
return 0
}
return time.Duration(c.ReadTimeout) * time.Second
}
// WriteTimeoutDuration converts the config's write timeout (in seconds) to a Duration.
func (c *Config) WriteTimeoutDuration() time.Duration {
if c.WriteTimeout == 0 {
return 0
}
return time.Duration(c.WriteTimeout) * time.Second
}
// Load reads and parses the configuration from a JSON file.
// It applies defaults for missing/invalid values and validates the final config.
func Load(path string) (*Config, error) {
//nolint: gosec
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Apply defaults before validation to ensure minimum viable config
cfg.SetDefaults()
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
logger.SetLevel(cfg.LogLevel)
if cfg.LogFile != "" {
if err := logger.SetOutput(cfg.LogFile); err != nil {
return nil, fmt.Errorf("failed to set log file: %w", err)
}
}
return &cfg, nil
}
// Addr returns the bind address in host:port format for use in net.Listen.
// IPv6 addresses are wrapped in brackets: [::]:6379.
func (c *Config) Addr() string {
ip := net.ParseIP(c.Bind)
if ip != nil && ip.To4() == nil {
// IPv6 address — wrap in brackets
return "[" + c.Bind + "]:" + strconv.Itoa(c.Port)
}
return c.Bind + ":" + strconv.Itoa(c.Port)
}
// String returns a JSON representation of the config for debugging.
func (c *Config) String() string {
b, _ := json.MarshalIndent(c, "", " ")
return string(b)
}