Files
SciMesh/coordinator/internal/infra/config.go
T
Efremenko Arhip 18d58cce84 feat(coordinator): quorum verification for untrusted (volunteer) results
Replaces the C1 quarantine with real verification. Trusted results (lab token,
verified, or admin worker) are accepted directly as before. An untrusted
worker's result is recorded as one vote per (task, owner) in a new task_results
table; the task only completes once QUORUM_SIZE distinct owners submit the same
result hash, otherwise it returns to the queue for another independent compute.

- migration 0013 task_results (one vote per owner, quorum by result_sha256)
- CompleteTask branches on worker trust; unknown worker defaults trusted (safe:
  completing needs the lease, whose owner is always a known registered worker)
- claim drops the quarantine and excludes chunks the owner already voted on
- domain Task.ReleaseAfterVote; QUORUM_SIZE config (default 2)
- unit tests: trusted direct-complete, untrusted needs-quorum, can-claim

Reducer and job done/total logic untouched — still one completed task per chunk.
2026-07-26 22:55:51 +03:00

222 lines
7.4 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
// 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
// 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 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
}
// 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"),
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,
QuorumSize: 2,
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")
}
// 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")
}
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
}