280 lines
8.3 KiB
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 ""
|
||
|
|
}
|