feat: runtime manager
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user