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
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user