Some checks failed
check / check (push) Has been cancelled
Defaults now apply only to unset or empty environment variables; a set-but- unparseable value aborts startup with an error naming the key and the value. envInt is gone, envBool parses with strconv.ParseBool, and PORT is bounded.
307 lines
8.1 KiB
Go
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 = ¶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
|
|
}
|