Files
pixa/internal/config/config.go
sneak 888c3a409a refactor: plain error assignment in eviction, storage and config (noinlineerr)
Hand-reviewed rather than mechanical, because two of these functions
carried cleanup that depended on the shape being replaced.

internal/imgcache/storage.go, writeIfAbsent: dropped the named result
and the deferred temp-file cleanup that read it (nonamedreturns), in
favour of an explicit os.Remove(tmpPath) on each failing path. The
deferred form removed tmpPath whenever the function returned a non-nil
error, which is reachable on exactly three paths once the temp file
exists: Write, Close and Rename. Each of those now unlinks explicitly,
in the same order relative to tmpFile.Close(). The paths that must NOT
unlink are unchanged and still cannot: the content-already-present
early return, a MkdirAll failure and a CreateTemp failure all happen
before tmpPath exists, and the success path renames the temp file away.
This is the same cleanup shape MetadataStorage.Store and
VariantStorage.Store already use in this file. StoreHashed likewise
loses its named results.

internal/imgcache/eviction.go: converted 26 inline assignments. In
evictSourceBlob the conversions reuse the function-scope err that the
transaction already used; the rollback defer does not read it, and the
ordering of the delete transaction, its commit, the sidecar deletes and
the blob unlink is untouched. In the rows.Next() loops the scan error
is declared inside the loop body and rows.Err() is checked after it, as
before.

internal/config: the remaining conversions are in straight-line code
with no defer or named result.

No behavior changes. make test (with -race, per script/test) is green,
including TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent,
which exercises the commit-to-unlink window this cleanup protects.
2026-08-09 13:34:27 +00:00

775 lines
21 KiB
Go

