261 lines
9.2 KiB
Go
261 lines
9.2 KiB
Go
// Config: coordinator settings, read only from the environment, so the same
|
|
// binary behaves identically in CI, local, and prod.
|
|
package infra
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// defaultEnvFile is loaded by Load unless ENV_FILE points elsewhere.
|
|
const defaultEnvFile = ".env"
|
|
|
|
type Config struct {
|
|
// HTTP listen address, e.g. ":8080".
|
|
Addr string
|
|
// PostgreSQL connection string (pgx format / libpq URL).
|
|
DatabaseURL string
|
|
// Shared bearer token workers must present. Empty disables auth (dev only).
|
|
Token string
|
|
// Local operator UI credential. Empty disables the embedded UI entirely.
|
|
UIToken string
|
|
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
|
|
// submitter may authenticate with a JWT (in addition to workers using the
|
|
// shared token) and their jobs are stamped with owner_id. Empty disables
|
|
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
|
|
// userservice's JWT_SECRET.
|
|
JWTSecret string
|
|
// Base URL of the userservice, e.g. http://userservice:8081. When set
|
|
// together with JWTSecret, the operator UI authenticates via userservice
|
|
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
|
|
// basic auth. Empty keeps the basic-auth UI.
|
|
UserserviceURL string
|
|
// Browser-facing base URLs used to render the "add your machine" command on
|
|
// the UI. They must be reachable from a user's own machine, which is not
|
|
// necessarily the in-cluster address the coordinator uses for UserserviceURL.
|
|
// PublicCoordinatorURL empty lets the page fall back to its own origin;
|
|
// PublicUserserviceURL empty falls back to UserserviceURL.
|
|
PublicCoordinatorURL string
|
|
PublicUserserviceURL string
|
|
|
|
// Minimum log level: debug, info, warn, error.
|
|
LogLevel string
|
|
// Path to a rotated log file. Empty logs to stdout only.
|
|
LogFile string
|
|
// Directory where artifact bytes are stored.
|
|
StorageDir string
|
|
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
|
|
// disables the docs route; the UI shows a hint page instead.
|
|
DocsDir string
|
|
// Upper bound on an uploaded dataset or artifact body, in bytes.
|
|
MaxUploadBytes int64
|
|
|
|
// Connection pool upper bound.
|
|
DBMaxConns int32
|
|
// How long to keep retrying the initial database connection at startup
|
|
// before giving up. Covers a Postgres container that is still booting.
|
|
DBConnectTimeout time.Duration
|
|
// Per-request context timeout applied to handlers and DB calls.
|
|
RequestTimeout time.Duration
|
|
|
|
// Suggested heartbeat cadence returned to workers on registration.
|
|
HeartbeatInterval time.Duration
|
|
// Default lease length handed out on claim.
|
|
LeaseDuration time.Duration
|
|
// Default attempt ceiling for newly created tasks.
|
|
DefaultMaxAttempts int
|
|
// How many distinct owners must agree on an untrusted result before it is
|
|
// accepted (trusted workers are accepted directly).
|
|
QuorumSize int
|
|
// How often the background lease-reaper runs.
|
|
ReaperInterval time.Duration
|
|
// A worker silent for longer than this is marked offline by the reaper.
|
|
WorkerOfflineAfter time.Duration
|
|
// Whether the binary applies its embedded schema migrations on startup.
|
|
// On by default so a downloaded binary provisions its own database; set
|
|
// AUTO_MIGRATE=false when an operator manages migrations out of band.
|
|
AutoMigrate bool
|
|
// DatabaseEngine selects the storage backend: "sqlite" (embedded, the
|
|
// single-binary default) or "postgres" (cluster deployments). The
|
|
// postgres engine requires DATABASE_URL.
|
|
DatabaseEngine string
|
|
// DBPath is the sqlite database file (engine=sqlite only).
|
|
DBPath string
|
|
}
|
|
|
|
// Load reads the environment and fails fast on anything required-but-missing
|
|
// or malformed, so a misconfigured process never limps along half-wired.
|
|
//
|
|
// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev
|
|
// convenience. It only fills variables the environment does not already define.
|
|
func LoadConfig() (Config, error) {
|
|
envFile := os.Getenv("ENV_FILE")
|
|
if envFile == "" {
|
|
envFile = defaultEnvFile
|
|
}
|
|
// godotenv.Load never overwrites variables already present in the
|
|
// environment, so an orchestrator's values always beat the file. A missing
|
|
// file is expected in production, where env vars are injected directly.
|
|
if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return Config{}, fmt.Errorf("load env file %q: %w", envFile, err)
|
|
}
|
|
|
|
cfg := Config{
|
|
Addr: getEnv("COORDINATOR_ADDR", ":8080"),
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
// COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the
|
|
// former name, still honoured so existing .env files keep working.
|
|
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
|
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
|
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
|
PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"),
|
|
PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")),
|
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
|
LogFile: os.Getenv("LOG_FILE"),
|
|
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
|
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
|
|
MaxUploadBytes: 1 << 30, // 1 GiB
|
|
DBMaxConns: 10,
|
|
DBConnectTimeout: 30 * time.Second,
|
|
RequestTimeout: 15 * time.Second,
|
|
HeartbeatInterval: 15 * time.Second,
|
|
LeaseDuration: 2 * time.Minute,
|
|
DefaultMaxAttempts: 3,
|
|
QuorumSize: 2,
|
|
ReaperInterval: 30 * time.Second,
|
|
WorkerOfflineAfter: 1 * time.Minute,
|
|
}
|
|
|
|
cfg.DatabaseEngine = getEnv("SCIMESH_DB", "sqlite")
|
|
switch cfg.DatabaseEngine {
|
|
case "sqlite", "postgres":
|
|
default:
|
|
return Config{}, fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
|
|
}
|
|
cfg.DBPath = getEnv("SCIMESH_DB_PATH", filepath.Join(cfg.StorageDir, "scimesh.db"))
|
|
|
|
if cfg.DatabaseEngine == "postgres" && cfg.DatabaseURL == "" {
|
|
return Config{}, fmt.Errorf("DATABASE_URL is required for the postgres engine")
|
|
}
|
|
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
|
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
|
}
|
|
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
|
|
// than verify tokens against it.
|
|
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
|
|
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
|
}
|
|
|
|
var err error
|
|
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.MaxUploadBytes, err = getEnvInt64("MAX_UPLOAD_BYTES", cfg.MaxUploadBytes); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.HeartbeatInterval, err = getEnvDuration("HEARTBEAT_INTERVAL", cfg.HeartbeatInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.LeaseDuration, err = getEnvDuration("LEASE_DURATION", cfg.LeaseDuration); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.ReaperInterval, err = getEnvDuration("REAPER_INTERVAL", cfg.ReaperInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.WorkerOfflineAfter, err = getEnvDuration("WORKER_OFFLINE_AFTER", cfg.WorkerOfflineAfter); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.QuorumSize < 1 {
|
|
return Config{}, fmt.Errorf("QUORUM_SIZE must be positive")
|
|
}
|
|
if cfg.DefaultMaxAttempts < 1 {
|
|
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
|
|
}
|
|
cfg.AutoMigrate = true
|
|
if raw := os.Getenv("AUTO_MIGRATE"); raw != "" {
|
|
parsed, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false")
|
|
}
|
|
cfg.AutoMigrate = parsed
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func getEnv(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getEnvInt(key string, def int) (int, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def, nil
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func getEnvInt32(key string, def int32) (int32, error) {
|
|
n, err := getEnvInt(key, int(def))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// On 64-bit builds int is wider than int32, so an oversized value would
|
|
// wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size.
|
|
if n < math.MinInt32 || n > math.MaxInt32 {
|
|
return 0, fmt.Errorf("%s: %d is out of range for int32", key, n)
|
|
}
|
|
return int32(n), nil
|
|
}
|
|
|
|
func getEnvInt64(key string, def int64) (int64, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def, nil
|
|
}
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func getEnvDuration(key string, def time.Duration) (time.Duration, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def, nil
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
return d, nil
|
|
}
|