- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
312 lines
7.2 KiB
Go
312 lines
7.2 KiB
Go
// Package config provides configuration loading with environment variable binding and startup validation.
|
|
// All configuration values are loaded from environment variables with sensible defaults.
|
|
// Required fields that are missing will cause a fatal error at startup.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config holds all application configuration.
|
|
// Fields are populated from environment variables during Load().
|
|
type Config struct {
|
|
// Environment
|
|
Env string // "development" | "production" | "test"
|
|
LogLevel string // "debug" | "info" | "warn" | "error"
|
|
LogFmt string // "text" | "json"
|
|
|
|
// Server
|
|
Port int
|
|
Host string
|
|
GinMode string // "debug" | "release" | "test"
|
|
|
|
// Database
|
|
DatabaseURL string // Full connection string (takes priority)
|
|
PostgresHost string
|
|
PostgresPort int
|
|
PostgresUser string
|
|
PostgresPassword string
|
|
PostgresDB string
|
|
PostgresSSLMode string
|
|
|
|
// NATS
|
|
NATSUrl string
|
|
|
|
// Redis
|
|
RedisHost string
|
|
RedisPort int
|
|
|
|
// LLM
|
|
LLMBaseURL string
|
|
LLMAPIKey string
|
|
LLMModel string
|
|
LLMFallbackModel string
|
|
|
|
// Embedding
|
|
EmbeddingAPIURL string
|
|
EmbeddingModel string
|
|
EmbeddingAPIKey string
|
|
|
|
// Voice
|
|
DashScopeAPIKey string
|
|
TTSProvider string // "edge-tts" | "espeak"
|
|
ASRProvider string // "dashscope" | "whisper-local"
|
|
|
|
// Auth
|
|
JWTSecret string
|
|
JWTExpiryHours int
|
|
AdminUsername string
|
|
AdminPassword string
|
|
RegistrationOpen bool
|
|
|
|
// Internal Service
|
|
InternalServiceToken string
|
|
|
|
// CORS
|
|
AllowedOrigins []string
|
|
|
|
// Background Thinking
|
|
EnableBackgroundThinking bool
|
|
ThinkIntervalSec int
|
|
ThinkSilenceTimeoutSec int
|
|
|
|
// Platform Bridge
|
|
PlatformBridgeEnabled bool
|
|
PlatformChannels string
|
|
|
|
// IoT
|
|
IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none"
|
|
|
|
// IoT Debug Service URL (legacy, kept for v1 compat)
|
|
IoTServiceURL string
|
|
|
|
// RAG
|
|
PersonaDir string
|
|
KnowledgeDir string
|
|
DataDir string
|
|
|
|
// Search
|
|
SearXNGURL string
|
|
|
|
// Host Exec
|
|
HostExecBackend string // "wsl" | "docker" | "direct" | ""
|
|
|
|
// Caddy (production)
|
|
Domain string
|
|
ACMEEmail string
|
|
|
|
// Ethend (deprecated, kept for compat)
|
|
EthendPort int
|
|
|
|
// WebSocket
|
|
WSMaxConnections int
|
|
SessionIdleTimeoutMin int
|
|
|
|
// Timezone
|
|
TZ string
|
|
}
|
|
|
|
// DefaultConfig returns a Config populated with safe default values.
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
Env: "development",
|
|
LogLevel: "info",
|
|
LogFmt: "text",
|
|
|
|
Port: 8080,
|
|
Host: "0.0.0.0",
|
|
GinMode: "debug",
|
|
|
|
PostgresHost: "localhost",
|
|
PostgresPort: 5432,
|
|
PostgresUser: "cyrene",
|
|
PostgresPassword: "cyrene_pass",
|
|
PostgresDB: "cyrene_ai",
|
|
PostgresSSLMode: "disable",
|
|
|
|
NATSUrl: "nats://localhost:4222",
|
|
|
|
RedisHost: "localhost",
|
|
RedisPort: 6379,
|
|
|
|
LLMBaseURL: "https://api.openai.com/v1",
|
|
LLMModel: "gpt-4o",
|
|
LLMFallbackModel: "gpt-4o-mini",
|
|
|
|
EmbeddingModel: "text-embedding-3-small",
|
|
|
|
TTSProvider: "edge-tts",
|
|
ASRProvider: "dashscope",
|
|
|
|
JWTExpiryHours: 720,
|
|
AdminUsername: "admin",
|
|
RegistrationOpen: true,
|
|
|
|
EnableBackgroundThinking: true,
|
|
ThinkIntervalSec: 300,
|
|
ThinkSilenceTimeoutSec: 120,
|
|
|
|
IoTProvider: "mock",
|
|
|
|
PersonaDir: "./internal/persona",
|
|
KnowledgeDir: "./data/knowledge",
|
|
DataDir: "./data",
|
|
|
|
HostExecBackend: "",
|
|
|
|
EthendPort: 9090,
|
|
|
|
WSMaxConnections: 1000,
|
|
SessionIdleTimeoutMin: 30,
|
|
|
|
TZ: "Asia/Shanghai",
|
|
}
|
|
}
|
|
|
|
// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set.
|
|
func (c *Config) GetDatabaseURL() string {
|
|
if c.DatabaseURL != "" {
|
|
return c.DatabaseURL
|
|
}
|
|
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
|
c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode)
|
|
}
|
|
|
|
// IsDevelopment returns true when running in development mode.
|
|
func (c *Config) IsDevelopment() bool {
|
|
return c.Env == "development"
|
|
}
|
|
|
|
// IsProduction returns true when running in production mode.
|
|
func (c *Config) IsProduction() bool {
|
|
return c.Env == "production"
|
|
}
|
|
|
|
// validate runs all validation checks on the loaded config.
|
|
// Returns a list of validation errors.
|
|
func (c *Config) validate() []string {
|
|
var errs []string
|
|
|
|
// Required: LLM API Key (only required in production or when not using mock)
|
|
if c.LLMAPIKey == "" {
|
|
errs = append(errs, "LLM_API_KEY is required")
|
|
}
|
|
|
|
// Required: JWT Secret
|
|
if c.JWTSecret == "" {
|
|
errs = append(errs, "JWT_SECRET is required")
|
|
}
|
|
if len(c.JWTSecret) < 32 {
|
|
errs = append(errs, "JWT_SECRET must be at least 32 characters")
|
|
}
|
|
|
|
// Required: Admin Password
|
|
if c.AdminPassword == "" {
|
|
errs = append(errs, "ADMIN_PASSWORD is required")
|
|
}
|
|
if len(c.AdminPassword) < 8 {
|
|
errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters")
|
|
}
|
|
|
|
// Required: Internal Service Token
|
|
if c.InternalServiceToken == "" && c.IsProduction() {
|
|
errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production")
|
|
}
|
|
|
|
// IoT Provider must be valid
|
|
validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true}
|
|
if !validIoTProviders[c.IoTProvider] {
|
|
errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing.
|
|
func (c *Config) ValidateOrFatal() {
|
|
errs := c.validate()
|
|
if len(errs) > 0 {
|
|
fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n")
|
|
for _, e := range errs {
|
|
fmt.Fprintf(os.Stderr, " - %s\n", e)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "=======================================\n")
|
|
fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// --- Helper functions for loading from environment ---
|
|
|
|
func getEnv(key, defaultVal string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
func getEnvInt(key string, defaultVal int) int {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultVal
|
|
}
|
|
i, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal)
|
|
return defaultVal
|
|
}
|
|
return i
|
|
}
|
|
|
|
func getEnvBool(key string, defaultVal bool) bool {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultVal
|
|
}
|
|
switch strings.ToLower(v) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal)
|
|
return defaultVal
|
|
}
|
|
}
|
|
|
|
func getEnvSlice(key string, defaultVal []string) []string {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultVal
|
|
}
|
|
parts := strings.Split(v, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return defaultVal
|
|
}
|
|
return result
|
|
}
|
|
|
|
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultVal
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal)
|
|
return defaultVal
|
|
}
|
|
return d
|
|
}
|