textmachine/platform/internal/config/config.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
}