// 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 DEBUG=yes has said something specific that this // program does not understand, and starting anyway with the default // 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 several. package config import ( "errors" "fmt" "strconv" "strings" "github.com/spf13/viper" // spooky action at a distance! // this populates the environment // from a ./.env file automatically // for development configuration. // .env contents should be things like // `DEBUG=true` // (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 ( EnvDataDir = "DATA_DIR" EnvDebug = "DEBUG" ) // DefaultDataDir applies when DATA_DIR is absent. const DefaultDataDir = "./data" // 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 anything starts. type Config struct { // DataDir holds the SimpleX Chat database: the bot's profile, its // address and its contacts. Losing it loses the address. DataDir string Debug bool } // 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 } // 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 setting ends up the // opposite of what the operator meant. 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 } // New parses and validates the environment. An error here aborts // startup before the chat client is launched, so there is no partially // configured running state to reason about. func New() (*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{ DataDir: l.str(EnvDataDir, DefaultDataDir), Debug: l.boolean(EnvDebug, false), } if len(l.errs) > 0 { return nil, errors.Join(l.errs...) } return c, nil }