feat(internal): add foundational packages — config, logger, errors
- 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
This commit is contained in:
1 parent
b69b74435a
commit
7943e290ec
11 files changed
+1590
No files matched your search
@@ -0,0 +1,311 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user