Files
webhooker/internal/config/config.go
clawbot 985464dcf9
All checks were successful
check / check (push) Successful in 6m3s
Fail loudly on set-but-unparseable env config values (closes #80)
The config env helpers silently substituted the documented default
whenever a variable was set but could not be parsed, so a typo in an
operator-supplied value produced a running daemon with configuration
nobody asked for instead of a startup failure. `PORT=eighty` quietly
listened on 8080 and `DEBUG=ture` quietly disabled debug logging.

Defaults now apply only to variables that are unset or empty. Any
variable that is set but unparseable is a hard error that names the
key and the offending value and aborts startup through fx.

- add `envPositiveInt` with `ErrNonPositiveValue`, copied verbatim
  from the definition on the unmerged #87 so that rebasing after it
  lands is a delete-one-copy operation rather than a semantic merge
- remove `envInt` entirely; `PORT` is parsed by a new `envPort`,
  which adds the TCP upper bound (`ErrInvalidPort`, 1..65535)
- change `envBool` to return an error and parse with
  `strconv.ParseBool`, so `yes`, `on`, and typos are rejected rather
  than silently treated as false; callers are `DEBUG` and
  `MAINTENANCE_MODE`
- move env loading into `loadFromEnv`, with the environment check
  extracted to `resolveEnvironment` (also as #87 defines it), keeping
  `New` within the funlen budget

`envString` parses nothing and `envDuration` was already fail-loud,
so both are unchanged. A repo-wide audit of `os.Getenv`/`os.LookupEnv`
found no parse sites outside `internal/config`.

Tests cover each helper with a table (unset, valid, set-but-invalid)
plus `config.New`-level cases proving a bad `PORT`, `DEBUG`, or
`MAINTENANCE_MODE` aborts startup while unset variables still get
their defaults. README documents the fail-loud rule and the accepted
boolean spellings.
2026-08-09 01:45:27 +00:00

307 lines
8.1 KiB
Go

// 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 = &params
// 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
}