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:
clawbot
2026-09-26 21:38:57 +00:00
parent e336f46e34
commit f8ce8cef83
79 changed files with 7131 additions and 0 deletions
+390
View File
@@ -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
}
+257
View File
@@ -0,0 +1,257 @@
package config_test
import (
"errors"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/simplexcalc/internal/config"
)
// Credentials used by the metrics-auth cases.
const (
testUser = "scraper"
testPass = "hunter2"
)
// env builds a viper instance holding exactly the given keys, so a test
// describes one environment without touching the process's.
func env(kv map[string]string) *viper.Viper {
v := viper.New()
for k, val := range kv {
v.Set(k, val)
}
return v
}
// TestAbsentValuesTakeDefaults pins the other half of the iron rule: a
// value that is not set does get the default. Without this, a bug that
// rejected everything would pass every test below.
func TestAbsentValuesTakeDefaults(t *testing.T) {
t.Parallel()
c, err := config.Load(env(nil))
if err != nil {
t.Fatalf("empty environment must be valid, got: %v", err)
}
if c.Port != int(config.DefaultPort) {
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
}
if c.MaxRequestBody != config.DefaultMaxRequestBody {
t.Errorf("MaxRequestBody = %d, want %d",
c.MaxRequestBody, config.DefaultMaxRequestBody)
}
if c.RequestTimeout != config.DefaultRequestTimeout {
t.Errorf("RequestTimeout = %s, want %s",
c.RequestTimeout, config.DefaultRequestTimeout)
}
if !c.HSTS {
t.Error("HSTS must default on when DEBUG is not set")
}
if !c.CSRFKeyEphemeral {
t.Error("an absent CSRF_KEY must mark the config for an ephemeral key")
}
}
// TestSetButUnparseableAborts is the central contract of this package.
// Every case is a value an operator plausibly types, and every one of
// them must fail startup rather than be replaced by the default.
func TestSetButUnparseableAborts(t *testing.T) {
t.Parallel()
cases := map[string]map[string]string{
"port is not a number": {config.EnvPort: "eighty"},
"port is zero": {config.EnvPort: "0"},
"port is above the range": {config.EnvPort: "70000"},
"port is a float": {config.EnvPort: "8080.0"},
"debug is yes": {config.EnvDebug: "yes"},
"hsts is on": {config.EnvHSTS: "on"},
"body cap is nonsense": {config.EnvMaxRequestBody: "big"},
"body cap is too large": {config.EnvMaxRequestBody: "1TiB"},
"body cap is too small": {config.EnvMaxRequestBody: "10"},
"timeout has no unit": {config.EnvRequestTimeout: "30"},
"timeout is out of range": {config.EnvRequestTimeout: "1h"},
"grace is nonsense": {config.EnvShutdownGrace: "soon"},
"sentry dsn is not a url": {config.EnvSentryDSN: "not a dsn"},
"csrf key is not hex": {config.EnvCSRFKey: "not-hex-at-all"},
"csrf key is wrong length": {config.EnvCSRFKey: "abcdef"},
}
for name, kv := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
c, err := config.Load(env(kv))
if err == nil {
t.Fatalf("wanted a startup failure, got a Config: %+v", c)
}
if !errors.Is(err, config.ErrInvalidConfig) {
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
}
if c != nil {
t.Error("a failed load must return no Config at all")
}
})
}
}
// TestSecretsAreNotEchoed: a rejected CSRF key must not appear in the
// error, because errors are logged and a log is not a place to put a
// key.
func TestSecretsAreNotEchoed(t *testing.T) {
t.Parallel()
const secret = "00112233445566778899aabbccdd" // valid hex, wrong length
_, err := config.Load(env(map[string]string{config.EnvCSRFKey: secret}))
if err == nil {
t.Fatal("wanted a failure for a short CSRF key")
}
if strings.Contains(err.Error(), secret) {
t.Errorf("the rejected key was echoed in the error: %v", err)
}
}
// TestHalfSetMetricsAuthAborts covers the case the issue calls out
// explicitly: auth config that is half-set must fail loudly, in both
// directions.
func TestHalfSetMetricsAuthAborts(t *testing.T) {
t.Parallel()
cases := map[string]map[string]string{
"user without password": {config.EnvMetricsUser: testUser},
"password without user": {config.EnvMetricsPassword: testPass},
}
for name, kv := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := config.Load(env(kv))
if err == nil {
t.Fatal("half-set metrics credentials must abort startup")
}
if !errors.Is(err, config.ErrInvalidConfig) {
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
}
})
}
both, err := config.Load(env(map[string]string{
config.EnvMetricsUser: testUser, config.EnvMetricsPassword: testPass,
}))
if err != nil {
t.Fatalf("both credentials set must be valid, got: %v", err)
}
if both.MetricsUser != testUser || both.MetricsPassword != testPass {
t.Error("credentials did not survive parsing")
}
neither, err := config.Load(env(nil))
if err != nil {
t.Fatalf("neither credential set must be valid, got: %v", err)
}
if neither.MetricsUser != "" || neither.MetricsPassword != "" {
t.Error("credentials appeared from nowhere")
}
}
// TestEveryFailureIsReported: one restart should surface the whole list,
// not just the first problem.
func TestEveryFailureIsReported(t *testing.T) {
t.Parallel()
_, err := config.Load(env(map[string]string{
config.EnvPort: "eighty",
config.EnvDebug: "yes",
config.EnvRequestTimeout: "soon",
}))
if err == nil {
t.Fatal("wanted failures")
}
for _, key := range []string{
config.EnvPort, config.EnvDebug, config.EnvRequestTimeout,
} {
if !strings.Contains(err.Error(), key) {
t.Errorf("%s is broken but is not named in the error: %v", key, err)
}
}
}
// TestValidValuesAreUsed proves the parsers accept what they document.
func TestValidValuesAreUsed(t *testing.T) {
t.Parallel()
const key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := config.Load(env(map[string]string{
config.EnvPort: "9000",
config.EnvDebug: "true",
config.EnvMaxRequestBody: "2MiB",
config.EnvRequestTimeout: "45s",
config.EnvShutdownGrace: "5s",
config.EnvDataDir: "/var/lib/example",
config.EnvCSRFKey: key,
}))
if err != nil {
t.Fatalf("valid environment was rejected: %v", err)
}
if c.Port != 9000 {
t.Errorf("Port = %d, want 9000", c.Port)
}
if c.MaxRequestBody != 2<<20 {
t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, 2<<20)
}
if c.RequestTimeout != 45*time.Second {
t.Errorf("RequestTimeout = %s, want 45s", c.RequestTimeout)
}
if c.HSTS {
t.Error("HSTS must default off when DEBUG is true")
}
if len(c.CSRFKey) != config.CSRFKeyBytes || c.CSRFKeyEphemeral {
t.Errorf("CSRFKey not decoded: len=%d ephemeral=%v",
len(c.CSRFKey), c.CSRFKeyEphemeral)
}
if c.DBPath != "/var/lib/example/simplexcalc.db" {
t.Errorf("DBPath = %q, want it derived from DATA_DIR", c.DBPath)
}
}
// TestExplicitOverridesDerivedDBPath: DB_PATH wins over the DATA_DIR
// derivation, which is the only reason it exists.
func TestExplicitOverridesDerivedDBPath(t *testing.T) {
t.Parallel()
c, err := config.Load(env(map[string]string{
config.EnvDataDir: "/var/lib/example",
config.EnvDBPath: "/srv/other.db",
}))
if err != nil {
t.Fatalf("valid environment was rejected: %v", err)
}
if c.DBPath != "/srv/other.db" {
t.Errorf("DBPath = %q, want /srv/other.db", c.DBPath)
}
}
+17
View File
@@ -0,0 +1,17 @@
package config
// Load is load, exported for the external test package.
//
// The tests live in config_test rather than config so that they drive
// this package the way the rest of the program does — through its
// exported surface — and cannot quietly depend on an internal detail.
// The one thing they need that is not exported is the ability to
// supply an environment instead of reading the process's, which is a
// testing seam and not API.
//
//nolint:gochecknoglobals // a test seam, not mutable state.
var Load = load
// CSRFKeyBytes is the required key length, so the tests can assert on
// it without restating the number.
const CSRFKeyBytes = csrfKeyBytes
+29
View File
@@ -0,0 +1,29 @@
package config
import (
"encoding/hex"
"errors"
"fmt"
)
// errKeyLength is returned for a well-formed hex string of the wrong
// length, so decodeHex has one error type for both ways of being wrong.
var errKeyLength = errors.New("wrong key length")
// decodeHex decodes exactly csrfKeyBytes bytes of hex. It exists as its
// own function so that the length rule and the encoding rule are
// enforced in one place, and so that the caller never has to decide
// what a short-but-valid key means.
func decodeHex(s string) ([]byte, error) {
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decoding hex: %w", err)
}
if len(b) != csrfKeyBytes {
return nil, fmt.Errorf("%w: got %d bytes, want %d",
errKeyLength, len(b), csrfKeyBytes)
}
return b, nil
}