mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
e2076beb9f
relates to: https://github.com/coder/internal/issues/1026 On POSIX systems (macOS and Linux) we compile the dbcleaner binary into a temp directory. This allows us to explicitly separate the compilation step and report the time it takes. We suspect this might be a contributing factor in the above linked flakes we see on macOS. This doesn't work on Windows because Go tests clean up the temp directory at the end of the test and the dbcleaner binary will still be executing. On Windows you cannot delete a file being executed nor the directory. However, we are not seeing any flakes on Windows so the old behavior seems to be OK.
507 lines
16 KiB
Go
507 lines
16 KiB
Go
package dbtestutil
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/gofrs/flock"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/ory/dockertest/v3/docker"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database/migrations"
|
|
"github.com/coder/retry"
|
|
)
|
|
|
|
const postgresImage = "us-docker.pkg.dev/coder-v2-images-public/public/postgres"
|
|
|
|
type ConnectionParams struct {
|
|
Username string
|
|
Password string
|
|
Host string
|
|
Port string
|
|
DBName string
|
|
}
|
|
|
|
func (p ConnectionParams) DSN() string {
|
|
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", p.Username, p.Password, p.Host, p.Port, p.DBName)
|
|
}
|
|
|
|
// These variables are global because all tests share them.
|
|
var (
|
|
connectionParamsInitOnce sync.Once
|
|
defaultConnectionParams ConnectionParams
|
|
errDefaultConnectionParamsInit error
|
|
retryableErrSubstrings = []string{
|
|
"connection reset by peer",
|
|
}
|
|
noPostgresRunningErrSubstrings = []string{
|
|
"connection refused", // nothing is listening on the port
|
|
"No connection could be made", // Windows variant of the above
|
|
}
|
|
DefaultBroker = Broker{}
|
|
)
|
|
|
|
// initDefaultConnection initializes the default postgres connection parameters.
|
|
// It first checks if the database is running at localhost:5432. If it is, it will
|
|
// use that database. If it's not, it will start a new container and use that.
|
|
func initDefaultConnection(t TBSubset) error {
|
|
params := ConnectionParams{
|
|
Username: "postgres",
|
|
Password: "postgres",
|
|
Host: "127.0.0.1",
|
|
Port: "5432",
|
|
DBName: "postgres",
|
|
}
|
|
dsn := params.DSN()
|
|
|
|
// Helper closure to try opening and pinging the default Postgres instance.
|
|
// Used within a single retry loop that handles both retryable and permanent errors.
|
|
attemptConn := func() error {
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err == nil {
|
|
err = db.Ping()
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
return xerrors.Errorf("close db: %w", closeErr)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
var dbErr error
|
|
// Retry up to 10 seconds for temporary errors.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for r := retry.New(10*time.Millisecond, 500*time.Millisecond); r.Wait(ctx); {
|
|
dbErr = attemptConn()
|
|
if dbErr == nil {
|
|
break
|
|
}
|
|
errString := dbErr.Error()
|
|
if !containsAnySubstring(errString, retryableErrSubstrings) {
|
|
break
|
|
}
|
|
t.Logf("%s failed to connect to postgres, retrying: %s", time.Now().Format(time.StampMilli), errString)
|
|
}
|
|
|
|
// After the loop dbErr is the last connection error (if any).
|
|
if dbErr != nil && containsAnySubstring(dbErr.Error(), noPostgresRunningErrSubstrings) {
|
|
// If there's no database running on the default port, we'll start a
|
|
// postgres container. We won't be cleaning it up so it can be reused
|
|
// by subsequent tests. It'll keep on running until the user terminates
|
|
// it manually.
|
|
container, _, err := openContainer(t, DBContainerOptions{
|
|
Name: "coder-test-postgres",
|
|
Port: 5432,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("open container: %w", err)
|
|
}
|
|
params.Host = container.Host
|
|
params.Port = container.Port
|
|
dsn = params.DSN()
|
|
|
|
// Retry connecting for at most 10 seconds.
|
|
// The fact that openContainer succeeded does not
|
|
// mean that port forwarding is ready.
|
|
for r := retry.New(100*time.Millisecond, 10*time.Second); r.Wait(context.Background()); {
|
|
db, connErr := sql.Open("postgres", dsn)
|
|
if connErr == nil {
|
|
connErr = db.Ping()
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
return xerrors.Errorf("close db, container: %w", closeErr)
|
|
}
|
|
}
|
|
if connErr == nil {
|
|
break
|
|
}
|
|
t.Logf("failed to connect to postgres after starting container, may retry: %s", connErr.Error())
|
|
}
|
|
} else if dbErr != nil {
|
|
return xerrors.Errorf("open postgres connection: %w", dbErr)
|
|
}
|
|
defaultConnectionParams = params
|
|
return nil
|
|
}
|
|
|
|
type OpenOptions struct {
|
|
DBFrom *string
|
|
LogDSN bool
|
|
}
|
|
|
|
type OpenOption func(*OpenOptions)
|
|
|
|
// WithDBFrom sets the template database to use when creating a new database.
|
|
// Overrides the DB_FROM environment variable.
|
|
func WithDBFrom(dbFrom string) OpenOption {
|
|
return func(o *OpenOptions) {
|
|
o.DBFrom = &dbFrom
|
|
}
|
|
}
|
|
|
|
// WithLogDSN sets whether the DSN should be logged during testing.
|
|
// This provides an ergonomic way to connect to test databases during debugging.
|
|
func WithLogDSN(logDSN bool) OpenOption {
|
|
return func(o *OpenOptions) {
|
|
o.LogDSN = logDSN
|
|
}
|
|
}
|
|
|
|
// TBSubset is a subset of the testing.TB interface.
|
|
// It allows to use dbtestutil.Open outside of tests.
|
|
type TBSubset interface {
|
|
Name() string
|
|
Cleanup(func())
|
|
Helper()
|
|
Logf(format string, args ...any)
|
|
TempDir() string
|
|
}
|
|
|
|
// Open creates a new PostgreSQL database instance.
|
|
// If there's a database running at localhost:5432, it will use that.
|
|
// Otherwise, it will start a new postgres container.
|
|
func Open(t TBSubset, opts ...OpenOption) (string, error) {
|
|
t.Helper()
|
|
params, err := DefaultBroker.Create(t, opts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return params.DSN(), nil
|
|
}
|
|
|
|
// createDatabaseFromTemplate creates a new database from a template database.
|
|
// If templateDBName is empty, it will create a new template database based on
|
|
// the current migrations, and name it "tpl_<migrations_hash>". Or if it's
|
|
// already been created, it will use that.
|
|
func createDatabaseFromTemplate(t TBSubset, connParams ConnectionParams, db *sql.DB, newDBName string, templateDBName string) error {
|
|
t.Helper()
|
|
|
|
emptyTemplateDBName := templateDBName == ""
|
|
if emptyTemplateDBName {
|
|
templateDBName = fmt.Sprintf("tpl_%s", migrations.GetMigrationsHash()[:32])
|
|
}
|
|
_, err := db.Exec("CREATE DATABASE " + newDBName + " WITH TEMPLATE " + templateDBName)
|
|
if err == nil {
|
|
// Template database already exists and we successfully created the new database.
|
|
return nil
|
|
}
|
|
tplDbDoesNotExistOccurred := strings.Contains(err.Error(), "template database") && strings.Contains(err.Error(), "does not exist")
|
|
if (tplDbDoesNotExistOccurred && !emptyTemplateDBName) || !tplDbDoesNotExistOccurred {
|
|
// First and case: user passed a templateDBName that doesn't exist.
|
|
// Second and case: some other error.
|
|
return xerrors.Errorf("create db with template: %w", err)
|
|
}
|
|
if !emptyTemplateDBName {
|
|
// sanity check
|
|
panic("templateDBName is not empty. there's a bug in the code above")
|
|
}
|
|
|
|
// The templateDBName is empty, so we need to create the template database.
|
|
err = createAndInitDatabase(t, connParams, db, templateDBName, func(tplDb *sql.DB) error {
|
|
if err := migrations.Up(tplDb); err != nil {
|
|
return xerrors.Errorf("migrate template db: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("create template database: %w", err)
|
|
}
|
|
|
|
// Try to create the database again now that a template exists.
|
|
if _, err = db.Exec("CREATE DATABASE " + newDBName + " WITH TEMPLATE " + templateDBName); err != nil {
|
|
return xerrors.Errorf("create db with template after migrations: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createAndInitDatabase(t TBSubset, connParams ConnectionParams, db *sql.DB, name string, initialize func(*sql.DB) error) error {
|
|
// We will use a tx to obtain a lock, so another test or process doesn't race with us.
|
|
tx, err := db.BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
return xerrors.Errorf("begin tx: %w", err)
|
|
}
|
|
// we only use the transaction for locking and querying, so it's fine to always roll it back.
|
|
defer func() {
|
|
err := tx.Rollback()
|
|
if err != nil && !errors.Is(err, sql.ErrTxDone) {
|
|
t.Logf("create database: failed to rollback tx: %s\n", err.Error())
|
|
}
|
|
}()
|
|
// 2137 is an arbitrary number. We just need a lock that is unique to creating
|
|
// the database.
|
|
_, err = tx.Exec("SELECT pg_advisory_xact_lock(2137)")
|
|
if err != nil {
|
|
return xerrors.Errorf("acquire lock: %w", err)
|
|
}
|
|
|
|
// Someone else might have created the db while we were waiting.
|
|
dbExistsRes, err := tx.Query("SELECT 1 FROM pg_database WHERE datname = $1", name)
|
|
if err != nil {
|
|
return xerrors.Errorf("check if db exists: %w", err)
|
|
}
|
|
dbAlreadyExists := dbExistsRes.Next()
|
|
if err := dbExistsRes.Close(); err != nil {
|
|
return xerrors.Errorf("close tpl db exists res: %w", err)
|
|
}
|
|
if dbAlreadyExists {
|
|
return nil
|
|
}
|
|
|
|
// We will use a temporary database to avoid race conditions. We will
|
|
// rename it to the real database name after we're sure it was fully
|
|
// initialized.
|
|
// It's dropped here to ensure that if a previous run of this function failed
|
|
// midway, we don't encounter issues with the temporary database still existing.
|
|
tmpDBName := "tmp_" + name
|
|
// We're using db instead of tx here because you can't run `DROP DATABASE` inside
|
|
// a transaction.
|
|
if _, err := db.Exec("DROP DATABASE IF EXISTS " + tmpDBName); err != nil {
|
|
return xerrors.Errorf("drop tmp db: %w", err)
|
|
}
|
|
if _, err := db.Exec("CREATE DATABASE " + tmpDBName); err != nil {
|
|
return xerrors.Errorf("create tmp db: %w", err)
|
|
}
|
|
tmpDbURL := ConnectionParams{
|
|
Username: connParams.Username,
|
|
Password: connParams.Password,
|
|
Host: connParams.Host,
|
|
Port: connParams.Port,
|
|
DBName: tmpDBName,
|
|
}.DSN()
|
|
tmpDb, err := sql.Open("postgres", tmpDbURL)
|
|
if err != nil {
|
|
return xerrors.Errorf("connect to template db: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := tmpDb.Close(); err != nil {
|
|
t.Logf("failed to close temp db: %s\n", err.Error())
|
|
}
|
|
}()
|
|
if err := initialize(tmpDb); err != nil {
|
|
return xerrors.Errorf("initialize: %w", err)
|
|
}
|
|
if err := tmpDb.Close(); err != nil {
|
|
return xerrors.Errorf("close template db: %w", err)
|
|
}
|
|
if _, err := db.Exec("ALTER DATABASE " + tmpDBName + " RENAME TO " + name); err != nil {
|
|
return xerrors.Errorf("rename tmp db: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type DBContainerOptions struct {
|
|
Port int
|
|
Name string
|
|
}
|
|
|
|
type container struct {
|
|
Resource *dockertest.Resource
|
|
Pool *dockertest.Pool
|
|
Host string
|
|
Port string
|
|
}
|
|
|
|
// OpenContainer creates a new PostgreSQL server using a Docker container. If port is nonzero, forward host traffic
|
|
// to that port to the database. If port is zero, allocate a free port from the OS.
|
|
// If name is set, we'll ensure that only one container is started with that name. If it's already running, we'll use that.
|
|
// Otherwise, we'll start a new container.
|
|
func openContainer(t TBSubset, opts DBContainerOptions) (container, func(), error) {
|
|
if opts.Name != "" {
|
|
// We only want to start the container once per unique name,
|
|
// so we take an inter-process lock to avoid concurrent test runs
|
|
// racing with us.
|
|
nameHash := sha256.Sum256([]byte(opts.Name))
|
|
nameHashStr := hex.EncodeToString(nameHash[:])
|
|
lock := flock.New(filepath.Join(os.TempDir(), "coder-postgres-container-"+nameHashStr[:8]))
|
|
if err := lock.Lock(); err != nil {
|
|
return container{}, nil, xerrors.Errorf("lock: %w", err)
|
|
}
|
|
defer func() {
|
|
err := lock.Unlock()
|
|
if err != nil {
|
|
t.Logf("create database from template: failed to unlock: %s\n", err.Error())
|
|
}
|
|
}()
|
|
}
|
|
|
|
pool, err := dockertest.NewPool("")
|
|
if err != nil {
|
|
return container{}, nil, xerrors.Errorf("create pool: %w", err)
|
|
}
|
|
|
|
var resource *dockertest.Resource
|
|
var tempDir string
|
|
if opts.Name != "" {
|
|
// If the container already exists, we'll use it.
|
|
resource, _ = pool.ContainerByName(opts.Name)
|
|
}
|
|
if resource == nil {
|
|
tempDir, err = os.MkdirTemp(os.TempDir(), "postgres")
|
|
if err != nil {
|
|
return container{}, nil, xerrors.Errorf("create tempdir: %w", err)
|
|
}
|
|
runOptions := dockertest.RunOptions{
|
|
Repository: postgresImage,
|
|
Tag: strconv.Itoa(minimumPostgreSQLVersion),
|
|
Env: []string{
|
|
"POSTGRES_PASSWORD=postgres",
|
|
"POSTGRES_USER=postgres",
|
|
"POSTGRES_DB=postgres",
|
|
// The location for temporary database files!
|
|
"PGDATA=/tmp",
|
|
"listen_addresses = '*'",
|
|
},
|
|
PortBindings: map[docker.Port][]docker.PortBinding{
|
|
"5432/tcp": {{
|
|
// Manually specifying a host IP tells Docker just to use an IPV4 address.
|
|
// If we don't do this, we hit a fun bug:
|
|
// https://github.com/moby/moby/issues/42442
|
|
// where the ipv4 and ipv6 ports might be _different_ and collide with other running docker containers.
|
|
HostIP: "0.0.0.0",
|
|
HostPort: strconv.FormatInt(int64(opts.Port), 10),
|
|
}},
|
|
},
|
|
Mounts: []string{
|
|
// The postgres image has a VOLUME parameter in it's image.
|
|
// If we don't mount at this point, Docker will allocate a
|
|
// volume for this directory.
|
|
//
|
|
// This isn't used anyways, since we override PGDATA.
|
|
fmt.Sprintf("%s:/var/lib/postgresql/data", tempDir),
|
|
},
|
|
Cmd: []string{"-c", "max_connections=1000"},
|
|
}
|
|
if opts.Name != "" {
|
|
runOptions.Name = opts.Name
|
|
}
|
|
resource, err = pool.RunWithOptions(&runOptions, func(config *docker.HostConfig) {
|
|
// set AutoRemove to true so that stopped container goes away by itself
|
|
config.AutoRemove = true
|
|
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
|
config.Tmpfs = map[string]string{
|
|
"/tmp": "rw",
|
|
}
|
|
})
|
|
if err != nil {
|
|
return container{}, nil, xerrors.Errorf("could not start resource: %w", err)
|
|
}
|
|
}
|
|
|
|
hostAndPort := resource.GetHostPort("5432/tcp")
|
|
host, port, err := net.SplitHostPort(hostAndPort)
|
|
if err != nil {
|
|
return container{}, nil, xerrors.Errorf("split host and port: %w", err)
|
|
}
|
|
|
|
for r := retry.New(50*time.Millisecond, 15*time.Second); r.Wait(context.Background()); {
|
|
stdout := &strings.Builder{}
|
|
stderr := &strings.Builder{}
|
|
_, err = resource.Exec([]string{"pg_isready", "-h", "127.0.0.1"}, dockertest.ExecOptions{
|
|
StdOut: stdout,
|
|
StdErr: stderr,
|
|
})
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
return container{}, nil, xerrors.Errorf("pg_isready: %w", err)
|
|
}
|
|
|
|
return container{
|
|
Host: host,
|
|
Port: port,
|
|
Resource: resource,
|
|
Pool: pool,
|
|
}, func() {
|
|
_ = pool.Purge(resource)
|
|
if tempDir != "" {
|
|
_ = os.RemoveAll(tempDir)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// OpenContainerized creates a new PostgreSQL server using a Docker container. If port is nonzero, forward host traffic
|
|
// to that port to the database. If port is zero, allocate a free port from the OS.
|
|
// The user is responsible for calling the returned cleanup function.
|
|
func OpenContainerized(t TBSubset, opts DBContainerOptions) (string, func(), error) {
|
|
container, containerCleanup, err := openContainer(t, opts)
|
|
if err != nil {
|
|
return "", nil, xerrors.Errorf("open container: %w", err)
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
containerCleanup()
|
|
}
|
|
}()
|
|
dbURL := ConnectionParams{
|
|
Username: "postgres",
|
|
Password: "postgres",
|
|
Host: container.Host,
|
|
Port: container.Port,
|
|
DBName: "postgres",
|
|
}.DSN()
|
|
|
|
// Docker should hard-kill the container after 120 seconds.
|
|
err = container.Resource.Expire(120)
|
|
if err != nil {
|
|
return "", nil, xerrors.Errorf("expire resource: %w", err)
|
|
}
|
|
|
|
container.Pool.MaxWait = 120 * time.Second
|
|
|
|
// Record the error that occurs during the retry.
|
|
// The 'pool' pkg hardcodes a deadline error devoid
|
|
// of any useful context.
|
|
var retryErr error
|
|
err = container.Pool.Retry(func() error {
|
|
db, err := sql.Open("postgres", dbURL)
|
|
if err != nil {
|
|
retryErr = xerrors.Errorf("open postgres: %w", err)
|
|
return retryErr
|
|
}
|
|
defer db.Close()
|
|
|
|
err = db.Ping()
|
|
if err != nil {
|
|
retryErr = xerrors.Errorf("ping postgres: %w", err)
|
|
return retryErr
|
|
}
|
|
|
|
err = migrations.Up(db)
|
|
if err != nil {
|
|
retryErr = xerrors.Errorf("migrate db: %w", err)
|
|
// Only try to migrate once.
|
|
return backoff.Permanent(retryErr)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", nil, retryErr
|
|
}
|
|
|
|
return dbURL, containerCleanup, nil
|
|
}
|
|
|
|
func containsAnySubstring(s string, substrings []string) bool {
|
|
for _, substr := range substrings {
|
|
if strings.Contains(s, substr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|