Files
SciMesh/users/internal/infra/config.go
T
Efremenko Arhip a7e949a0a7 feat(users): bootstrap first admin on startup
BOOTSTRAP_ADMIN_EMAIL/PASSWORD seed a role=admin account at boot if absent —
the only way to get the first admin, since /register makes plain users and
promotion needs an existing admin. Idempotent and race-safe. Tests included.
2026-07-26 19:44:06 +03:00

161 lines
4.9 KiB
Go

// Config: userservice 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 LoadConfig unless ENV_FILE points elsewhere.
const defaultEnvFile = ".env"
type Config struct {
// HTTP listen address, e.g. ":8081".
Addr string
// PostgreSQL connection string (pgx format / libpq URL).
DatabaseURL string
// Shared HS256 secret used to sign JWTs. The coordinator verifies tokens
// with this same secret, so the two values MUST match. This is the only
// secret shared between the services.
JWTSecret string
// How long an issued token stays valid.
TokenTTL time.Duration
// bcrypt work factor. 0 falls back to the library default (currently 10).
BcryptCost int
// Optional first-admin bootstrap. When both are set and no such account
// exists, the service creates it with role=admin on startup — the only way
// to get the first admin, since /register always makes a plain user and
// promotion needs an existing admin. Idempotent: a no-op once created.
BootstrapAdminEmail string
BootstrapAdminPassword string
// Minimum log level: debug, info, warn, error.
LogLevel string
// Path to a rotated log file. Empty logs to stdout only.
LogFile string
// 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 timeout applied to every handler.
RequestTimeout time.Duration
}
// LoadConfig 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("USERSERVICE_ADDR", ":8081"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
BootstrapAdminEmail: os.Getenv("BOOTSTRAP_ADMIN_EMAIL"),
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
TokenTTL: 24 * time.Hour,
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTSecret == "" {
return Config{}, fmt.Errorf("JWT_SECRET is required")
}
// A short secret makes the HMAC brute-forceable; refuse to start with one.
if len(cfg.JWTSecret) < 32 {
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
}
var err error
if cfg.TokenTTL, err = getEnvDuration("JWT_TTL", cfg.TokenTTL); err != nil {
return Config{}, err
}
if cfg.BcryptCost, err = getEnvInt("BCRYPT_COST", cfg.BcryptCost); err != nil {
return Config{}, err
}
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.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
return Config{}, err
}
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 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
}