Files
simplexcalc/internal/config/config.go
T
clawbot f8ce8cef83 Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
2026-09-26 21:38:57 +00:00

391 lines
11 KiB
Go

// Package config loads runtime configuration from the environment (and
// an optional ./.env file) via viper.
//
// The iron rule of this package: a value that is SET but cannot be
// parsed aborts startup. It is never replaced by the default. An
// operator who writes PORT=eighty has said something specific and
// wrong, and starting anyway on port 8080 turns their mistake into a
// silent misconfiguration that only surfaces much later, somewhere
// else. Defaults apply to values that are ABSENT, and to nothing else.
//
// Every parse failure found in one pass is reported together, so a
// broken deployment takes one restart to diagnose rather than five.
package config
import (
"errors"
"fmt"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/spf13/viper"
"go.uber.org/fx"
// spooky action at a distance!
// this populates the environment
// from a ./.env file automatically
// for development configuration.
// .env contents should be things like
// `PORT=8080`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
)
// Environment variable names. Bare names, no prefix: this matches the
// other services and keeps a compose file readable.
const (
EnvPort = "PORT"
EnvDataDir = "DATA_DIR"
EnvDBPath = "DB_PATH"
EnvDebug = "DEBUG"
EnvHSTS = "HSTS"
EnvBaseURL = "BASE_URL"
EnvMaxRequestBody = "MAX_REQUEST_BODY"
EnvRequestTimeout = "REQUEST_TIMEOUT"
EnvShutdownGrace = "SHUTDOWN_GRACE"
EnvSentryDSN = "SENTRY_DSN"
EnvSentryEnv = "SENTRY_ENVIRONMENT"
EnvMetricsUser = "METRICS_USER"
EnvMetricsPassword = "METRICS_PASSWORD"
EnvCSRFKey = "CSRF_KEY"
)
// Defaults for values that are absent. A value that is present and
// unparseable never reaches these.
const (
DefaultPort int64 = 8080
DefaultDataDir = "./data"
DefaultBaseURL = "http://localhost:8080"
DefaultMaxRequestBody int64 = 1 << 20 // 1 MiB
DefaultRequestTimeout = 30 * time.Second
DefaultShutdownGrace = 15 * time.Second
DefaultSentryEnv = "development"
)
// Bounds. A value inside the type but outside the range is as
// misconfigured as one that does not parse, and fails the same way.
const (
minPort int64 = 1
maxPort int64 = 65535
// minRequestBody is a floor below which no useful form submission
// fits; maxRequestBody is a ceiling above which the cap is not
// doing its job.
minRequestBody int64 = 1 << 10 // 1 KiB
maxRequestBody int64 = 1 << 26 // 64 MiB
minTimeout = 1 * time.Second
maxTimeout = 10 * time.Minute
// csrfKeyBytes is what gorilla/csrf requires: exactly 32 bytes,
// supplied as csrfKeyHexChars hex characters.
csrfKeyBytes = 32
csrfKeyHexChars = csrfKeyBytes * 2
)
// ErrInvalidConfig is the sentinel every configuration failure wraps,
// so callers can distinguish "the operator got it wrong" from "the
// machine is broken" without string matching.
var ErrInvalidConfig = errors.New("invalid configuration")
// Config is the parsed, validated runtime configuration. Every field
// is final by the time New returns: nothing re-reads the environment
// later, so there is exactly one moment at which configuration can be
// wrong, and it is before the listener opens.
type Config struct {
Port int
DataDir string
DBPath string
Debug bool
HSTS bool
BaseURL string
MaxRequestBody int64
RequestTimeout time.Duration
ShutdownGrace time.Duration
SentryDSN string
SentryEnvironment string
// MetricsUser and MetricsPassword gate /metrics. Both set or
// neither: half-set is refused rather than resolved, because
// either resolution is dangerous. Treating a missing password as
// empty would publish the metrics endpoint to anyone who guesses
// the username; treating a missing username as "no auth" would
// publish it to everyone, in a deployment whose operator plainly
// intended it to be closed.
MetricsUser string
MetricsPassword string
// CSRFKey is exactly 32 bytes. When CSRF_KEY is absent, a random
// key is generated at startup and a warning is logged: tokens then
// do not survive a restart, which is fine in development and not
// fine behind more than one replica. Absent is a default; present
// and malformed is a startup failure.
CSRFKey []byte
CSRFKeyEphemeral bool
}
// Params defines dependencies for Config.
type Params struct {
fx.In
}
// loader parses one environment into a Config, accumulating every
// failure instead of stopping at the first, so one restart surfaces the
// whole list.
type loader struct {
v *viper.Viper
errs []error
}
func (l *loader) fail(key, raw, why string) {
l.errs = append(l.errs, fmt.Errorf(
"%w: %s=%q is %s", ErrInvalidConfig, key, raw, why,
))
}
// raw returns the trimmed value of key, and whether it was set to
// anything. Whitespace-only counts as absent: it is what an empty
// compose-file entry produces, and no key here has a meaningful blank
// value.
func (l *loader) raw(key string) (string, bool) {
s := strings.TrimSpace(l.v.GetString(key))
return s, s != ""
}
func (l *loader) str(key, def string) string {
if s, ok := l.raw(key); ok {
return s
}
return def
}
func (l *loader) integer(key string, def, minVal, maxVal int64) int64 {
s, ok := l.raw(key)
if !ok {
return def
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
l.fail(key, s, "not an integer")
return def
}
if n < minVal || n > maxVal {
l.fail(key, s, fmt.Sprintf("outside the range %d..%d", minVal, maxVal))
return def
}
return n
}
// boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True
// and the false equivalents) and refuses everything else. "yes" is a
// parse failure on purpose: guessing at it is how a security header
// ends up off in production.
func (l *loader) boolean(key string, def bool) bool {
s, ok := l.raw(key)
if !ok {
return def
}
b, err := strconv.ParseBool(s)
if err != nil {
l.fail(key, s, "not a boolean (use true or false)")
return def
}
return b
}
func (l *loader) duration(key string, def time.Duration) time.Duration {
s, ok := l.raw(key)
if !ok {
return def
}
d, err := time.ParseDuration(s)
if err != nil {
l.fail(key, s, "not a duration (e.g. 30s, 2m)")
return def
}
if d < minTimeout || d > maxTimeout {
l.fail(key, s, fmt.Sprintf("outside the range %s..%s", minTimeout, maxTimeout))
return def
}
return d
}
// bytesize accepts both a plain integer and a human size ("1MiB",
// "512kB"), which is the form an operator actually writes.
func (l *loader) bytesize(key string, def, minVal, maxVal int64) int64 {
s, ok := l.raw(key)
if !ok {
return def
}
n, err := humanize.ParseBytes(s)
if err != nil {
l.fail(key, s, "not a byte size (e.g. 1048576, 1MiB, 512kB)")
return def
}
// maxVal and minVal are compile-time constants of this package,
// both positive, so these conversions cannot overflow; n is
// range-checked before it is narrowed.
if n > uint64(maxVal) { //nolint:gosec // see above
l.fail(key, s, byteRangeMessage(minVal, maxVal))
return def
}
sz := int64(n) //nolint:gosec // n was just checked against maxVal, a positive int64.
if sz < minVal {
l.fail(key, s, byteRangeMessage(minVal, maxVal))
return def
}
return sz
}
// byteRangeMessage renders the permitted size range the way an operator
// wrote the value they got wrong.
func byteRangeMessage(minVal, maxVal int64) string {
//nolint:gosec // both are positive compile-time constants of this package.
return fmt.Sprintf("outside the range %s..%s",
humanize.IBytes(uint64(minVal)), humanize.IBytes(uint64(maxVal)))
}
// New parses and validates the environment. Returning an error here
// aborts fx startup before anything listens, which is the whole point:
// there is no partially configured running state to reason about.
//
//nolint:revive // lc parameter is required by fx even if unused.
func New(lc fx.Lifecycle, _ Params) (*Config, error) {
v := viper.New()
v.AutomaticEnv()
return load(v)
}
// load is New's body against an explicit viper instance, so tests can
// drive it with a known environment instead of mutating the process's.
func load(v *viper.Viper) (*Config, error) {
l := &loader{v: v}
c := &Config{}
c.Port = int(l.integer(EnvPort, DefaultPort, minPort, maxPort))
c.DataDir = l.str(EnvDataDir, DefaultDataDir)
c.Debug = l.boolean(EnvDebug, false)
c.BaseURL = l.str(EnvBaseURL, DefaultBaseURL)
// HSTS defaults to on unless debugging: pinning a developer's
// browser to HTTPS on localhost is a self-inflicted outage that
// outlives the process.
c.HSTS = l.boolean(EnvHSTS, !c.Debug)
c.DBPath = l.str(EnvDBPath, filepath.Join(c.DataDir, "simplexcalc.db"))
c.MaxRequestBody = l.bytesize(
EnvMaxRequestBody, DefaultMaxRequestBody, minRequestBody, maxRequestBody,
)
c.RequestTimeout = l.duration(EnvRequestTimeout, DefaultRequestTimeout)
c.ShutdownGrace = l.duration(EnvShutdownGrace, DefaultShutdownGrace)
c.SentryDSN = l.str(EnvSentryDSN, "")
c.SentryEnvironment = l.str(EnvSentryEnv, DefaultSentryEnv)
l.checkSentryDSN(c.SentryDSN)
c.MetricsUser = l.str(EnvMetricsUser, "")
c.MetricsPassword = l.str(EnvMetricsPassword, "")
l.checkMetricsAuth(c)
l.loadCSRFKey(c)
if len(l.errs) > 0 {
return nil, errors.Join(l.errs...)
}
return c, nil
}
// checkSentryDSN refuses a DSN that is present and not a URL. An empty
// DSN disables Sentry and is not an error; a typo'd one that silently
// disabled it would be, since the operator would believe errors were
// being reported.
func (l *loader) checkSentryDSN(dsn string) {
if dsn == "" {
return
}
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" || u.Host == "" {
// The DSN embeds a key; report the failure without it.
l.errs = append(l.errs, fmt.Errorf(
"%w: %s is set but is not a valid DSN URL", ErrInvalidConfig, EnvSentryDSN,
))
}
}
// checkMetricsAuth refuses a half-configured metrics credential. See
// the field comment on Config.MetricsUser for why neither resolution
// is acceptable.
func (l *loader) checkMetricsAuth(c *Config) {
switch {
case c.MetricsUser == "" && c.MetricsPassword == "":
return
case c.MetricsUser == "":
l.errs = append(l.errs, fmt.Errorf(
"%w: %s is set but %s is not; set both or neither",
ErrInvalidConfig, EnvMetricsPassword, EnvMetricsUser,
))
case c.MetricsPassword == "":
l.errs = append(l.errs, fmt.Errorf(
"%w: %s is set but %s is not; set both or neither",
ErrInvalidConfig, EnvMetricsUser, EnvMetricsPassword,
))
}
}
// loadCSRFKey decodes CSRF_KEY, or marks the config for an ephemeral
// key. Generating the random key is deferred to the server, so that
// this function stays pure and testable.
func (l *loader) loadCSRFKey(c *Config) {
s, ok := l.raw(EnvCSRFKey)
if !ok {
c.CSRFKeyEphemeral = true
return
}
key, err := decodeHex(s)
if err != nil {
// The value is a secret: say what is wrong with it, never
// quote it.
l.errs = append(l.errs, fmt.Errorf(
"%w: %s is set but is not %d hex characters",
ErrInvalidConfig, EnvCSRFKey, csrfKeyHexChars,
))
return
}
c.CSRFKey = key
}