// Package config loads application configuration from environment variables. package config import ( "errors" "fmt" "log/slog" "os" "strconv" "time" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" // Populates the environment from a ./.env file automatically for // development configuration. Kept in one place only (here). _ "github.com/joho/godotenv/autoload" ) const ( // EnvironmentDev represents development environment. EnvironmentDev = "dev" // EnvironmentProd represents production environment. EnvironmentProd = "prod" // defaultPort is the default HTTP listen port. defaultPort = 8080 // defaultRetentionSweepInterval is how often the retention // reaper deletes events older than each webhook's RetentionDays. defaultRetentionSweepInterval = time.Hour // maxPort is the highest valid TCP port number. The lower // bound (at least 1) is enforced by envPositiveInt. maxPort = 65535 ) // ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT // contains an unrecognised value. var ErrInvalidEnvironment = errors.New("invalid environment") // ErrNonPositiveValue is returned when an environment variable that // requires a positive integer is set to zero or a negative number. var ErrNonPositiveValue = errors.New("value must be positive") // ErrInvalidPort is returned when an environment variable holding a // TCP port number is set above the valid port range. var ErrInvalidPort = errors.New("invalid port") //nolint:revive // ConfigParams is a standard fx naming convention. type ConfigParams struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds all application configuration loaded from // environment variables. type Config struct { DataDir string Debug bool MaintenanceMode bool Environment string MetricsPassword string MetricsUsername string Port int SentryDSN string // RetentionSweepInterval is how often the retention reaper runs. RetentionSweepInterval time.Duration params *ConfigParams log *slog.Logger } // IsDev returns true if running in development environment. func (c *Config) IsDev() bool { return c.Environment == EnvironmentDev } // IsProd returns true if running in production environment. func (c *Config) IsProd() bool { return c.Environment == EnvironmentProd } // envString returns the value of the named environment variable, // or an empty string if not set. func envString(key string) string { return os.Getenv(key) } // envBool returns the value of the named environment variable // parsed as a boolean. Returns defaultValue if not set. If the // variable is set but cannot be parsed, it returns a wrapped error // naming the key and the bad value, so startup fails loudly rather // than silently falling back to the default. // // Parsing is strconv.ParseBool, which accepts 1, t, T, TRUE, true, // True, 0, f, F, FALSE, false and False. Anything else — "yes", // "on", or a typo like "ture" — is an error rather than a silent // false. func envBool(key string, defaultValue bool) (bool, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf( "invalid boolean for %s: %q: %w", key, v, err, ) } return b, nil } // envPositiveInt returns the value of the named environment variable // parsed as a positive integer. Returns defaultValue if not set. If // the variable is set but cannot be parsed, or parses to less than // one, it returns a wrapped error naming the key and the bad value, // so startup fails loudly rather than silently falling back to the // default. func envPositiveInt( key string, defaultValue int, ) (int, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } i, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf( "invalid integer for %s: %q: %w", key, v, err, ) } if i < 1 { return 0, fmt.Errorf( "%w: %s must be at least 1, got %q", ErrNonPositiveValue, key, v, ) } return i, nil } // envPort returns the value of the named environment variable parsed // as a TCP port number. Returns defaultValue if not set. A set value // that is unparseable, below 1, or above maxPort is a hard error // naming the key and the bad value. func envPort(key string, defaultValue int) (int, error) { port, err := envPositiveInt(key, defaultValue) if err != nil { return 0, err } if port > maxPort { return 0, fmt.Errorf( "%w: %s must be at most %d, got %d", ErrInvalidPort, key, maxPort, port, ) } return port, nil } // envDuration returns the value of the named environment variable // parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if // not set. If the variable is set but cannot be parsed, it returns a // wrapped error naming the key and the bad value, so startup fails // loudly rather than silently falling back to the default. func envDuration( key string, defaultValue time.Duration, ) (time.Duration, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf( "invalid duration for %s: %q: %w", key, v, err, ) } return d, nil } // resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to // dev, and rejects unrecognised values. func resolveEnvironment() (string, error) { environment := os.Getenv("WEBHOOKER_ENVIRONMENT") if environment == "" { environment = EnvironmentDev } if environment != EnvironmentDev && environment != EnvironmentProd { return "", fmt.Errorf( "%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'", ErrInvalidEnvironment, EnvironmentDev, EnvironmentProd, environment, ) } return environment, nil } // loadFromEnv builds a Config from the environment. Every value that // needs parsing fails loudly when it is set but unparseable: the // documented defaults apply only to variables that are unset (or // empty), never as a substitute for a value the operator actually // provided. func loadFromEnv() (*Config, error) { environment, err := resolveEnvironment() if err != nil { return nil, err } port, err := envPort("PORT", defaultPort) if err != nil { return nil, err } debug, err := envBool("DEBUG", false) if err != nil { return nil, err } maintenanceMode, err := envBool("MAINTENANCE_MODE", false) if err != nil { return nil, err } retentionSweepInterval, err := envDuration( "RETENTION_SWEEP_INTERVAL", defaultRetentionSweepInterval, ) if err != nil { return nil, err } return &Config{ DataDir: envString("DATA_DIR"), Debug: debug, MaintenanceMode: maintenanceMode, Environment: environment, MetricsUsername: envString("METRICS_USERNAME"), MetricsPassword: envString("METRICS_PASSWORD"), Port: port, SentryDSN: envString("SENTRY_DSN"), RetentionSweepInterval: retentionSweepInterval, }, nil } // New creates a Config by reading environment variables. // //nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { log := params.Logger.Get() // A set-but-unparseable value anywhere in the environment is a // hard error, so fx aborts startup rather than running with a // silently substituted default. s, err := loadFromEnv() if err != nil { return nil, err } s.log = log s.params = ¶ms // Set default DataDir. All SQLite databases (main application // DB and per-webhook event DBs) live here. The same default is // used regardless of environment; override with DATA_DIR if // needed. if s.DataDir == "" { s.DataDir = "/var/lib/webhooker" } if s.Debug { params.Logger.EnableDebugLogging() } // Log configuration summary (without secrets) log.Info("Configuration loaded", "environment", s.Environment, "port", s.Port, "debug", s.Debug, "maintenanceMode", s.MaintenanceMode, "dataDir", s.DataDir, "retentionSweepInterval", s.RetentionSweepInterval.String(), "hasSentryDSN", s.SentryDSN != "", "hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "", ) return s, nil }