// Package config provides application configuration using smartconfig.
package config
import (
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"git.eeqj.de/sneak/smartconfig"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// Default configuration values.
const (
DefaultPort = 8080
DefaultStateDir = "/var/lib/pixa"
DefaultUpstreamConnectionsPerHost = 20
)
// Configuration key names.
const (
keyDebug = "debug"
keyMaintenanceMode = "maintenance_mode"
keyPort = "port"
keyStateDir = "state_dir"
keySentryDSN = "sentry_dsn"
keyDBURL = "db_url"
keyMetrics = "metrics"
keyMetricsUsername = "metrics.username"
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyCacheMaxBytes = "cache_max_bytes"
)
// Static validation errors. Each use site attaches the offending key
// and value by wrapping these with fmt.Errorf and %w.
var (
errValueRequired = errors.New("a value is required")
errValueEmpty = errors.New("value must not be empty")
errUnknownConfigKeys = errors.New("unknown config keys")
errNotAString = errors.New("not a string")
errNotAnInteger = errors.New("not an integer")
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range")
errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short")
errMustBeSetTogether = errors.New("must be set together")
errValueNull = errors.New(
"value is null; omit the key entirely to use the default")
errValuesNull = errors.New(
"value is null; omit a key entirely to use its default")
errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels")
)
// Params defines dependencies for Config.
type Params struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds application configuration values.
type Config struct {
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
StateDir string
DBURL string
// Image proxy settings
SigningKey string // HMAC signing key for URL signatures
AllowlistHosts []string // Hosts that don't require signatures
AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// CacheMaxBytes is the disk cache size limit in bytes. Zero
// disables the disk cache entirely. When cache_max_bytes is
// omitted from the configuration, this holds the computed default
// (75% of free space on the filesystem containing
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
CacheMaxBytes int64
// cacheMaxBytesExplicit records whether cache_max_bytes was
// explicitly set in the configuration file. Explicit values are
// used exactly as given; only an omitted key gets the computed
// default (and its floor) in resolveCacheMaxBytes.
cacheMaxBytesExplicit bool
}
// New creates a new Config instance by loading configuration from file.
func New(_ fx.Lifecycle, params Params) (*Config, error) {
log := params.Logger.Get()
name := params.Globals.Appname
sc, err := loadConfigFile(log, name)
if err != nil {
return nil, err
}
if sc == nil {
log.Info("no config file found, using defaults")
}
c, err := newFromSmartConfig(sc)
if err != nil {
return nil, err
}
err = c.ensureStateDirWritable()
if err != nil {
return nil, err
}
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
if err != nil {
return nil, err
}
if c.Debug {
params.Logger.EnableDebugLogging()
}
return c, nil
}
// newFromSmartConfig constructs a Config from a loaded smartconfig
// instance and validates it. A nil sc means no config file was found,
// in which case every option takes its default value. A key that is
// present but unparseable or invalid is an error: defaults apply only
// to omitted keys, never to invalid explicit values.
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
if sc != nil {
err := validateKnownKeys(sc)
if err != nil {
return nil, err
}
err = validateAllowlistHostsValue(sc)
if err != nil {
return nil, err
}
}
loader := &strictLoader{sc: sc}
c := &Config{
Debug: loader.boolVal(keyDebug, false),
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
Port: loader.intVal(keyPort, DefaultPort),
StateDir: loader.stringVal(keyStateDir, DefaultStateDir),
SentryDSN: loader.stringVal(keySentryDSN, ""),
MetricsUsername: loader.stringVal(keyMetricsUsername, ""),
MetricsPassword: loader.stringVal(keyMetricsPassword, ""),
SigningKey: loader.stringVal(keySigningKey, ""),
AllowlistHosts: getStringSlice(sc),
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
UpstreamConnectionsPerHost: loader.intVal(
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
}
// The computed default for cache_max_bytes needs a validated
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
// we only record whether the operator set the key explicitly.
if sc != nil {
if _, present := sc.Get(keyCacheMaxBytes); present {
c.cacheMaxBytesExplicit = true
}
}
// Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an
// explicitly empty value.
c.DBURL = loader.stringVal(keyDBURL, "")
if c.DBURL == "" && loader.err == nil {
if sc != nil {
if _, present := sc.Get(keyDBURL); present {
return nil, fmt.Errorf(
"config key %q: %w; omit the key to derive it from state_dir",
keyDBURL, errValueEmpty)
}
}
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
}
if loader.err != nil {
return nil, loader.err
}
err := c.validate()
if err != nil {
return nil, err
}
return c, nil
}
// validateKnownKeys rejects configuration files containing keys the
// application does not understand, so typos fail at startup instead of
// being silently ignored, and rejects keys that are explicitly set to
// null: a null is a SET value, never an omission, so it must not
// silently take the default. The env section is permitted because
// smartconfig consumes it for environment variable injection.
func validateKnownKeys(sc *smartconfig.Config) error {
var unknown, nullKeys []string
for key, value := range sc.Data() {
if !isKnownConfigKey(key) {
unknown = append(unknown, key)
continue
}
if value == nil {
nullKeys = append(nullKeys, key)
continue
}
if key == keyMetrics {
metricsMap, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("config key %q: value %v is %w",
keyMetrics, value, errNotAMetricsMap)
}
for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" {
unknown = append(unknown, keyMetrics+"."+subkey)
continue
}
if subvalue == nil {
nullKeys = append(nullKeys, keyMetrics+"."+subkey)
}
}
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
}
if len(nullKeys) > 0 {
sort.Strings(nullKeys)
if len(nullKeys) == 1 {
return errNullConfigValue(nullKeys[0])
}
return fmt.Errorf("config keys %s: %w",
strings.Join(nullKeys, ", "), errValuesNull)
}
return nil
}
// errNullConfigValue reports a config key that is explicitly set to
// null (including the bare "key:" form and the "~" alias). Silently
// applying the default would mask a truncated or typo'd config entry.
func errNullConfigValue(key string) error {
return fmt.Errorf("config key %q: %w", key, errValueNull)
}
// isKnownConfigKey reports whether key is a permitted top-level
// configuration key.
func isKnownConfigKey(key string) bool {
switch key {
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
return true
}
return false
}
// ensureStateDirWritable verifies at startup that StateDir can be
// created and written to, so a misconfigured path aborts startup
// instead of failing later at first use.
func (c *Config) ensureStateDirWritable() error {
const stateDirPerms = 0o750
err := os.MkdirAll(c.StateDir, stateDirPerms)
if err != nil {
return fmt.Errorf("config key %q: cannot create directory %q: %w",
keyStateDir, c.StateDir, err)
}
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
if err != nil {
return fmt.Errorf("config key %q: directory %q is not writable: %w",
keyStateDir, c.StateDir, err)
}
probePath := probe.Name()
err = probe.Close()
if err != nil {
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
keyStateDir, probePath, err)
}
err = os.Remove(probePath)
if err != nil {
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
keyStateDir, probePath, err)
}
return nil
}
// validate checks that all required configuration values are set and
// that every value is within its valid range.
func (c *Config) validate() error {
// The signing key value is never echoed in error messages.
if c.SigningKey == "" {
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
}
// Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32
if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
}
const maxPort = 65535
if c.Port < 1 || c.Port > maxPort {
return fmt.Errorf("config key %q: value %d is %w 1-%d",
keyPort, c.Port, errPortOutOfRange, maxPort)
}
if c.UpstreamConnectionsPerHost < 1 {
return fmt.Errorf("config key %q: value %d %w",
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
errTooFewConnections)
}
if c.StateDir == "" {
return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty)
}
// Zero is valid (it disables the disk cache); only negative
// values are rejected. No floor applies to explicit values.
if c.CacheMaxBytes < 0 {
return fmt.Errorf("config key %q: value %d must not be negative",
"cache_max_bytes", c.CacheMaxBytes)
}
for _, host := range c.AllowlistHosts {
err := validateAllowlistHost(host)
if err != nil {
return err
}
}
if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config key %q: value %q is %w",
keySentryDSN, c.SentryDSN, errNotAValidURL)
}
}
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
return fmt.Errorf("config keys %q and %q %w",
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
}
return nil
}
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with
// no hostname labels (such as ".") is rejected: the allowlist matcher
// treats a leading dot as a suffix pattern, so a bare "." would match
// any upstream host written in FQDN trailing-dot form and effectively
// disable URL signing.
func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf("config key %q: entry %q %w",
keyAllowlistHosts, host, errNotBareHostname)
}
if strings.Trim(host, ".") == "" {
return fmt.Errorf("config key %q: entry %q %w",
keyAllowlistHosts, host, errNoHostnameLabels)
}
return nil
}
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
// or standard locations.
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
// Check for explicit config path from environment
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
sc, err := smartconfig.NewFromConfigPath(envPath)
if err != nil {
return nil, fmt.Errorf("failed to load config from %s: %w", envPath, err)
}
log.Info("loaded config file", "path", envPath)
return sc, nil
}
// Try loading config from standard locations
configPaths := []string{
fmt.Sprintf("/etc/%s/config.yml", appName),
fmt.Sprintf("/etc/%s/config.yaml", appName),
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yml"),
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yaml"),
"config.yml",
"config.yaml",
}
for _, path := range configPaths {
cleanPath := filepath.Clean(path)
_, statErr := os.Stat(cleanPath)
if statErr == nil {
// A config file that exists but does not parse is a fatal
// startup error, never something to skip over.
sc, err := smartconfig.NewFromConfigPath(path)
if err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
}
log.Info("loaded config file", "path", path)
return sc, nil
}
}
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
}
// strictLoader accumulates the first error encountered while reading
// typed values out of a smartconfig instance, so Config construction
// can stay a single struct literal.
type strictLoader struct {
sc *smartconfig.Config
err error
}
func (l *strictLoader) stringVal(key, defaultVal string) string {
if l.err != nil {
return ""
}
val, err := getString(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
func (l *strictLoader) intVal(key string, defaultVal int) int {
if l.err != nil {
return 0
}
val, err := getInt(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
if l.err != nil {
return 0
}
val, err := getInt64(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
if l.err != nil {
return false
}
val, err := getBool(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
// getString returns the string value for key, or defaultVal if the key
// is omitted. A present value that is not a string, or is explicitly
// null, is an error.
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return "", errNullConfigValue(key)
}
str, ok := raw.(string)
if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAString)
}
return str, nil
}
// getInt returns the integer value for key, or defaultVal if the key is
// omitted. A present value that is not a whole number, or is explicitly
// null, is an error; fractional values are never truncated.
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) {
case int:
return val, nil
case int64:
return int(val), nil
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is %w",
key, val, errNotAnInteger)
}
return int(val), nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotAnInteger)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAnInteger)
}
}
// getInt64 returns the 64-bit integer value for key, or defaultVal if
// the key is omitted. A present value that is not a whole number, or
// is explicitly null, is an error; fractional values are never
// truncated and out-of-range values are never clamped.
func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) {
case int:
return int64(val), nil
case int64:
return val, nil
case uint64:
if val > math.MaxInt64 {
return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer",
key, val)
}
return int64(val), nil
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
}
return int64(val), nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
key, raw, raw)
}
}
// getBool returns the boolean value for key, or defaultVal if the key
// is omitted. A present value that is not a boolean (or a ParseBool-able
// string), or is explicitly null, is an error; numbers are not accepted
// as booleans.
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return false, errNullConfigValue(key)
}
switch val := raw.(type) {
case bool:
return val, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
if err != nil {
return false, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotABoolean)
}
return parsed, nil
default:
return false, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotABoolean)
}
}
// validateAllowlistHostsValue checks the raw shape of the
// allowlist_hosts value before the lenient extraction in getStringSlice
// runs: an explicitly null value, a value that is not a list of strings
// (or a comma-separated string), a non-string entry, or an empty entry
// is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
raw, ok := sc.Get(keyAllowlistHosts)
if !ok {
return nil
}
if raw == nil {
return errNullConfigValue(keyAllowlistHosts)
}
switch val := raw.(type) {
case []any:
for _, item := range val {
str, ok := item.(string)
if !ok {
return fmt.Errorf("config key %q: list entry %v (%T) is %w",
keyAllowlistHosts, item, item, errNotAString)
}
if strings.TrimSpace(str) == "" {
return fmt.Errorf("config key %q: %w",
keyAllowlistHosts, errEmptyListEntry)
}
}
case string:
if strings.TrimSpace(val) == "" {
return nil
}
for part := range strings.SplitSeq(val, ",") {
if strings.TrimSpace(part) == "" {
return fmt.Errorf("config key %q: value %q %w",
keyAllowlistHosts, val, errEmptyEntry)
}
}
default:
return fmt.Errorf("config key %q: value %v (%T) is %w",
keyAllowlistHosts, raw, raw, errNotAStringList)
}
return nil
}
// getStringSlice returns the allowlist_hosts list of strings, or nil if
// the key is omitted. It accepts a YAML list of strings or a
// comma-separated string (backwards compatibility). Malformed entries
// are rejected beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config) []string {
if sc == nil {
return nil
}
val, ok := sc.Get(keyAllowlistHosts)
if !ok || val == nil {
return nil
}
// Handle YAML list format
if slice, ok := val.([]any); ok {
result := make([]string, 0, len(slice))
for _, item := range slice {
if str, ok := item.(string); ok {
trimmed := strings.TrimSpace(str)
if trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}
// Fall back to comma-separated string for backwards compatibility
if str, ok := val.(string); ok && str != "" {
parts := strings.Split(str, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
return nil
}