75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ilyakaznacheev/cleanenv"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/joho/godotenv"
|
|
|
|
env_utils "databasus-backend/internal/util/env"
|
|
"databasus-backend/internal/util/logger"
|
|
"databasus-backend/internal/util/tools"
|
|
)
|
|
|
|
var log = logger.GetLogger()
|
|
|
|
const (
|
|
AppModeWeb = "web"
|
|
AppModeBackground = "background"
|
|
)
|
|
|
|
const (
|
|
// defaultTestParallelWorkers is the number of parallel test workers, used
|
|
// when TEST_PARALLEL_WORKERS is unset. Each worker gets its own metadata DB
|
|
// and Valkey logical DB, so this must stay <= 16 (Valkey's default logical DB
|
|
// count) and equal to the `go test -p` value.
|
|
defaultTestParallelWorkers = 8
|
|
|
|
// testSlotAdvisoryLockBase is the first pg_advisory_lock key reserved for
|
|
// test-worker slot claims; slot N uses base+N.
|
|
testSlotAdvisoryLockBase = 945_000_000
|
|
|
|
// testSlotClaimTimeout bounds the wait for a free slot, absorbing the brief
|
|
// window where `go test -p` starts the next package before the previous
|
|
// process has exited and released its advisory lock.
|
|
testSlotClaimTimeout = 60 * time.Second
|
|
)
|
|
|
|
type EnvVariables struct {
|
|
IsTesting bool
|
|
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
|
|
|
|
// Internal database
|
|
DatabaseDsn string `env:"DATABASE_DSN" required:"true"`
|
|
TestDatabaseDsn string `env:"TEST_DATABASE_DSN"`
|
|
TestParallelWorkers int `env:"TEST_PARALLEL_WORKERS"`
|
|
// Internal Valkey
|
|
ValkeyHost string `env:"VALKEY_HOST" required:"true"`
|
|
ValkeyPort string `env:"VALKEY_PORT" required:"true"`
|
|
ValkeyUsername string `env:"VALKEY_USERNAME"`
|
|
ValkeyPassword string `env:"VALKEY_PASSWORD"`
|
|
ValkeyIsSsl bool `env:"VALKEY_IS_SSL" required:"true"`
|
|
|
|
// Per-worker test isolation (computed, only set under `go test`): each test
|
|
// binary claims a slot 0..TestParallelWorkers-1 that selects its own metadata DB
|
|
// (DatabaseDsn is rewritten to dbname=<base>_w{slot}), its own Valkey logical
|
|
// DB (ValkeySelectDB), and a cache-key namespace ("w{slot}:"). All zero/empty in
|
|
// production.
|
|
ValkeySelectDB int
|
|
CacheNamespace string
|
|
|
|
TestLocalhost string `env:"TEST_LOCALHOST"`
|
|
|
|
ShowDbInstallationVerificationLogs bool `env:"SHOW_DB_INSTALLATION_VERIFICATION_LOGS"`
|
|
|
|
DataFolder string
|
|
TempFolder string
|
|
SecretKeyPath string
|
|
TelemetryInstancePath string
|
|
|
|
IsDisableAnonymousTelemetry bool `env:"IS_DISABLE_ANONYMOUS_TELEMETRY"`
|
|
|
|
// TestLogicalPostgres16Port is the shared always-on logical-Postgres fixture used by
|
|
// GetTestPostgresConfig / CreateTestDatabase across the test suite. The per-version PG
|
|
// logical tests (14-18 + SSL + mTLS) run on testcontainers and need no fixed port.
|
|
TestLogicalPostgres16Port string `env:"TEST_LOGICAL_POSTGRES_16_PORT"`
|
|
|
|
// The physical primary sources are the shared always-on fixture (like the logical PG-16
|
|
// fixture); the per-version, no-summary, tablespace, mTLS and restore-target containers all run
|
|
// on testcontainers and need no fixed port.
|
|
TestPhysicalPostgres17Port string `env:"TEST_PHYSICAL_POSTGRES_17_PORT"`
|
|
TestPhysicalPostgres18Port string `env:"TEST_PHYSICAL_POSTGRES_18_PORT"`
|
|
|
|
// oauth
|
|
GitHubClientID string `env:"GITHUB_CLIENT_ID"`
|
|
GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
|
|
GoogleClientID string `env:"GOOGLE_CLIENT_ID"`
|
|
GoogleClientSecret string `env:"GOOGLE_CLIENT_SECRET"`
|
|
|
|
// Cloudflare Turnstile
|
|
CloudflareTurnstileSecretKey string `env:"CLOUDFLARE_TURNSTILE_SECRET_KEY"`
|
|
CloudflareTurnstileSiteKey string `env:"CLOUDFLARE_TURNSTILE_SITE_KEY"`
|
|
|
|
// SMTP configuration (optional)
|
|
SMTPHost string `env:"SMTP_HOST"`
|
|
SMTPPort int `env:"SMTP_PORT"`
|
|
SMTPUser string `env:"SMTP_USER"`
|
|
SMTPPassword string `env:"SMTP_PASSWORD"`
|
|
SMTPFrom string `env:"SMTP_FROM"`
|
|
SMTPInsecureSkipVerify bool `env:"SMTP_INSECURE_SKIP_VERIFY"`
|
|
|
|
// Application URL (optional) - used for email links
|
|
DatabasusURL string `env:"DATABASUS_URL"`
|
|
}
|
|
|
|
var env EnvVariables
|
|
|
|
var initEnv = sync.OnceFunc(loadEnvVariables)
|
|
|
|
func GetEnv() *EnvVariables {
|
|
initEnv()
|
|
return &env
|
|
}
|
|
|
|
func loadEnvVariables() {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
log.Warn("could not get current working directory", "error", err)
|
|
cwd = "."
|
|
}
|
|
|
|
backendRoot := cwd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(backendRoot, "go.mod")); err == nil {
|
|
break
|
|
}
|
|
|
|
parent := filepath.Dir(backendRoot)
|
|
if parent == backendRoot {
|
|
break
|
|
}
|
|
|
|
backendRoot = parent
|
|
}
|
|
|
|
envPath := filepath.Join(filepath.Dir(backendRoot), ".env")
|
|
|
|
log.Info("Trying to load .env", "path", envPath)
|
|
if err := godotenv.Load(envPath); err != nil {
|
|
log.Error("Error loading .env file from repo root", "path", envPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
log.Info("Successfully loaded .env", "path", envPath)
|
|
|
|
// Empty values for non-string fields (e.g. SMTP_PORT=) crash cleanenv's
|
|
// strconv parsing. Drop them so cleanenv falls back to the Go zero value.
|
|
unsetEmptyEnvVars()
|
|
|
|
err = cleanenv.ReadEnv(&env)
|
|
if err != nil {
|
|
log.Error("Configuration could not be loaded", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if env.SMTPHost != "" && env.SMTPPort <= 0 {
|
|
log.Error("SMTP_PORT must be a positive integer when SMTP_HOST is set", "value", env.SMTPPort)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Set default value for ShowDbInstallationVerificationLogs if not defined
|
|
if os.Getenv("SHOW_DB_INSTALLATION_VERIFICATION_LOGS") == "" {
|
|
env.ShowDbInstallationVerificationLogs = true
|
|
}
|
|
|
|
for _, arg := range os.Args {
|
|
if strings.Contains(arg, "test") {
|
|
env.IsTesting = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if env.IsTesting {
|
|
if env.TestDatabaseDsn == "" {
|
|
log.Error("TEST_DATABASE_DSN is empty")
|
|
os.Exit(1)
|
|
}
|
|
|
|
env.DatabaseDsn = env.TestDatabaseDsn
|
|
|
|
if env.TestParallelWorkers <= 0 {
|
|
env.TestParallelWorkers = defaultTestParallelWorkers
|
|
}
|
|
|
|
// Only a real `go test` binary claims a per-worker slot; the cleanup_test_db
|
|
// command and any other tool run with IsTesting=true must operate on all
|
|
// slots, so they keep the base test DSN and default Valkey DB.
|
|
if strings.Contains(os.Args[0], ".test") {
|
|
applyTestWorkerSlot()
|
|
}
|
|
}
|
|
|
|
if env.DatabaseDsn == "" {
|
|
log.Error("DATABASE_DSN is empty")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if env.EnvMode == "" {
|
|
log.Error("ENV_MODE is empty")
|
|
os.Exit(1)
|
|
}
|
|
if env.EnvMode != "development" && env.EnvMode != "production" {
|
|
log.Error("ENV_MODE is invalid", "mode", env.EnvMode)
|
|
os.Exit(1)
|
|
}
|
|
log.Info("ENV_MODE loaded", "mode", env.EnvMode)
|
|
|
|
tools.LogAndExitIfClientToolsBroken(log, env.ShowDbInstallationVerificationLogs)
|
|
|
|
if env.TestLocalhost == "" {
|
|
env.TestLocalhost = "localhost"
|
|
}
|
|
|
|
// Valkey
|
|
if env.ValkeyHost == "" {
|
|
log.Error("VALKEY_HOST is empty")
|
|
os.Exit(1)
|
|
}
|
|
if env.ValkeyPort == "" {
|
|
log.Error("VALKEY_PORT is empty")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Store the data and temp folders one level below the root
|
|
// (projectRoot/databasus-data -> /databasus-data)
|
|
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "databasus-data", "backups")
|
|
env.TempFolder = filepath.Join(filepath.Dir(backendRoot), "databasus-data", "temp")
|
|
env.SecretKeyPath = filepath.Join(filepath.Dir(backendRoot), "databasus-data", "secret.key")
|
|
env.TelemetryInstancePath = filepath.Join(
|
|
filepath.Dir(backendRoot), "databasus-data", "instance.json",
|
|
)
|
|
|
|
if env.IsTesting {
|
|
if env.TestLogicalPostgres16Port == "" {
|
|
log.Error("TEST_LOGICAL_POSTGRES_16_PORT is empty")
|
|
os.Exit(1)
|
|
}
|
|
if env.TestPhysicalPostgres17Port == "" {
|
|
log.Error("TEST_PHYSICAL_POSTGRES_17_PORT is empty")
|
|
os.Exit(1)
|
|
}
|
|
if env.TestPhysicalPostgres18Port == "" {
|
|
log.Error("TEST_PHYSICAL_POSTGRES_18_PORT is empty")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
log.Info("Environment variables loaded successfully!")
|
|
}
|
|
|
|
func unsetEmptyEnvVars() {
|
|
for _, kv := range os.Environ() {
|
|
key, value, ok := strings.Cut(kv, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if value == "" {
|
|
_ = os.Unsetenv(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// slotLockConn holds the system-DB connection whose session owns this worker's
|
|
// advisory lock. It must live for the whole process: closing it (or letting it
|
|
// be garbage-collected) releases the lock and frees the slot for another worker
|
|
// mid-run. It is assigned-only by design — the reference itself is the point, so
|
|
// it anchors the connection lifetime as a GC root.
|
|
//
|
|
//nolint:unused // assigned-only: keeps the advisory-lock connection alive for the process lifetime
|
|
var slotLockConn *sql.Conn
|
|
|
|
// applyTestWorkerSlot claims a free slot for this test binary and rewrites the
|
|
// env so the worker runs fully isolated: its own metadata DB, Valkey logical DB,
|
|
// and registry namespace.
|
|
func applyTestWorkerSlot() {
|
|
baseDbName, _, err := RewriteDbName(env.TestDatabaseDsn, systemDbName)
|
|
if err != nil {
|
|
log.Error("could not parse TEST_DATABASE_DSN for slot isolation", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
slot := claimTestWorkerSlot(env.TestDatabaseDsn, env.TestParallelWorkers)
|
|
|
|
slotDbName := fmt.Sprintf("%s_w%d", baseDbName, slot)
|
|
_, slotDsn, err := RewriteDbName(env.TestDatabaseDsn, slotDbName)
|
|
if err != nil {
|
|
log.Error("could not build per-slot DSN", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
env.DatabaseDsn = slotDsn
|
|
env.ValkeySelectDB = slot
|
|
env.CacheNamespace = fmt.Sprintf("w%d:", slot)
|
|
|
|
log.Info("claimed test worker slot", "slot", slot, "db", slotDbName)
|
|
}
|
|
|
|
// systemDbName is the always-present database used for slot coordination and as
|
|
// the connection target when (re)creating per-slot databases.
|
|
const systemDbName = "postgres"
|
|
|
|
// claimTestWorkerSlot acquires a session-level advisory lock for the first free
|
|
// slot in [0,pool) on the system Postgres DB and returns it. The lock is held by
|
|
// slotLockConn until the process exits. It retries until testSlotClaimTimeout to
|
|
// absorb the handoff window where `go test -p` overlaps a finishing worker.
|
|
func claimTestWorkerSlot(testDsn string, pool int) int {
|
|
_, systemDsn, err := RewriteDbName(testDsn, systemDbName)
|
|
if err != nil {
|
|
log.Error("could not build system DSN for slot claim", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
db, err := sql.Open("pgx", systemDsn)
|
|
if err != nil {
|
|
log.Error("could not open system DB for slot claim", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
conn, err := db.Conn(ctx)
|
|
if err != nil {
|
|
log.Error("could not get system DB connection for slot claim", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
deadline := time.Now().Add(testSlotClaimTimeout)
|
|
for {
|
|
for slot := range pool {
|
|
var locked bool
|
|
lockErr := conn.QueryRowContext(
|
|
ctx,
|
|
"SELECT pg_try_advisory_lock($1)",
|
|
testSlotAdvisoryLockBase+int64(slot),
|
|
).Scan(&locked)
|
|
if lockErr != nil {
|
|
log.Error("advisory lock query failed during slot claim", "error", lockErr)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if locked {
|
|
slotLockConn = conn
|
|
return slot
|
|
}
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
log.Error(
|
|
"no free test DB slot",
|
|
"pool", pool,
|
|
"hint", "TEST_PARALLEL_WORKERS must be >= the `go test -p` value",
|
|
)
|
|
os.Exit(1)
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// RewriteDbName replaces the dbname= token in a keyword/value Postgres DSN,
|
|
// returning the original dbname and the rewritten DSN. Used both to derive the
|
|
// system DSN and to build per-slot test DSNs.
|
|
func RewriteDbName(dsn, newDbName string) (origDbName, rewritten string, err error) {
|
|
parts := strings.Fields(dsn)
|
|
out := make([]string, 0, len(parts))
|
|
|
|
for _, p := range parts {
|
|
k, v, ok := strings.Cut(p, "=")
|
|
if !ok {
|
|
return "", "", fmt.Errorf("invalid DSN token: %q", p)
|
|
}
|
|
|
|
if k == "dbname" {
|
|
origDbName = v
|
|
out = append(out, "dbname="+newDbName)
|
|
continue
|
|
}
|
|
|
|
out = append(out, p)
|
|
}
|
|
|
|
if origDbName == "" {
|
|
return "", "", fmt.Errorf("DSN missing dbname: %q", dsn)
|
|
}
|
|
|
|
return origDbName, strings.Join(out, " "), nil
|
|
}
|