302 lines
12 KiB
Go
302 lines
12 KiB
Go
// Package config reads the service's settings from the environment.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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.
|
|
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
|
|
|
|
// Runner is everything about starting engine runs.
|
|
Runner RunnerConfig
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Load reads the environment.
|
|
func Load() (Config, error) {
|
|
c := Config{
|
|
Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"),
|
|
DSN: "", // read below: it may come from a file
|
|
// 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,
|
|
OIDCProvider: env("TM_PLATFORM_OIDC_PROVIDER", "google"),
|
|
OIDCIssuer: os.Getenv("TM_PLATFORM_OIDC_ISSUER"),
|
|
OIDCClientID: os.Getenv("TM_PLATFORM_OIDC_CLIENT_ID"),
|
|
OIDCClientSecret: "", // read below: it may come from a file
|
|
OIDCRedirectURL: os.Getenv("TM_PLATFORM_OIDC_REDIRECT_URL"),
|
|
AfterLogin: env("TM_PLATFORM_AFTER_LOGIN", "/"),
|
|
SignupGrantMicroUSD: 5 * 1_000_000,
|
|
}
|
|
if raw := os.Getenv("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 = boolean("TM_PLATFORM_MIGRATE"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.InsecureCookies, err = boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.DSN, err = secret("TM_PLATFORM_DSN"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.OIDCClientSecret, err = secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.SessionIdleTTL, err = duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.SessionMaxAge, err = 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 raw := os.Getenv("TM_PLATFORM_SIGNUP_GRANT_USD"); raw != "" {
|
|
v, err := money.ParseUSD(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("config: TM_PLATFORM_SIGNUP_GRANT_USD: %w", err)
|
|
}
|
|
if v < 0 {
|
|
return Config{}, errors.New("config: TM_PLATFORM_SIGNUP_GRANT_USD cannot be negative")
|
|
}
|
|
c.SignupGrantMicroUSD = int64(v)
|
|
}
|
|
if err := c.loadRunner(); 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")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// 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 != "" }
|
|
|
|
// loadRunner reads the runner's settings.
|
|
func (c *Config) loadRunner() error {
|
|
r := RunnerConfig{
|
|
EngineBinary: os.Getenv("TM_PLATFORM_ENGINE_BIN"),
|
|
StateDir: env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"),
|
|
MarkerBinary: os.Getenv("TM_PLATFORM_CTL_BIN"),
|
|
CeilingArg: 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: 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 = number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil {
|
|
return err
|
|
}
|
|
if r.AllowEngineVersionChange, err = boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil {
|
|
return err
|
|
}
|
|
if r.Workers, err = number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil {
|
|
return err
|
|
}
|
|
if r.SweepEvery, err = duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil {
|
|
return err
|
|
}
|
|
if r.ResyncEvery, err = duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil {
|
|
return err
|
|
}
|
|
if raw := os.Getenv("TM_PLATFORM_USD_PER_CHAPTER"); raw != "" {
|
|
v, err := money.ParseUSD(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("config: TM_PLATFORM_USD_PER_CHAPTER: %w", err)
|
|
}
|
|
if v <= 0 {
|
|
return errors.New("config: TM_PLATFORM_USD_PER_CHAPTER must be positive")
|
|
}
|
|
r.PerChapterMicroUSD = int64(v)
|
|
}
|
|
c.Runner = r
|
|
return nil
|
|
}
|
|
|
|
func number(key string, def int) (int, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
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)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// 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) { return 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).
|
|
func 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)
|
|
}
|
|
return strings.TrimSpace(string(b)), nil
|
|
}
|
|
return os.Getenv(key), 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 boolean(key string) (bool, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
return false, nil
|
|
}
|
|
v, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func env(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func duration(key string, def time.Duration) (time.Duration, error) {
|
|
raw := os.Getenv(key)
|
|
if raw == "" {
|
|
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)
|
|
}
|
|
return d, nil
|
|
}
|