194 lines
6.0 KiB
Go
194 lines
6.0 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"
|
|
"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
|
|
|
|
// 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
|
|
// 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 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
|
|
}
|
|
|
|
// 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"),
|
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
|
LogFile: os.Getenv("LOG_FILE"),
|
|
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
|
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,
|
|
ReaperInterval: 30 * time.Second,
|
|
WorkerOfflineAfter: 1 * time.Minute,
|
|
}
|
|
|
|
if cfg.DatabaseURL == "" {
|
|
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
|
}
|
|
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
|
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
|
}
|
|
|
|
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.DefaultMaxAttempts < 1 {
|
|
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
|
|
}
|
|
|
|
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
|
|
}
|