Files
golib/pg/pgtest/pgtest.go
T
unkin-agent d59dcbe74e
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the golib scaffold and the pg module
Stand up the shared library with its first module: the Postgres plumbing
every service currently copy-pastes — the DSN builder, the pool
constructor, and the migration runner arrproxy proved out.

- Add pg.DSNFromEnv, generalising the identical Sprintf builders in
  encapi, artifactapi and forgebot into one prefixed lookup with
  DATABASE_URL passthrough and libpq fallbacks.
- Add pg.New and pg.NewMigrated, which ping before returning so an
  unreachable server fails at startup rather than on the first query.
- Add pg.Migrate, lifting arrproxy's runner verbatim in semantics and
  generalising the hardcoded advisory-lock key to FNV-1a/64 of a
  caller-supplied name and the embedded set to an fs.FS.
- Add pg/pgtest, unifying the encapi and artifactapi testcontainers
  helpers, with SkipIfShort so container-backed tests self-skip on the
  Docker-less Kubernetes runners.
- Add the Makefile, README and pre-commit config, plus test, pre-commit
  and build pipelines on golib-ci.
2026-08-31 22:19:40 +10:00

101 lines
3.2 KiB
Go

// Package pgtest starts a throwaway Postgres container for integration-style
// tests. Import it only from _test.go files so testcontainers never reaches a
// production binary.
//
// The estate's CI runs on Kubernetes with no Docker socket, so tests that need
// a container must skip themselves under -short. SkipIfShort does exactly that,
// and StartPostgres reports a plain error when no container runtime is
// reachable, leaving it to the caller whether that is a skip or a failure.
package pgtest
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
// Image is the Postgres the estate targets; every service runs the same major.
const Image = "postgres:17-alpine"
const (
database = "pgtest"
username = "pgtest"
password = "pgtest123"
)
// startTimeout bounds the container's readiness wait.
const startTimeout = 60 * time.Second
func init() {
// The Ryuk reaper container cannot start in every environment (rootless
// podman, restricted CI). Callers get an explicit terminate func instead,
// so disable Ryuk unless the environment has deliberately enabled it.
if _, ok := os.LookupEnv("TESTCONTAINERS_RYUK_DISABLED"); !ok {
_ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
}
}
// SkipIfShort skips the test under -short. Container-backed tests call it
// first, which is what keeps `go test -short ./...` green with no Docker.
func SkipIfShort(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("skipping container-backed test in short mode")
}
}
// StartPostgres launches a Postgres container and returns its DSN plus a
// terminate func the caller must run, even on failure paths.
func StartPostgres(ctx context.Context) (dsn string, terminate func(), err error) {
c, err := tcpostgres.Run(ctx,
Image,
tcpostgres.WithDatabase(database),
tcpostgres.WithUsername(username),
tcpostgres.WithPassword(password),
testcontainers.WithWaitStrategy(
// Postgres opens the port, runs its init scripts, then restarts, so
// wait for the readiness log twice to avoid connection resets.
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(startTimeout),
),
)
if err != nil {
return "", nil, fmt.Errorf("start postgres container: %w", err)
}
terminate = func() { _ = c.Terminate(ctx) }
host, err := c.Host(ctx)
if err != nil {
terminate()
return "", nil, fmt.Errorf("container host: %w", err)
}
port, err := c.MappedPort(ctx, "5432/tcp")
if err != nil {
terminate()
return "", nil, fmt.Errorf("container port: %w", err)
}
dsn = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
username, password, host, port.Port(), database)
return dsn, terminate, nil
}
// MustStartPostgres is StartPostgres for a TestMain-less test: it skips under
// -short, fails the test if the container cannot start, and registers cleanup.
func MustStartPostgres(ctx context.Context, t *testing.T) string {
t.Helper()
SkipIfShort(t)
dsn, terminate, err := StartPostgres(ctx)
if err != nil {
t.Fatalf("start postgres: %v", err)
}
t.Cleanup(terminate)
return dsn
}