493 lines
21 KiB
Go
493 lines
21 KiB
Go
// Package config reads the service's settings from the environment.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/books"
|
|
"textmachine/platform/internal/money"
|
|
"textmachine/platform/internal/pricing"
|
|
)
|
|
|
|
// Config is the whole configuration surface. Environment only: a control plane is deployed, not
|
|
// hand-run, and a settings file is one more thing to keep in sync with the deployment. Ratified
|
|
// 09.08 (register row PD-114) together with the obligation this file now also carries — an operator
|
|
// must be able to read what actually applied, which is what Settings and LogEffective are for.
|
|
type Config struct {
|
|
Addr string
|
|
// DSN is the Postgres connection string. Empty is allowed and means "start unready": the
|
|
// service answers liveness and reports on /readyz why it cannot serve.
|
|
//
|
|
// ⚠ It carries a password. It is read from the environment, never logged, and never echoed
|
|
// into an error message.
|
|
DSN string
|
|
// TrustedOrigins are origins besides our own that may make unsafe requests.
|
|
TrustedOrigins []string
|
|
// SessionIdleTTL is how long a session survives without use; SessionMaxAge is the ceiling no
|
|
// amount of use can extend. Both are policy, and the policy — the two values, the concurrent
|
|
// session rule and what our session does when the provider's ends — is written down in
|
|
// STACK_DECISIONS §13, because ASVS 5.0 7.1.1 asks for the document, not the number.
|
|
SessionIdleTTL time.Duration
|
|
SessionMaxAge time.Duration
|
|
// Migrate applies pending migrations at boot. Off by default: a rollout should migrate once,
|
|
// deliberately, not once per replica.
|
|
Migrate bool
|
|
// InsecureCookies serves the session over plain HTTP under a different cookie name. A DEV
|
|
// switch: __Host- requires Secure, so localhost cannot use the production name at all (PD-8).
|
|
InsecureCookies bool
|
|
// OIDC is the sign-in provider. Empty issuer means no login surface is mounted — the service
|
|
// still runs, which is what keeps a bare `go run` useful.
|
|
// OIDCProvider is OUR name for the issuer and the first half of the identity key. It is
|
|
// configured next to the issuer because the two must move together: pointing the issuer at a
|
|
// different IdP while keeping the name would file that IdP's subjects under the old provider —
|
|
// the silent account-linking the identity model exists to prevent.
|
|
OIDCProvider string
|
|
OIDCIssuer string
|
|
OIDCClientID string
|
|
OIDCClientSecret string
|
|
OIDCRedirectURL string
|
|
// AfterLogin is where a completed sign-in lands.
|
|
AfterLogin string
|
|
// SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08:
|
|
// default five dollars, settable per account by granting a different amount).
|
|
SignupGrantMicroUSD int64
|
|
// MetricsAddr is where the telemetry listener binds. A SEPARATE listener from the API on
|
|
// purpose — see the runner's own note — and empty turns it off.
|
|
MetricsAddr string
|
|
|
|
// Runner is everything about starting engine runs.
|
|
Runner RunnerConfig
|
|
// Intake is everything about receiving a book.
|
|
Intake IntakeConfig
|
|
|
|
// Settings is every value above with where it came from, in the order it was read. It is what
|
|
// the boot line prints (PD-114) and it carries no secret and no money amount — see Setting.
|
|
Settings []Setting
|
|
}
|
|
|
|
// RunnerConfig is the runner's operator-facing surface.
|
|
type RunnerConfig struct {
|
|
// EngineBinary is the VERSIONED path of tmctl. Empty means no run can be spawned, which is the
|
|
// right default for an instance that only serves reads. A version-bearing path is the point
|
|
// (unified backlog row 139): the engine ships more often than a run finishes, and an attempt is
|
|
// pinned to the path it started with.
|
|
EngineBinary string
|
|
// StateDir is where the platform keeps its own run state (exit markers). Never the book's
|
|
// directory: the engine owns that one.
|
|
StateDir string
|
|
// MarkerBinary is the command systemd runs as ExecStopPost to record how a unit ended. It is
|
|
// this project's own admin CLI; empty means "the tmplatformctl next to the running daemon".
|
|
MarkerBinary string
|
|
// CeilingArg is how the run's ceiling reaches the engine, as an argv template containing {{usd}}.
|
|
// Defaulted to the landed form (row 145, D39.122) and overridable for a build that spells the flag
|
|
// differently; an override that does not carry {{usd}} is refused rather than sent as a literal.
|
|
CeilingArg string
|
|
// MemoryMax and TasksMax bound ONE run's cgroup (PD-13).
|
|
MemoryMax string
|
|
TasksMax int
|
|
// AllowEngineVersionChange lets a RESUME move to the currently deployed engine build instead of
|
|
// the one its run started with (unified backlog row 139: another version only on an explicit say-so).
|
|
AllowEngineVersionChange bool
|
|
// SweepEvery is how often the reconciler reads the world.
|
|
SweepEvery time.Duration
|
|
// ResyncEvery is how often a LIVE run is refreshed from `tmctl status --json`. Slow on purpose:
|
|
// each call costs seconds of CPU (unified backlog row 100).
|
|
ResyncEvery time.Duration
|
|
// PerChapterMicroUSD is the platform's chapters-to-money rate. Always set: the default is
|
|
// resolved HERE and not by whoever reads the field, so there is one answer to "what rate is this
|
|
// instance using" instead of one per caller. Its provenance is pricing.DefaultPerChapter.
|
|
PerChapterMicroUSD int64
|
|
// Workers is how many queue workers spawn units concurrently.
|
|
Workers int
|
|
}
|
|
|
|
// IntakeConfig is the book upload's operator-facing surface.
|
|
type IntakeConfig struct {
|
|
// BooksDir is the root under which the platform creates one directory per uploaded book. Empty
|
|
// means this deployment takes no uploads, and then POST /books is not mounted at all: an
|
|
// instance that accepted files it has nowhere to put would reject every one of them after the
|
|
// upload rather than before it.
|
|
//
|
|
// Absolute, for the same reason StateDir is: a relative root names a different directory to the
|
|
// daemon and to anything else that reads a book's workdir out of the database.
|
|
BooksDir string
|
|
// MaxUploadBytes caps ONE upload's request body, and it is the per-route limit PD-72 was opened
|
|
// for. It is a refusal and not a sizing: the acceptance corpus is a 23 MB book.
|
|
MaxUploadBytes int64
|
|
// UploadDeadline is how long one upload's body may take to arrive. It REPLACES the server's
|
|
// 30-second ReadTimeout for that route alone and stays finite; clearing a deadline instead is
|
|
// what re-created PD-2 and is forbidden (STACK_DECISIONS §12).
|
|
UploadDeadline time.Duration
|
|
}
|
|
|
|
// Setting is one resolved value as an operator may read it in a log.
|
|
type Setting struct {
|
|
// Key is the environment variable.
|
|
Key string
|
|
// Value is what applied — or a REDACTION where the value may not be logged. Two classes are
|
|
// redacted and both are rules of this project rather than taste: a secret (a DSN carries a
|
|
// password; §11 keeps it out of the process environment for the same reason), and any amount of
|
|
// MONEY, which does not reach an INFO log in any form (D39.84, PD-99). For those the source is
|
|
// the whole point — "did my file get picked up" is answerable without the value.
|
|
Value string
|
|
// Source is where the value came from: default, environment, or a *_FILE.
|
|
Source string
|
|
}
|
|
|
|
const (
|
|
sourceDefault = "default"
|
|
sourceEnv = "environment"
|
|
sourceFile = "file"
|
|
)
|
|
|
|
// redacted values. They are written out rather than left empty so that a line always says something:
|
|
// an empty value column reads as "unset", which is a different fact.
|
|
const (
|
|
valueSet = "(set)"
|
|
valueUnset = "(unset)"
|
|
valueAmount = "(an amount; not logged)"
|
|
)
|
|
|
|
// Load reads the environment.
|
|
func Load() (Config, error) {
|
|
var l loader
|
|
c := Config{
|
|
Addr: l.env("TM_PLATFORM_ADDR", "127.0.0.1:8080"),
|
|
OIDCProvider: l.env("TM_PLATFORM_OIDC_PROVIDER", "google"),
|
|
OIDCIssuer: l.env("TM_PLATFORM_OIDC_ISSUER", ""),
|
|
OIDCClientID: l.env("TM_PLATFORM_OIDC_CLIENT_ID", ""),
|
|
OIDCRedirectURL: l.env("TM_PLATFORM_OIDC_REDIRECT_URL", ""),
|
|
AfterLogin: l.env("TM_PLATFORM_AFTER_LOGIN", "/"),
|
|
MetricsAddr: metricsAddr(l.env("TM_PLATFORM_METRICS_ADDR", "127.0.0.1:9464")),
|
|
SignupGrantMicroUSD: 5 * 1_000_000,
|
|
// 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure
|
|
// ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from
|
|
// a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13).
|
|
SessionIdleTTL: 14 * 24 * time.Hour,
|
|
SessionMaxAge: 30 * 24 * time.Hour,
|
|
}
|
|
if raw := l.env("TM_PLATFORM_TRUSTED_ORIGINS", ""); raw != "" {
|
|
for _, o := range strings.Split(raw, ",") {
|
|
if o = strings.TrimSpace(o); o != "" {
|
|
c.TrustedOrigins = append(c.TrustedOrigins, o)
|
|
}
|
|
}
|
|
}
|
|
var err error
|
|
if c.Migrate, err = l.boolean("TM_PLATFORM_MIGRATE"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.InsecureCookies, err = l.boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.DSN, err = l.secret("TM_PLATFORM_DSN"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.OIDCClientSecret, err = l.secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.SessionIdleTTL, err = l.duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.SessionMaxAge, err = l.duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.SessionIdleTTL > c.SessionMaxAge {
|
|
return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge)
|
|
}
|
|
if c.SignupGrantMicroUSD, err = l.amount("TM_PLATFORM_SIGNUP_GRANT_USD", c.SignupGrantMicroUSD, false); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := c.loadRunner(&l); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := c.loadIntake(&l); err != nil {
|
|
return Config{}, err
|
|
}
|
|
// Half a login configuration is worse than none: the surface would mount and fail at the first
|
|
// click instead of at boot, where an operator is watching.
|
|
oidc := []string{c.OIDCIssuer, c.OIDCClientID, c.OIDCClientSecret, c.OIDCRedirectURL}
|
|
set := 0
|
|
for _, v := range oidc {
|
|
if v != "" {
|
|
set++
|
|
}
|
|
}
|
|
if set != 0 && set != len(oidc) {
|
|
return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none")
|
|
}
|
|
c.Settings = l.settings
|
|
return c, nil
|
|
}
|
|
|
|
// metricsAddr turns the operator's word for "do not serve metrics" into the empty address the
|
|
// listener understands.
|
|
//
|
|
// A word is needed because an EMPTY environment variable is indistinguishable from an unset one to
|
|
// os.Getenv, and unset takes the default — so without this the switch documented as "empty turns it
|
|
// off" could not be reached from the environment at all.
|
|
func metricsAddr(v string) string {
|
|
if strings.EqualFold(v, "off") || strings.EqualFold(v, "none") {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
// LogEffective prints what actually applied, one line per setting (PD-114, ratified 09.08).
|
|
//
|
|
// It exists because an environment-only configuration has no artifact an operator can read back: a
|
|
// deployment whose EnvironmentFile= did not load looks exactly like one whose values were chosen,
|
|
// and the difference used to be discoverable only from behaviour. The line carries the SOURCE as
|
|
// well as the value, because "did my setting take effect" is the actual question and a value alone
|
|
// cannot answer it — a default and an environment variable holding the same string are the same line
|
|
// otherwise.
|
|
//
|
|
// Secrets and money amounts are redacted here rather than at the call site (see Setting.Value).
|
|
func (c Config) LogEffective(log *slog.Logger) {
|
|
for _, s := range c.Settings {
|
|
log.Info("config", "key", s.Key, "value", s.Value, "source", s.Source)
|
|
}
|
|
}
|
|
|
|
// LoginEnabled reports whether a sign-in provider is configured.
|
|
func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" }
|
|
|
|
// RunsEnabled reports whether this instance can spawn engine runs at all. An instance without an
|
|
// engine binary still serves every read: the library and the book card are not run machinery.
|
|
func (c Config) RunsEnabled() bool { return c.Runner.EngineBinary != "" }
|
|
|
|
// IntakeEnabled reports whether this instance can receive a book. It needs somewhere to put the file
|
|
// AND an engine to cut it with: parsing is one call of tmctl, so a deployment without the binary
|
|
// could only take uploads it would have to reject.
|
|
func (c Config) IntakeEnabled() bool { return c.Intake.BooksDir != "" && c.RunsEnabled() }
|
|
|
|
// loadRunner reads the runner's settings.
|
|
func (c *Config) loadRunner(l *loader) error {
|
|
r := RunnerConfig{
|
|
EngineBinary: l.env("TM_PLATFORM_ENGINE_BIN", ""),
|
|
StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"),
|
|
MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", ""),
|
|
CeilingArg: l.env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"),
|
|
// A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a
|
|
// sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a
|
|
// single run can hurt the others regardless of money (PD-13).
|
|
MemoryMax: l.env("TM_PLATFORM_RUN_MEMORY_MAX", "4G"),
|
|
TasksMax: 64,
|
|
SweepEvery: 15 * time.Second,
|
|
ResyncEvery: 5 * time.Minute,
|
|
PerChapterMicroUSD: int64(pricing.DefaultPerChapter),
|
|
Workers: 4,
|
|
}
|
|
// An ABSOLUTE state directory or none. The exit marker is written by a systemd unit whose
|
|
// WorkingDirectory is the BOOK's directory and read by this daemon from its own working directory,
|
|
// so a relative path names two different files: the run finishes, its marker lands somewhere the
|
|
// reconciler never looks, and the run is restarted forever. Refused at boot, where an operator
|
|
// sees it, rather than discovered at the first end of a run.
|
|
if !filepath.IsAbs(r.StateDir) {
|
|
return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir)
|
|
}
|
|
var err error
|
|
if r.TasksMax, err = l.number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil {
|
|
return err
|
|
}
|
|
if r.AllowEngineVersionChange, err = l.boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil {
|
|
return err
|
|
}
|
|
if r.Workers, err = l.number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil {
|
|
return err
|
|
}
|
|
if r.SweepEvery, err = l.duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil {
|
|
return err
|
|
}
|
|
if r.ResyncEvery, err = l.duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil {
|
|
return err
|
|
}
|
|
if r.PerChapterMicroUSD, err = l.amount("TM_PLATFORM_USD_PER_CHAPTER", r.PerChapterMicroUSD, true); err != nil {
|
|
return err
|
|
}
|
|
c.Runner = r
|
|
return nil
|
|
}
|
|
|
|
// loadIntake reads the book upload's settings.
|
|
func (c *Config) loadIntake(l *loader) error {
|
|
in := IntakeConfig{
|
|
BooksDir: l.env("TM_PLATFORM_BOOKS_DIR", ""),
|
|
// 64 MiB and ten minutes: the acceptance corpus is a 23 MB book, and ten minutes is what a
|
|
// file that size takes on an uplink an ordinary reader has. Both are refusals rather than
|
|
// promises — the numbers an operator raises when their users hit them.
|
|
MaxUploadBytes: 64 << 20,
|
|
UploadDeadline: 10 * time.Minute,
|
|
}
|
|
if in.BooksDir != "" && !filepath.IsAbs(in.BooksDir) {
|
|
return fmt.Errorf("config: TM_PLATFORM_BOOKS_DIR must be an absolute path, got %q", in.BooksDir)
|
|
}
|
|
var err error
|
|
if in.MaxUploadBytes, err = l.bytes("TM_PLATFORM_MAX_UPLOAD_BYTES", in.MaxUploadBytes); err != nil {
|
|
return err
|
|
}
|
|
if in.UploadDeadline, err = l.duration("TM_PLATFORM_UPLOAD_DEADLINE", in.UploadDeadline); err != nil {
|
|
return err
|
|
}
|
|
// The two are a PAIR, and the boot is where a mismatched pair is cheap to find. The intake sweep
|
|
// treats a book that has been `uploading` longer than its grace as an upload whose request is
|
|
// gone — true only while one request cannot legitimately take that long. Longer, and the sweep
|
|
// deletes the row and the directory under a request that is still writing into them.
|
|
if in.UploadDeadline >= books.UploadGrace {
|
|
return fmt.Errorf("config: TM_PLATFORM_UPLOAD_DEADLINE (%s) must be shorter than the intake sweep's grace (%s)",
|
|
in.UploadDeadline, books.UploadGrace)
|
|
}
|
|
c.Intake = in
|
|
return nil
|
|
}
|
|
|
|
// loader reads the environment and REMEMBERS what it read, so that the boot line can say where each
|
|
// value came from. Every setting of this service goes through it: a print that covers most of the
|
|
// configuration is worse than none, because the variable an operator is hunting is exactly the one
|
|
// nobody thought to record.
|
|
type loader struct{ settings []Setting }
|
|
|
|
func (l *loader) record(key, value, source string) {
|
|
l.settings = append(l.settings, Setting{Key: key, Value: value, Source: source})
|
|
}
|
|
|
|
func (l *loader) env(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
l.record(key, v, sourceEnv)
|
|
return v
|
|
}
|
|
if def == "" {
|
|
l.record(key, valueUnset, sourceDefault)
|
|
} else {
|
|
l.record(key, def, sourceDefault)
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Secret is the shared way to read a credential: from KEY, or preferably from the file named by
|
|
// KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who
|
|
// followed the deploy notes has it in a file, not in the environment.
|
|
func Secret(key string) (string, error) {
|
|
var l loader
|
|
return l.secret(key)
|
|
}
|
|
|
|
// secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a
|
|
// file does not show up in /proc/<pid>/environ, is not inherited by child processes, and is exactly
|
|
// what systemd's LoadCredential= hands over (deploy/tmplatformd.service).
|
|
//
|
|
// What is recorded is that it was set and WHERE from, never the value: a DSN carries a password.
|
|
func (l *loader) secret(key string) (string, error) {
|
|
if path := os.Getenv(key + "_FILE"); path != "" {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
// The path, never the content: an unreadable secret file is an operator's problem and
|
|
// the error goes to the log.
|
|
return "", fmt.Errorf("config: %s_FILE: %w", key, err)
|
|
}
|
|
l.record(key, valueSet, sourceFile)
|
|
return strings.TrimSpace(string(b)), nil
|
|
}
|
|
v := os.Getenv(key)
|
|
switch v {
|
|
case "":
|
|
l.record(key, valueUnset, sourceDefault)
|
|
default:
|
|
l.record(key, valueSet, sourceEnv)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote
|
|
// `true` deserves an error or the truth, not a silent no.
|
|
func (l *loader) boolean(key string) (bool, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
l.record(key, "false", sourceDefault)
|
|
return false, nil
|
|
}
|
|
v, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw)
|
|
}
|
|
l.record(key, strconv.FormatBool(v), sourceEnv)
|
|
return v, nil
|
|
}
|
|
|
|
func (l *loader) number(key string, def int) (int, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
l.record(key, strconv.Itoa(def), sourceDefault)
|
|
return def, nil
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil || v <= 0 {
|
|
return 0, fmt.Errorf("config: %s must be a positive whole number, got %q", key, raw)
|
|
}
|
|
l.record(key, strconv.Itoa(v), sourceEnv)
|
|
return v, nil
|
|
}
|
|
|
|
func (l *loader) bytes(key string, def int64) (int64, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
l.record(key, strconv.FormatInt(def, 10), sourceDefault)
|
|
return def, nil
|
|
}
|
|
v, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || v <= 0 {
|
|
return 0, fmt.Errorf("config: %s must be a positive number of bytes, got %q", key, raw)
|
|
}
|
|
l.record(key, strconv.FormatInt(v, 10), sourceEnv)
|
|
return v, nil
|
|
}
|
|
|
|
func (l *loader) duration(key string, def time.Duration) (time.Duration, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
l.record(key, def.String(), sourceDefault)
|
|
return def, nil
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
|
}
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf("config: %s must be positive", key)
|
|
}
|
|
l.record(key, d.String(), sourceEnv)
|
|
return d, nil
|
|
}
|
|
|
|
// amount reads a sum in dollars into micro-USD. positiveOnly refuses zero as well as negatives,
|
|
// which is the difference between a rate (a zero rate makes every scale infinite) and a grant (a
|
|
// deployment that grants nothing is a legitimate choice).
|
|
//
|
|
// The VALUE is never recorded — money does not reach an INFO log in any form (D39.84, PD-99) — and
|
|
// the source is, which is what an operator needs to see that their figure was picked up.
|
|
func (l *loader) amount(key string, def int64, positiveOnly bool) (int64, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
l.record(key, valueAmount, sourceDefault)
|
|
return def, nil
|
|
}
|
|
v, err := money.ParseUSD(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
|
}
|
|
switch {
|
|
case positiveOnly && v <= 0:
|
|
return 0, fmt.Errorf("config: %s must be positive", key)
|
|
case v < 0:
|
|
return 0, fmt.Errorf("config: %s cannot be negative", key)
|
|
}
|
|
l.record(key, valueAmount, sourceEnv)
|
|
return int64(v), nil
|
|
}
|