diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a8dc016 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,137 @@ +// Package config loads, defaults, and validates the Cyrene v2 configuration +// from environment variables and an optional .env file (development only). +// +// Every setting is sourced from an environment variable (no hard-coded values +// in code). Nested groups map to struct fields tagged with `env:"NAME"`. +// Missing required values cause Load/MustLoad to return an error so startup +// can fail fast. +package config + +import ( + "fmt" + "reflect" + "strings" +) + +// Config is the root configuration tree. +type Config struct { + Env string `env:"ENV" default:"development"` + LogLevel string `env:"LOG_LEVEL" default:"debug"` + Timezone string `env:"TZ" default:"Asia/Shanghai"` + DataDir string `env:"DATA_DIR" default:"./data"` + AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"` + + Server ServerConfig + Auth AuthConfig + DB DBConfig + LLM LLMConfig + Embedding EmbeddingConfig + IoT IoTConfig + Voice VoiceConfig + Background BackgroundConfig + WSHub WSHubConfig +} + +// ServerConfig holds the single gateway listener settings. +type ServerConfig struct { + Host string `env:"HOST" default:"0.0.0.0"` + Port int `env:"PORT" default:"8080"` +} + +// AuthConfig holds authentication and admin credential settings. +type AuthConfig struct { + JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"` + AdminUser string `env:"ADMIN_USERNAME" default:"admin"` + AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"` + RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"` +} + +// DBConfig holds database connection settings. SQLite is the default; PGURL is +// the optional PostgreSQL+pgvector enhancement. +type DBConfig struct { + SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"` + PGURL string `env:"DB_PG_URL"` +} + +// LLMConfig holds the multi-supplier LLM adapter settings. +type LLMConfig struct { + BaseURL string `env:"LLM_BASE_URL"` + APIKey string `env:"LLM_API_KEY"` + Model string `env:"LLM_MODEL"` + Fallback string `env:"LLM_FALLBACK_MODEL"` +} + +// EmbeddingConfig holds the optional semantic embedding API settings. When +// unset, the runtime falls back to built-in n-gram/FTS vectors. +type EmbeddingConfig struct { + APIURL string `env:"EMBEDDING_API_URL"` + APIKey string `env:"EMBEDDING_API_KEY"` + Model string `env:"EMBEDDING_MODEL"` +} + +// IoTConfig selects the IoT device provider. +type IoTConfig struct { + Provider string `env:"IOT_PROVIDER" default:"mock"` +} + +// VoiceConfig holds TTS/STT provider settings. +type VoiceConfig struct { + TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"` + STTProvider string `env:"STT_PROVIDER" default:"dashscope"` + DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"` +} + +// BackgroundConfig holds the autonomous thinking settings. +type BackgroundConfig struct { + Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"` + IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"` + SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"` +} + +// WSHubConfig holds the WebSocket hub limits. +type WSHubConfig struct { + MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"` + SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"` +} + +// Load reads configuration, merging an optional .env file (development only) +// with process environment variables. Process environment variables always take +// precedence over .env values, which take precedence over code defaults. +// +// Passing an empty envPath skips .env entirely. A missing .env file is not an +// error; only missing required values are. +func Load(envPath string) (*Config, error) { + fromEnv := map[string]string{} + if envPath != "" { + m, err := parseDotEnv(envPath) + if err != nil { + return nil, fmt.Errorf("config: read %s: %w", envPath, err) + } + fromEnv = m + } + + cfg := &Config{} + if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil { + return nil, err + } + + if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 { + parts := make([]string, 0, len(errs)) + for _, e := range errs { + parts = append(parts, e.Error()) + } + return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; ")) + } + + return cfg, nil +} + +// MustLoad is Load that panics on error. It is intended for startup, matching +// the "schema validation fails at boot" rule. +func MustLoad(envPath string) *Config { + cfg, err := Load(envPath) + if err != nil { + panic(err) + } + return cfg +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e8df32e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func writeEnvFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + return path +} + +func TestLoad_DefaultsWhenUnset(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.Env != "development" { + t.Errorf("Env = %q, want development", cfg.Env) + } + if cfg.Server.Host != "0.0.0.0" || cfg.Server.Port != 8080 { + t.Errorf("Server = %+v, want 0.0.0.0:8080", cfg.Server) + } + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want debug", cfg.LogLevel) + } + if cfg.DB.SQLitePath != "./data/cyrene.db" { + t.Errorf("SQLitePath = %q, want ./data/cyrene.db", cfg.DB.SQLitePath) + } + want := []string{"http://localhost:5173", "http://localhost:3000"} + if !reflect.DeepEqual(cfg.AllowedOrigins, want) { + t.Errorf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want) + } +} + +func TestLoad_EnvOverridesDefault(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + t.Setenv("PORT", "9090") + t.Setenv("LOG_LEVEL", "warn") + t.Setenv("IOT_PROVIDER", "none") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Server.Port != 9090 { + t.Errorf("Port = %d, want 9090", cfg.Server.Port) + } + if cfg.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want warn", cfg.LogLevel) + } + if cfg.IoT.Provider != "none" { + t.Errorf("IoT.Provider = %q, want none", cfg.IoT.Provider) + } +} + +func TestLoad_DotEnvThenProcessEnv(t *testing.T) { + path := writeEnvFile(t, `PORT=7000 +LOG_LEVEL=error +# comment line +IOT_PROVIDER=none +`) + // Process env overrides .env for PORT but leaves LOG_LEVEL from .env. + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + t.Setenv("PORT", "8000") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load(%q) error: %v", path, err) + } + if cfg.Server.Port != 8000 { + t.Errorf("Port = %d, want 8000 (process env wins)", cfg.Server.Port) + } + if cfg.LogLevel != "error" { + t.Errorf("LogLevel = %q, want error from .env", cfg.LogLevel) + } + if cfg.IoT.Provider != "none" { + t.Errorf("IoT.Provider = %q, want none from .env", cfg.IoT.Provider) + } +} + +func TestLoad_MissingRequiredFails(t *testing.T) { + // Do not set JWT_SECRET. + if _, err := Load(""); err == nil { + t.Fatal("expected error when JWT_SECRET missing, got nil") + } else if !strings.Contains(err.Error(), "JWT_SECRET") { + t.Errorf("error should mention JWT_SECRET, got: %v", err) + } +} + +func TestLoad_ValidationMinLength(t *testing.T) { + t.Setenv("JWT_SECRET", "short") + if _, err := Load(""); err == nil { + t.Fatal("expected error for JWT_SECRET shorter than 32, got nil") + } else if !strings.Contains(err.Error(), "at least 32") { + t.Errorf("error should mention min length, got: %v", err) + } +} + +func TestLoad_InvalidPortFails(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + t.Setenv("PORT", "not-a-number") + if _, err := Load(""); err == nil { + t.Fatal("expected error for invalid PORT, got nil") + } else if !strings.Contains(err.Error(), "PORT") { + t.Errorf("error should mention PORT, got: %v", err) + } +} + +func TestLoad_MissingDotEnvIsTolerated(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + // A non-existent .env path must not error. + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env")) + if err != nil { + t.Fatalf("Load(nonexistent) error: %v", err) + } + if cfg.Server.Port != 8080 { + t.Errorf("Port = %d, want default 8080", cfg.Server.Port) + } +} + +func TestParseDotEnv_QuotedAndExport(t *testing.T) { + path := writeEnvFile(t, `export FOO=bar +BAZ='quoted value' +EMPTY= +`) + m, err := parseDotEnv(path) + if err != nil { + t.Fatalf("parseDotEnv error: %v", err) + } + if m["FOO"] != "bar" { + t.Errorf("FOO = %q, want bar", m["FOO"]) + } + if m["BAZ"] != "quoted value" { + t.Errorf("BAZ = %q, want 'quoted value'", m["BAZ"]) + } + if v, ok := m["EMPTY"]; !ok || v != "" { + t.Errorf("EMPTY = %q (present=%v), want empty", v, ok) + } +} diff --git a/internal/config/decode.go b/internal/config/decode.go new file mode 100644 index 0000000..3f3a863 --- /dev/null +++ b/internal/config/decode.go @@ -0,0 +1,127 @@ +package config + +import ( + "fmt" + "os" + "reflect" + "strconv" + "strings" +) + +// decodeValue populates the struct pointed to by v. Precedence, highest first: +// process environment variable, the parsed .env file, then the default tag. +func decodeValue(v reflect.Value, fromEnv map[string]string) error { + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("config: decode expects a pointer to a struct") + } + return decodeStruct(v.Elem(), fromEnv) +} + +func decodeStruct(sv reflect.Value, fromEnv map[string]string) error { + st := sv.Type() + for i := 0; i < st.NumField(); i++ { + field := st.Field(i) + if !field.IsExported() { + continue + } + fv := sv.Field(i) + + envTag, hasEnv := field.Tag.Lookup("env") + if !hasEnv { + // Untagged struct fields are nested config groups. + if fv.Kind() == reflect.Struct { + if err := decodeStruct(fv, fromEnv); err != nil { + return err + } + } + continue + } + + name := envTagName(envTag) + raw := lookupRaw(name, field.Tag.Get("default"), fromEnv) + if err := setField(fv, raw); err != nil { + return fmt.Errorf("config: %s: %w", name, err) + } + } + return nil +} + +// envTagName extracts the variable name from an env tag (dropping any +// comma-separated options that may be added later). +func envTagName(tag string) string { + name, _, _ := strings.Cut(tag, ",") + return strings.TrimSpace(name) +} + +// lookupRaw returns the effective raw value for key: process env, then .env, +// then default. +func lookupRaw(key, def string, fromEnv map[string]string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + if v, ok := fromEnv[key]; ok { + return v + } + return def +} + +func setField(fv reflect.Value, raw string) error { + switch fv.Kind() { + case reflect.String: + fv.SetString(raw) + return nil + case reflect.Bool: + return setBool(fv, raw) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return setInt(fv, raw) + case reflect.Slice: + return setStringSlice(fv, raw) + default: + return fmt.Errorf("unsupported kind %s", fv.Kind()) + } +} + +func setBool(fv reflect.Value, raw string) error { + if raw == "" { + fv.SetBool(false) + return nil + } + b, err := strconv.ParseBool(strings.ToLower(raw)) + if err != nil { + return fmt.Errorf("invalid boolean %q", raw) + } + fv.SetBool(b) + return nil +} + +func setInt(fv reflect.Value, raw string) error { + if raw == "" { + fv.SetInt(0) + return nil + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid integer %q", raw) + } + fv.SetInt(n) + return nil +} + +func setStringSlice(fv reflect.Value, raw string) error { + if fv.Type().Elem().Kind() != reflect.String { + return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind()) + } + if strings.TrimSpace(raw) == "" { + fv.Set(reflect.MakeSlice(fv.Type(), 0, 0)) + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + fv.Set(reflect.ValueOf(out)) + return nil +} diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go new file mode 100644 index 0000000..5646c38 --- /dev/null +++ b/internal/config/dotenv.go @@ -0,0 +1,39 @@ +package config + +import ( + "os" + "strings" +) + +// parseDotEnv loads a KEY=VALUE .env file into a map. It is tolerant by design: +// a missing file yields an empty map and no error, because .env is a +// development-only convenience. Values may be single- or double-quoted and may +// carry an optional `export` prefix. Lines starting with `#` and blank lines +// are ignored. +func parseDotEnv(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + + env := make(map[string]string) + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + val = strings.Trim(val, `"'`) + env[key] = val + } + return env, nil +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..32f2482 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,97 @@ +package config + +import ( + "fmt" + "reflect" + "strings" +) + +// validateStruct returns a list of human-readable validation errors found while +// walking the struct tree. Validation is driven by the `validate` struct tag, +// which holds comma-separated rules. Supported rules: required, min=N. +func validateStruct(sv reflect.Value) []error { + var errs []error + st := sv.Type() + for i := 0; i < st.NumField(); i++ { + field := st.Field(i) + if !field.IsExported() { + continue + } + fv := sv.Field(i) + + if rules := field.Tag.Get("validate"); rules != "" { + name := envTagName(field.Tag.Get("env")) + if name == "" { + name = field.Name + } + errs = append(errs, applyRules(name, rules, fv)...) + } + if fv.Kind() == reflect.Struct { + errs = append(errs, validateStruct(fv)...) + } + } + return errs +} + +func applyRules(name, rules string, fv reflect.Value) []error { + var errs []error + for _, rule := range strings.Split(rules, ",") { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + ruleName, arg, _ := strings.Cut(rule, "=") + switch ruleName { + case "required": + if isZero(fv) { + errs = append(errs, fmt.Errorf("%s is required", name)) + } + case "min": + if err := applyMin(name, arg, fv); err != nil { + errs = append(errs, err) + } + } + } + return errs +} + +func isZero(fv reflect.Value) bool { + switch fv.Kind() { + case reflect.String: + return fv.String() == "" + case reflect.Bool: + return !fv.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return fv.Int() == 0 + default: + return fv.IsZero() + } +} + +func applyMin(name, arg string, fv reflect.Value) error { + if fv.Kind() != reflect.String { + return nil + } + min, err := parseIntArg(arg) + if err != nil { + return nil + } + if len(fv.String()) < min { + return fmt.Errorf("%s must be at least %d characters", name, min) + } + return nil +} + +func parseIntArg(arg string) (int, error) { + if arg == "" { + return 0, fmt.Errorf("missing min argument") + } + n := 0 + for _, c := range arg { + if c < '0' || c > '9' { + return 0, fmt.Errorf("invalid min argument %q", arg) + } + n = n*10 + int(c-'0') + } + return n, nil +}