170 lines
5.7 KiB
Go
170 lines
5.7 KiB
Go
package configstore
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PostgresConfig represents the configuration for a Postgres database.
|
|
type PostgresConfig struct {
|
|
Host *schemas.EnvVar `json:"host"`
|
|
Port *schemas.EnvVar `json:"port"`
|
|
User *schemas.EnvVar `json:"user"`
|
|
Password *schemas.EnvVar `json:"password"`
|
|
DBName *schemas.EnvVar `json:"db_name"`
|
|
SSLMode *schemas.EnvVar `json:"ssl_mode"`
|
|
MaxIdleConns int `json:"max_idle_conns"`
|
|
MaxOpenConns int `json:"max_open_conns"`
|
|
}
|
|
|
|
// buildPostgresDSN assembles a libpq-style DSN from the validated config.
|
|
func buildPostgresDSN(config *PostgresConfig) string {
|
|
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
|
config.Host.GetValue(), config.Port.GetValue(), config.User.GetValue(),
|
|
config.Password.GetValue(), config.DBName.GetValue(), config.SSLMode.GetValue())
|
|
}
|
|
|
|
// openPostresConnection opens a *gorm.DB against the configured Postgres instance
|
|
// using the shared bifrost logger. Used for both the throwaway migration pool
|
|
// and the runtime pool.
|
|
func openPostresConnection(dsn string, logger schemas.Logger) (*gorm.DB, error) {
|
|
return gorm.Open(postgres.New(postgres.Config{DSN: dsn}), &gorm.Config{
|
|
Logger: newGormLogger(logger),
|
|
})
|
|
}
|
|
|
|
// closeDbConn closes the *sql.DB backing a *gorm.DB, logging any error.
|
|
// Used in error paths and for the throwaway migration pool.
|
|
func closeDbConn(db *gorm.DB, logger schemas.Logger) {
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
logger.Error("failed to resolve *sql.DB for close: %v", err)
|
|
return
|
|
}
|
|
if err := sqlDB.Close(); err != nil {
|
|
logger.Error("failed to close DB connection: %v", err)
|
|
}
|
|
}
|
|
|
|
// applyPostgresPoolTuning applies MaxIdleConns / MaxOpenConns from config to
|
|
// the supplied *gorm.DB, falling back to defaults when the config leaves the
|
|
// field at zero.
|
|
func applyPostgresPoolTuning(db *gorm.DB, config *PostgresConfig) error {
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
maxIdleConns := config.MaxIdleConns
|
|
if maxIdleConns == 0 {
|
|
maxIdleConns = 5
|
|
}
|
|
sqlDB.SetMaxIdleConns(maxIdleConns)
|
|
maxOpenConns := config.MaxOpenConns
|
|
if maxOpenConns == 0 {
|
|
maxOpenConns = 50
|
|
}
|
|
sqlDB.SetMaxOpenConns(maxOpenConns)
|
|
return nil
|
|
}
|
|
|
|
// newPostgresConfigStore creates a new Postgres config store.
|
|
//
|
|
// Uses a two-pool lifecycle to avoid SQLSTATE 0A000 ("cached plan must not
|
|
// change result type"): a throwaway migration pool runs DDL and is closed
|
|
// immediately, then a fresh runtime pool is opened. The runtime pool's
|
|
// connections never see pre-migration schema, so their cached prepared-plans
|
|
// stay valid for the life of the process.
|
|
func newPostgresConfigStore(ctx context.Context, config *PostgresConfig, logger schemas.Logger) (ConfigStore, error) {
|
|
if config == nil {
|
|
return nil, fmt.Errorf("config is required")
|
|
}
|
|
if config.Host == nil || config.Host.GetValue() == "" {
|
|
return nil, fmt.Errorf("postgres host is required")
|
|
}
|
|
if config.Port == nil || config.Port.GetValue() == "" {
|
|
return nil, fmt.Errorf("postgres port is required")
|
|
}
|
|
if config.User == nil || config.User.GetValue() == "" {
|
|
return nil, fmt.Errorf("postgres user is required")
|
|
}
|
|
if config.Password == nil {
|
|
return nil, fmt.Errorf("postgres password is required")
|
|
}
|
|
if config.DBName == nil || config.DBName.GetValue() == "" {
|
|
return nil, fmt.Errorf("postgres db name is required")
|
|
}
|
|
if config.SSLMode == nil || config.SSLMode.GetValue() == "" {
|
|
return nil, fmt.Errorf("postgres ssl mode is required")
|
|
}
|
|
dsn := buildPostgresDSN(config)
|
|
|
|
// Throwaway pool for schema migrations. Closing it before the runtime pool
|
|
// opens guarantees no cached prepared-plan survives the DDL.
|
|
mDb, err := openPostresConnection(dsn, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := triggerMigrations(ctx, mDb); err != nil {
|
|
closeDbConn(mDb, logger)
|
|
return nil, err
|
|
}
|
|
closeDbConn(mDb, logger)
|
|
|
|
// Runtime pool. Opens against post-migration schema.
|
|
db, err := openPostresConnection(dsn, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := applyPostgresPoolTuning(db, config); err != nil {
|
|
closeDbConn(db, logger)
|
|
return nil, err
|
|
}
|
|
|
|
d := &RDBConfigStore{logger: logger}
|
|
d.db.Store(db)
|
|
|
|
// migrateOnFreshFn: downstream consumers (e.g. bifrost-enterprise) run
|
|
// their migrations via this hook on a throwaway pool that closes after fn.
|
|
d.migrateOnFreshFn = func(ctx context.Context, fn func(context.Context, *gorm.DB) error) error {
|
|
tempDB, err := openPostresConnection(dsn, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDbConn(tempDB, logger)
|
|
return fn(ctx, tempDB)
|
|
}
|
|
|
|
// refreshPoolFn: open fresh runtime pool first (so a failure leaves the
|
|
// existing pool in place), swap atomically, then close the old pool.
|
|
// sql.DB.Close blocks until in-flight queries finish, so callers already
|
|
// using the old pool complete safely.
|
|
d.refreshPoolFn = func(ctx context.Context) error {
|
|
newDB, err := openPostresConnection(dsn, logger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open fresh runtime pool: %w", err)
|
|
}
|
|
if err := applyPostgresPoolTuning(newDB, config); err != nil {
|
|
closeDbConn(newDB, logger)
|
|
return fmt.Errorf("failed to tune fresh runtime pool: %w", err)
|
|
}
|
|
oldDB := d.db.Swap(newDB)
|
|
if oldDB != nil {
|
|
closeDbConn(oldDB, logger)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Encrypt any plaintext rows if encryption is enabled. Runs on the
|
|
// runtime pool — pure DML (SELECT + UPDATE), no DDL, so cached plans it
|
|
// installs remain valid until the next external migration batch.
|
|
if err := d.EncryptPlaintextRows(ctx); err != nil {
|
|
closeDbConn(db, logger)
|
|
return nil, fmt.Errorf("failed to encrypt plaintext rows: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|