// 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 "" }