Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:11:07 +08:00

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
}