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.
161 lines
4.9 KiB
Go
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
|
|
}
|