kereru-forms/backend/internal/config/config.go

280 lines
8.3 KiB
Go

// Package config loads application configuration from defaults, a YAML/JSON
// document, environment variables (optionally via a .env file) and CLI flags.
//
// Precedence, highest first:
// 1. CLI flags (--conf, --log-level, --listen-addr, --listen-addr6, --port)
// 2. Environment variables
// 3. .env file located next to the executable or in the working directory
// 4. Configuration file supplied with --conf
// 5. Built-in defaults
package config
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds every runtime setting supported by kereru-forms.
type Config struct {
// LogLevel is the minimum log level (trace, debug, info, warn, error).
LogLevel string `yaml:"logLevel" json:"logLevel"`
// LogFormat is "json" or "console".
LogFormat string `yaml:"logFormat" json:"logFormat"`
// Environment is a free-form deployment label (development, staging, production).
Environment string `yaml:"environment" json:"environment"`
// HTTPAddr is the IPv4 address to listen on. Empty disables that listener.
HTTPAddr string `yaml:"listenAddr" json:"listenAddr"`
// HTTPAddr6 is the IPv6 address to listen on. Empty disables that listener.
HTTPAddr6 string `yaml:"listenAddr6" json:"listenAddr6"`
// Port is the TCP port shared by both listeners.
Port string `yaml:"port" json:"port"`
// DBDriver selects the storage backend. Currently "sqlite" is implemented.
DBDriver string `yaml:"dbDriver" json:"dbDriver"`
// DBDSN is the driver-specific connection string.
DBDSN string `yaml:"dbDsn" json:"dbDsn"`
// TelemetryEnabled turns on OpenTelemetry tracing/metrics export.
TelemetryEnabled bool `yaml:"telemetryEnabled" json:"telemetryEnabled"`
// OTLPEndpoint is the OTLP/HTTP collector endpoint (host:port or URL).
OTLPEndpoint string `yaml:"otlpEndpoint" json:"otlpEndpoint"`
// CORSOrigins is the list of allowed browser origins ("*" allows all).
CORSOrigins []string `yaml:"corsOrigins" json:"corsOrigins"`
// StaticDir optionally serves a built frontend from this directory.
StaticDir string `yaml:"staticDir" json:"staticDir"`
}
// Default returns the built-in configuration.
func Default() *Config {
return &Config{
LogLevel: "info",
LogFormat: "console",
Environment: "development",
HTTPAddr: "0.0.0.0",
HTTPAddr6: "",
Port: "8080",
DBDriver: "sqlite",
DBDSN: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
TelemetryEnabled: false,
OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"},
StaticDir: "",
}
}
// Load builds the configuration from args (usually os.Args[1:]).
func Load(args []string) (*Config, error) {
cfg := Default()
dotEnvPath := LoadDotEnv()
_ = dotEnvPath
confPath := scanConf(args)
if confPath != "" {
if err := loadFile(cfg, confPath); err != nil {
return nil, err
}
}
applyEnv(cfg)
fs := flag.NewFlagSet("kereru-forms", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
flagConf = fs.String("conf", confPath, "path to a YAML or JSON configuration file")
flagLevel = fs.String("log-level", cfg.LogLevel, "log level: trace, debug, info, warn, error")
flagFormat = fs.String("log-format", cfg.LogFormat, "log format: console or json")
flagAddr = fs.String("listen-addr", cfg.HTTPAddr, "IPv4 listen address (empty to disable)")
flagAddr6 = fs.String("listen-addr6", cfg.HTTPAddr6, "IPv6 listen address (empty to disable)")
flagPort = fs.String("port", cfg.Port, "TCP listen port")
flagDB = fs.String("db-dsn", cfg.DBDSN, "database connection string")
flagDriver = fs.String("db-driver", cfg.DBDriver, "database driver (sqlite)")
flagStatic = fs.String("static-dir", cfg.StaticDir, "directory containing a built frontend (optional)")
)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: kereru-forms [flags]\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return nil, err
}
_ = flagConf
cfg.LogLevel = *flagLevel
cfg.LogFormat = *flagFormat
cfg.HTTPAddr = *flagAddr
cfg.HTTPAddr6 = *flagAddr6
cfg.Port = *flagPort
cfg.DBDSN = *flagDB
cfg.DBDriver = *flagDriver
cfg.StaticDir = *flagStatic
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is usable.
func (c *Config) Validate() error {
if strings.TrimSpace(c.Port) == "" {
return errors.New("port must not be empty")
}
if _, err := strconv.Atoi(c.Port); err != nil {
return fmt.Errorf("port %q is not a number", c.Port)
}
if c.HTTPAddr == "" && c.HTTPAddr6 == "" {
return errors.New("at least one of listenAddr or listenAddr6 must be set")
}
if strings.TrimSpace(c.DBDriver) == "" {
return errors.New("dbDriver must not be empty")
}
if strings.TrimSpace(c.DBDSN) == "" {
return errors.New("dbDsn must not be empty")
}
return nil
}
// Addresses returns the listen addresses (host:port) for enabled listeners.
func (c *Config) Addresses() []string {
var addrs []string
if c.HTTPAddr != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr, c.Port))
}
if c.HTTPAddr6 != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr6, c.Port))
}
return addrs
}
func joinHostPort(host, port string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]:" + port
}
return host + ":" + port
}
func scanConf(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--conf" || arg == "-conf" {
if i+1 < len(args) {
return args[i+1]
}
}
if strings.HasPrefix(arg, "--conf=") {
return strings.TrimPrefix(arg, "--conf=")
}
if strings.HasPrefix(arg, "-conf=") {
return strings.TrimPrefix(arg, "-conf=")
}
}
return ""
}
func loadFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if strings.EqualFold(filepath.Ext(path), ".json") {
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse JSON config %s: %w", path, err)
}
return nil
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse YAML config %s: %w", path, err)
}
return nil
}
func applyEnv(cfg *Config) {
setString(&cfg.LogLevel, "KERERU_LOG_LEVEL")
setString(&cfg.LogFormat, "KERERU_LOG_FORMAT")
setString(&cfg.Environment, "KERERU_ENVIRONMENT")
setString(&cfg.HTTPAddr, "KERERU_LISTEN_ADDR")
setString(&cfg.HTTPAddr6, "KERERU_LISTEN_ADDR6")
setString(&cfg.Port, "KERERU_PORT")
setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN")
setString(&cfg.StaticDir, "KERERU_STATIC_DIR")
if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b
}
}
setString(&cfg.OTLPEndpoint, "OTEL_EXPORTER_OTLP_ENDPOINT")
if v, ok := os.LookupEnv("KERERU_CORS_ORIGINS"); ok {
var origins []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
origins = append(origins, p)
}
}
if len(origins) > 0 {
cfg.CORSOrigins = origins
}
}
}
func setString(dst *string, key string) {
if v, ok := os.LookupEnv(key); ok {
*dst = v
}
}
// LoadDotEnv reads a .env file from the directory containing the executable
// or, failing that, the working directory. Existing environment variables are never
// overwritten. It returns the path that was loaded, if any.
func LoadDotEnv() string {
candidateDirs := []string{}
if exe, err := os.Executable(); err == nil {
candidateDirs = append(candidateDirs, filepath.Dir(exe))
}
if wd, err := os.Getwd(); err == nil {
candidateDirs = append(candidateDirs, wd)
}
for _, dir := range candidateDirs {
path := filepath.Join(dir, ".env")
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
return path
}
return ""
}