225 lines
6.8 KiB
Go
225 lines
6.8 KiB
Go
// Package harness exposes the testcontainers / Docker / image-build
|
|
// scaffolding shared by the Runtime Manager service-local integration
|
|
// suite under [`galaxy/rtmanager/integration`](..).
|
|
//
|
|
// Only `_test.go` files (and the harness itself) reference this
|
|
// package; production code paths in `cmd/rtmanager` never import it.
|
|
// The package therefore stays out of the production binary's import
|
|
// graph, identical to the in-package `pgtest` and `integration/internal/harness`
|
|
// patterns it mirrors.
|
|
package harness
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/postgres"
|
|
"galaxy/rtmanager/internal/adapters/postgres/migrations"
|
|
|
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
const (
|
|
pgImage = "postgres:16-alpine"
|
|
pgSuperUser = "galaxy"
|
|
pgSuperPassword = "galaxy"
|
|
pgSuperDatabase = "galaxy_rtmanager_it"
|
|
pgServiceRole = "rtmanagerservice"
|
|
pgServicePassword = "rtmanagerservice"
|
|
pgServiceSchema = "rtmanager"
|
|
pgStartupTimeout = 90 * time.Second
|
|
|
|
// pgOperationTimeout bounds the per-statement deadline used by every
|
|
// pool the harness opens. Short enough to surface a runaway
|
|
// integration test promptly, long enough to absorb laptop-grade I/O.
|
|
pgOperationTimeout = 10 * time.Second
|
|
)
|
|
|
|
// PostgresEnv carries the per-package PostgreSQL fixture. The container
|
|
// is started lazily on the first EnsurePostgres call and torn down by
|
|
// ShutdownPostgres at TestMain exit.
|
|
type PostgresEnv struct {
|
|
container *tcpostgres.PostgresContainer
|
|
pool *sql.DB
|
|
scopedDSN string
|
|
}
|
|
|
|
// Pool returns the harness-owned `*sql.DB` scoped to the rtmanager
|
|
// schema. Tests use it to read durable state directly through the
|
|
// existing store adapters.
|
|
func (env *PostgresEnv) Pool() *sql.DB { return env.pool }
|
|
|
|
// DSN returns the rtmanager-role-scoped DSN suitable for
|
|
// `RTMANAGER_POSTGRES_PRIMARY_DSN`. Both this DSN and Pool address the
|
|
// same database; the pool is reused across tests, while the runtime
|
|
// under test opens its own pool through this DSN.
|
|
func (env *PostgresEnv) DSN() string { return env.scopedDSN }
|
|
|
|
var (
|
|
pgOnce sync.Once
|
|
pgEnv *PostgresEnv
|
|
pgErr error
|
|
)
|
|
|
|
// EnsurePostgres starts the per-package PostgreSQL container on first
|
|
// invocation and applies the embedded goose migrations. Subsequent
|
|
// invocations reuse the same container. When Docker is unavailable the
|
|
// helper calls `t.Skip` so the suite stays green on hosts without a
|
|
// daemon (mirrors the contract from `internal/adapters/postgres/internal/pgtest`).
|
|
func EnsurePostgres(t testing.TB) *PostgresEnv {
|
|
t.Helper()
|
|
pgOnce.Do(func() {
|
|
pgEnv, pgErr = startPostgres()
|
|
})
|
|
if pgErr != nil {
|
|
t.Skipf("rtmanager integration: postgres container start failed (Docker unavailable?): %v", pgErr)
|
|
}
|
|
return pgEnv
|
|
}
|
|
|
|
// TruncatePostgres wipes every Runtime Manager table inside the shared
|
|
// pool, leaving the schema and indexes intact. Tests call this from
|
|
// their setup so each scenario starts on an empty state.
|
|
func TruncatePostgres(t testing.TB) {
|
|
t.Helper()
|
|
env := EnsurePostgres(t)
|
|
const stmt = `TRUNCATE TABLE runtime_records, operation_log, health_snapshots RESTART IDENTITY CASCADE`
|
|
if _, err := env.pool.ExecContext(context.Background(), stmt); err != nil {
|
|
t.Fatalf("truncate rtmanager tables: %v", err)
|
|
}
|
|
}
|
|
|
|
// ShutdownPostgres terminates the shared container and closes the pool.
|
|
// `TestMain` invokes it after `m.Run` so the container is released even
|
|
// if individual tests panic.
|
|
func ShutdownPostgres() {
|
|
if pgEnv == nil {
|
|
return
|
|
}
|
|
if pgEnv.pool != nil {
|
|
_ = pgEnv.pool.Close()
|
|
}
|
|
if pgEnv.container != nil {
|
|
_ = testcontainers.TerminateContainer(pgEnv.container)
|
|
}
|
|
pgEnv = nil
|
|
}
|
|
|
|
// RunMain is a convenience helper for the integration package
|
|
// `TestMain`: it runs the suite, captures the exit code, tears every
|
|
// shared container down, and exits. Wiring it through one helper keeps
|
|
// `TestMain` to two lines and centralises ordering.
|
|
func RunMain(m *testing.M) {
|
|
code := m.Run()
|
|
ShutdownRedis()
|
|
ShutdownPostgres()
|
|
ShutdownDocker()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func startPostgres() (*PostgresEnv, error) {
|
|
ctx := context.Background()
|
|
container, err := tcpostgres.Run(ctx, pgImage,
|
|
tcpostgres.WithDatabase(pgSuperDatabase),
|
|
tcpostgres.WithUsername(pgSuperUser),
|
|
tcpostgres.WithPassword(pgSuperPassword),
|
|
testcontainers.WithWaitStrategy(
|
|
wait.ForLog("database system is ready to accept connections").
|
|
WithOccurrence(2).
|
|
WithStartupTimeout(pgStartupTimeout),
|
|
),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
scopedDSN, err := scopedDSNForRole(baseDSN)
|
|
if err != nil {
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
cfg := postgres.DefaultConfig()
|
|
cfg.PrimaryDSN = scopedDSN
|
|
cfg.OperationTimeout = pgOperationTimeout
|
|
pool, err := postgres.OpenPrimary(ctx, cfg)
|
|
if err != nil {
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
if err := postgres.Ping(ctx, pool, pgOperationTimeout); err != nil {
|
|
_ = pool.Close()
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
|
|
_ = pool.Close()
|
|
_ = testcontainers.TerminateContainer(container)
|
|
return nil, err
|
|
}
|
|
return &PostgresEnv{
|
|
container: container,
|
|
pool: pool,
|
|
scopedDSN: scopedDSN,
|
|
}, nil
|
|
}
|
|
|
|
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
|
|
cfg := postgres.DefaultConfig()
|
|
cfg.PrimaryDSN = baseDSN
|
|
cfg.OperationTimeout = pgOperationTimeout
|
|
db, err := postgres.OpenPrimary(ctx, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
statements := []string{
|
|
`DO $$ BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rtmanagerservice') THEN
|
|
CREATE ROLE rtmanagerservice LOGIN PASSWORD 'rtmanagerservice';
|
|
END IF;
|
|
END $$;`,
|
|
`CREATE SCHEMA IF NOT EXISTS rtmanager AUTHORIZATION rtmanagerservice;`,
|
|
`GRANT USAGE ON SCHEMA rtmanager TO rtmanagerservice;`,
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scopedDSNForRole(baseDSN string) (string, error) {
|
|
parsed, err := url.Parse(baseDSN)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
values := url.Values{}
|
|
values.Set("search_path", pgServiceSchema)
|
|
values.Set("sslmode", "disable")
|
|
scoped := url.URL{
|
|
Scheme: parsed.Scheme,
|
|
User: url.UserPassword(pgServiceRole, pgServicePassword),
|
|
Host: parsed.Host,
|
|
Path: parsed.Path,
|
|
RawQuery: values.Encode(),
|
|
}
|
|
return scoped.String(), nil
|
|
}
|