feat: add PawSQL docs examples and image CI
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
This commit is contained in:
217
internal/postgres/provisioner.go
Normal file
217
internal/postgres/provisioner.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Package postgres provisions PawSQL-managed PostgreSQL containers through the Docker CLI.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/barkstack/pawsql/internal/config"
|
||||
)
|
||||
|
||||
const managedDatabaseLabel = "io.barkstack.pawsql.database"
|
||||
|
||||
// Provisioner ensures configured PostgreSQL containers exist and exposes their
|
||||
// loopback-published PostgreSQL address to PawSQL's static router.
|
||||
type Provisioner struct {
|
||||
DockerPath string
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewProvisioner creates a Docker CLI-backed provisioner.
|
||||
func NewProvisioner(logger *slog.Logger) *Provisioner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Provisioner{DockerPath: "docker", Logger: logger}
|
||||
}
|
||||
|
||||
// Ensure provisions each PostgreSQL-backed database and returns configuration
|
||||
// with its discovered upstream addresses. Existing containers and volumes are
|
||||
// adopted only when they carry PawSQL's matching ownership label.
|
||||
func (p *Provisioner) Ensure(ctx context.Context, cfg config.Config) (config.Config, error) {
|
||||
resolved := cfg
|
||||
resolved.Databases = append([]config.DatabaseConfig(nil), cfg.Databases...)
|
||||
for index := range resolved.Databases {
|
||||
database := &resolved.Databases[index]
|
||||
if database.Postgres == nil {
|
||||
continue
|
||||
}
|
||||
address, err := p.EnsureDatabase(ctx, database.Name, *database.Postgres)
|
||||
if err != nil {
|
||||
return config.Config{}, fmt.Errorf("provision database %q: %w", database.Name, err)
|
||||
}
|
||||
database.Upstream = address
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// EnsureDatabase creates or starts one managed PostgreSQL database and waits
|
||||
// until its loopback-published address accepts connections.
|
||||
func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postgres config.PostgresConfig) (string, error) {
|
||||
name := containerName(database)
|
||||
if _, err := p.run(ctx, nil, "volume", "create", postgres.Volume); err != nil {
|
||||
return "", fmt.Errorf("create volume %q: %w", postgres.Volume, err)
|
||||
}
|
||||
|
||||
label, exists, err := p.containerLabel(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
password, ok := os.LookupEnv(postgres.PasswordEnv)
|
||||
if !ok || password == "" {
|
||||
return "", fmt.Errorf("environment variable %q is required to create the container", postgres.PasswordEnv)
|
||||
}
|
||||
p.Logger.Info("creating PostgreSQL container", "database", database, "container", name, "image", postgres.Image, "volume", postgres.Volume)
|
||||
mount := postgresDataMount(postgres.Image)
|
||||
_, err := p.run(ctx, []string{"POSTGRES_PASSWORD=" + password},
|
||||
"container", "create",
|
||||
"--name", name,
|
||||
"--label", managedDatabaseLabel+"="+database,
|
||||
"--env", "POSTGRES_DB="+database,
|
||||
"--env", "POSTGRES_USER="+database,
|
||||
"--env", "POSTGRES_PASSWORD",
|
||||
"--volume", postgres.Volume+":"+mount,
|
||||
"--publish", "127.0.0.1::5432",
|
||||
postgres.Image,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create container %q: %w", name, err)
|
||||
}
|
||||
} else if label != database {
|
||||
return "", fmt.Errorf("container %q belongs to %q, not PawSQL database %q", name, label, database)
|
||||
}
|
||||
|
||||
running, err := p.containerRunning(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !running {
|
||||
p.Logger.Info("starting PostgreSQL container", "database", database, "container", name)
|
||||
if _, err := p.run(ctx, nil, "container", "start", name); err != nil {
|
||||
return "", fmt.Errorf("start container %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
port, err := p.hostPort(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
address := net.JoinHostPort("127.0.0.1", port)
|
||||
if err := waitForPostgres(ctx, address); err != nil {
|
||||
return "", fmt.Errorf("wait for PostgreSQL container %q: %w", name, err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// StopDatabase stops a managed PostgreSQL container without removing its data volume.
|
||||
func (p *Provisioner) StopDatabase(ctx context.Context, database string) error {
|
||||
name := containerName(database)
|
||||
label, exists, err := p.containerLabel(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
if label != database {
|
||||
return fmt.Errorf("container %q belongs to %q, not PawSQL database %q", name, label, database)
|
||||
}
|
||||
running, err := p.containerRunning(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
p.Logger.Info("stopping idle PostgreSQL container", "database", database, "container", name)
|
||||
if _, err := p.run(ctx, nil, "container", "stop", name); err != nil {
|
||||
return fmt.Errorf("stop container %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPostgres(ctx context.Context, address string) error {
|
||||
deadline := time.NewTimer(30 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
connection, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "tcp", address)
|
||||
if err == nil {
|
||||
return connection.Close()
|
||||
}
|
||||
retry := time.NewTimer(200 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
retry.Stop()
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
retry.Stop()
|
||||
return err
|
||||
case <-retry.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provisioner) containerLabel(ctx context.Context, name string) (label string, exists bool, err error) {
|
||||
output, err := p.run(ctx, nil, "container", "inspect", "--format", "{{ index .Config.Labels \""+managedDatabaseLabel+"\" }}", name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No such container") {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("inspect container %q: %w", name, err)
|
||||
}
|
||||
return strings.TrimSpace(output), true, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) containerRunning(ctx context.Context, name string) (bool, error) {
|
||||
output, err := p.run(ctx, nil, "container", "inspect", "--format", "{{.State.Running}}", name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect container state %q: %w", name, err)
|
||||
}
|
||||
return strings.TrimSpace(output) == "true", nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) hostPort(ctx context.Context, name string) (string, error) {
|
||||
output, err := p.run(ctx, nil, "container", "inspect", "--format", "{{(index (index .NetworkSettings.Ports \"5432/tcp\") 0).HostPort}}", name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect PostgreSQL port %q: %w", name, err)
|
||||
}
|
||||
port := strings.TrimSpace(output)
|
||||
if port == "" {
|
||||
return "", fmt.Errorf("container %q has no published PostgreSQL port", name)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) run(ctx context.Context, environment []string, args ...string) (string, error) {
|
||||
path := p.DockerPath
|
||||
if path == "" {
|
||||
path = "docker"
|
||||
}
|
||||
command := exec.CommandContext(ctx, path, args...)
|
||||
command.Env = append(os.Environ(), environment...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
if err := command.Run(); err != nil {
|
||||
return "", fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func containerName(database string) string {
|
||||
return "pawsql-" + database
|
||||
}
|
||||
|
||||
func postgresDataMount(image string) string {
|
||||
if image == "postgres:18" {
|
||||
return "/var/lib/postgresql"
|
||||
}
|
||||
return "/var/lib/postgresql/data"
|
||||
}
|
||||
65
internal/postgres/provisioner_integration_test.go
Normal file
65
internal/postgres/provisioner_integration_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
//go:build integration
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/barkstack/pawsql/internal/config"
|
||||
)
|
||||
|
||||
func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {
|
||||
const (
|
||||
database = "pawsql_integration_test"
|
||||
volume = "pawsql-integration-test-data"
|
||||
envName = "PAWSQL_INTEGRATION_POSTGRES_PASSWORD"
|
||||
)
|
||||
t.Setenv(envName, "integration-test-password")
|
||||
provisioner := NewProvisioner(nil)
|
||||
defer func() {
|
||||
_, _ = provisioner.run(context.Background(), nil, "container", "rm", "--force", containerName(database))
|
||||
_, _ = provisioner.run(context.Background(), nil, "volume", "rm", "--force", volume)
|
||||
}()
|
||||
|
||||
cfg := config.Config{Databases: []config.DatabaseConfig{{
|
||||
Name: database,
|
||||
Postgres: &config.PostgresConfig{
|
||||
Image: "postgres:18",
|
||||
Volume: volume,
|
||||
PasswordEnv: envName,
|
||||
},
|
||||
}}}
|
||||
resolved, err := provisioner.Ensure(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := resolved.Databases[0].Upstream
|
||||
waitForAddress(t, address)
|
||||
if err := provisioner.StopDatabase(context.Background(), database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err = provisioner.Ensure(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForAddress(t, resolved.Databases[0].Upstream)
|
||||
}
|
||||
|
||||
func waitForAddress(t *testing.T, address string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for {
|
||||
connection, err := net.DialTimeout("tcp", address, time.Second)
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("PostgreSQL at %s did not accept connections: %v", address, err)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
19
internal/postgres/provisioner_test.go
Normal file
19
internal/postgres/provisioner_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package postgres
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPostgresDataMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
image string
|
||||
want string
|
||||
}{
|
||||
{"postgres:16", "/var/lib/postgresql/data"},
|
||||
{"postgres:17", "/var/lib/postgresql/data"},
|
||||
{"postgres:18", "/var/lib/postgresql"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := postgresDataMount(test.image); got != test.want {
|
||||
t.Errorf("postgresDataMount(%q) = %q, want %q", test.image, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
253
internal/postgres/resolver.go
Normal file
253
internal/postgres/resolver.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/barkstack/pawsql/internal/config"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
)
|
||||
|
||||
// DatabaseController starts and stops managed PostgreSQL databases.
|
||||
type DatabaseController interface {
|
||||
EnsureDatabase(context.Context, string, config.PostgresConfig) (string, error)
|
||||
StopDatabase(context.Context, string) error
|
||||
}
|
||||
|
||||
// Resolver lazily starts managed PostgreSQL containers when a route is selected.
|
||||
// Concurrent connections to one database share a single start or stop operation.
|
||||
type Resolver struct {
|
||||
routes router.BackendResolver
|
||||
controller DatabaseController
|
||||
managed map[string]config.PostgresConfig
|
||||
|
||||
locksMu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
|
||||
statesMu sync.Mutex
|
||||
states map[string]*leaseState
|
||||
}
|
||||
|
||||
type leaseState struct {
|
||||
active int
|
||||
idleGeneration uint64
|
||||
idleTimer *time.Timer
|
||||
trafficGeneration uint64
|
||||
trafficTimer *time.Timer
|
||||
lastActivity time.Time
|
||||
clientBytes uint64
|
||||
backendBytes uint64
|
||||
}
|
||||
|
||||
// TrafficStats is a point-in-time aggregate for one managed database.
|
||||
type TrafficStats struct {
|
||||
ActiveConnections int
|
||||
ClientBytes uint64
|
||||
BackendBytes uint64
|
||||
LastActivity time.Time
|
||||
}
|
||||
|
||||
// NewResolver wraps static route matching with lazy PostgreSQL provisioning.
|
||||
func NewResolver(routes router.BackendResolver, databases []config.DatabaseConfig, controller DatabaseController) *Resolver {
|
||||
managed := make(map[string]config.PostgresConfig)
|
||||
for _, database := range databases {
|
||||
if database.Postgres != nil {
|
||||
managed[database.Name] = *database.Postgres
|
||||
}
|
||||
}
|
||||
return &Resolver{
|
||||
routes: routes, controller: controller, managed: managed,
|
||||
locks: make(map[string]*sync.Mutex), states: make(map[string]*leaseState),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve selects a hostname route and starts its PostgreSQL container if needed.
|
||||
func (r *Resolver) Resolve(ctx context.Context, hostname string) (router.Backend, error) {
|
||||
backend, err := r.routes.Resolve(ctx, hostname)
|
||||
if err != nil {
|
||||
return router.Backend{}, err
|
||||
}
|
||||
return r.ensure(ctx, backend)
|
||||
}
|
||||
|
||||
// ResolveDatabase selects a database route and starts its PostgreSQL container if needed.
|
||||
func (r *Resolver) ResolveDatabase(ctx context.Context, database string) (router.Backend, error) {
|
||||
backend, err := r.routes.ResolveDatabase(ctx, database)
|
||||
if err != nil {
|
||||
return router.Backend{}, err
|
||||
}
|
||||
return r.ensure(ctx, backend)
|
||||
}
|
||||
|
||||
func (r *Resolver) ensure(ctx context.Context, backend router.Backend) (router.Backend, error) {
|
||||
postgres, managed := r.managed[backend.DatabaseName]
|
||||
if !managed {
|
||||
return backend, nil
|
||||
}
|
||||
lock := r.lockFor(backend.DatabaseName)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
address, err := r.controller.EnsureDatabase(ctx, backend.DatabaseName, postgres)
|
||||
if err != nil {
|
||||
return router.Backend{}, err
|
||||
}
|
||||
r.acquire(backend.DatabaseName, postgres)
|
||||
backend.Address = address
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) lockFor(database string) *sync.Mutex {
|
||||
r.locksMu.Lock()
|
||||
defer r.locksMu.Unlock()
|
||||
lock := r.locks[database]
|
||||
if lock == nil {
|
||||
lock = &sync.Mutex{}
|
||||
r.locks[database] = lock
|
||||
}
|
||||
return lock
|
||||
}
|
||||
|
||||
// ReleaseConnection records a proxied managed-database session ending. Once the
|
||||
// final session closes, an idle_timeout countdown begins.
|
||||
func (r *Resolver) ReleaseConnection(database string) {
|
||||
postgres, managed := r.managed[database]
|
||||
if !managed {
|
||||
return
|
||||
}
|
||||
r.statesMu.Lock()
|
||||
state := r.states[database]
|
||||
if state == nil || state.active == 0 {
|
||||
r.statesMu.Unlock()
|
||||
return
|
||||
}
|
||||
state.active--
|
||||
if state.active == 0 {
|
||||
state.trafficGeneration++
|
||||
if state.trafficTimer != nil {
|
||||
state.trafficTimer.Stop()
|
||||
state.trafficTimer = nil
|
||||
}
|
||||
if postgres.IdleTimeout > 0 {
|
||||
state.idleGeneration++
|
||||
generation := state.idleGeneration
|
||||
state.idleTimer = time.AfterFunc(postgres.IdleTimeout, func() {
|
||||
r.stopAfterConnectionIdle(database, generation)
|
||||
})
|
||||
}
|
||||
}
|
||||
r.statesMu.Unlock()
|
||||
}
|
||||
|
||||
// RecordTraffic meters proxied bytes and resets the traffic-idle countdown.
|
||||
func (r *Resolver) RecordTraffic(database string, clientToBackend bool, bytes int64) {
|
||||
postgres, managed := r.managed[database]
|
||||
if !managed || bytes <= 0 {
|
||||
return
|
||||
}
|
||||
r.statesMu.Lock()
|
||||
state := r.stateFor(database)
|
||||
if clientToBackend {
|
||||
state.clientBytes += uint64(bytes)
|
||||
} else {
|
||||
state.backendBytes += uint64(bytes)
|
||||
}
|
||||
state.lastActivity = time.Now()
|
||||
if state.active > 0 && postgres.TrafficIdleTimeout > 0 {
|
||||
r.scheduleTrafficIdleLocked(database, state, postgres.TrafficIdleTimeout)
|
||||
}
|
||||
r.statesMu.Unlock()
|
||||
}
|
||||
|
||||
// TrafficStats reports aggregate traffic collected since PawSQL started.
|
||||
func (r *Resolver) TrafficStats(database string) (TrafficStats, bool) {
|
||||
if _, managed := r.managed[database]; !managed {
|
||||
return TrafficStats{}, false
|
||||
}
|
||||
r.statesMu.Lock()
|
||||
defer r.statesMu.Unlock()
|
||||
state := r.states[database]
|
||||
if state == nil {
|
||||
return TrafficStats{}, true
|
||||
}
|
||||
return TrafficStats{
|
||||
ActiveConnections: state.active,
|
||||
ClientBytes: state.clientBytes,
|
||||
BackendBytes: state.backendBytes,
|
||||
LastActivity: state.lastActivity,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (r *Resolver) acquire(database string, postgres config.PostgresConfig) {
|
||||
r.statesMu.Lock()
|
||||
defer r.statesMu.Unlock()
|
||||
state := r.stateFor(database)
|
||||
state.active++
|
||||
state.lastActivity = time.Now()
|
||||
state.idleGeneration++
|
||||
if state.idleTimer != nil {
|
||||
state.idleTimer.Stop()
|
||||
state.idleTimer = nil
|
||||
}
|
||||
if postgres.TrafficIdleTimeout > 0 {
|
||||
r.scheduleTrafficIdleLocked(database, state, postgres.TrafficIdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) stateFor(database string) *leaseState {
|
||||
state := r.states[database]
|
||||
if state == nil {
|
||||
state = &leaseState{}
|
||||
r.states[database] = state
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (r *Resolver) scheduleTrafficIdleLocked(database string, state *leaseState, timeout time.Duration) {
|
||||
state.trafficGeneration++
|
||||
generation := state.trafficGeneration
|
||||
if state.trafficTimer != nil {
|
||||
state.trafficTimer.Stop()
|
||||
}
|
||||
state.trafficTimer = time.AfterFunc(timeout, func() {
|
||||
r.stopAfterTrafficIdle(database, generation)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Resolver) stopAfterConnectionIdle(database string, generation uint64) {
|
||||
r.stopIfIdle(database, generation, false)
|
||||
}
|
||||
|
||||
func (r *Resolver) stopAfterTrafficIdle(database string, generation uint64) {
|
||||
r.stopIfIdle(database, generation, true)
|
||||
}
|
||||
|
||||
func (r *Resolver) stopIfIdle(database string, generation uint64, traffic bool) {
|
||||
lock := r.lockFor(database)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
r.statesMu.Lock()
|
||||
state := r.states[database]
|
||||
if state == nil {
|
||||
r.statesMu.Unlock()
|
||||
return
|
||||
}
|
||||
if traffic {
|
||||
if state.active == 0 || state.trafficGeneration != generation {
|
||||
r.statesMu.Unlock()
|
||||
return
|
||||
}
|
||||
state.trafficTimer = nil
|
||||
} else {
|
||||
if state.active != 0 || state.idleGeneration != generation {
|
||||
r.statesMu.Unlock()
|
||||
return
|
||||
}
|
||||
state.idleTimer = nil
|
||||
}
|
||||
r.statesMu.Unlock()
|
||||
if err := r.controller.StopDatabase(context.Background(), database); err != nil {
|
||||
slog.Error("stop idle PostgreSQL container", "database", database, "traffic_idle", traffic, "error", err)
|
||||
}
|
||||
}
|
||||
198
internal/postgres/resolver_test.go
Normal file
198
internal/postgres/resolver_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/barkstack/pawsql/internal/config"
|
||||
"github.com/barkstack/pawsql/internal/router"
|
||||
)
|
||||
|
||||
func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{
|
||||
{Name: "external", Upstream: "192.0.2.1:5432"},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{
|
||||
{Name: "external", Upstream: "192.0.2.1:5432"},
|
||||
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}},
|
||||
}, ensurer)
|
||||
|
||||
backend, err := resolver.ResolveDatabase(context.Background(), "external")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if backend.Address != "192.0.2.1:5432" || ensurer.calls != 0 {
|
||||
t.Errorf("external backend = %#v, calls = %d", backend, ensurer.calls)
|
||||
}
|
||||
|
||||
backend, err = resolver.ResolveDatabase(context.Background(), "managed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if backend.Address != "127.0.0.1:55432" || ensurer.calls != 1 {
|
||||
t.Errorf("managed backend = %#v, calls = %d", backend, ensurer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSerializesManagedStartup(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, gate: make(chan struct{}), started: make(chan struct{})}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}}, ensurer)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = resolver.ResolveDatabase(context.Background(), "managed")
|
||||
}()
|
||||
}
|
||||
<-ensurer.started
|
||||
close(ensurer.gate)
|
||||
wg.Wait()
|
||||
if ensurer.calls != 2 {
|
||||
t.Errorf("EnsureDatabase calls = %d, want 2 route resolutions", ensurer.calls)
|
||||
}
|
||||
if ensurer.maxActive != 1 {
|
||||
t.Errorf("concurrent EnsureDatabase calls = %d, want 1", ensurer.maxActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}}, controller)
|
||||
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver.ReleaseConnection("managed")
|
||||
select {
|
||||
case database := <-controller.stopped:
|
||||
if database != "managed" {
|
||||
t.Errorf("stopped database = %q", database)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("managed database was not stopped after idle timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverCancelsIdleStopForNewConnection(t *testing.T) {
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}}, controller)
|
||||
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver.ReleaseConnection("managed")
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case database := <-controller.stopped:
|
||||
t.Fatalf("stopped active database %q", database)
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
}
|
||||
resolver.ReleaseConnection("managed")
|
||||
select {
|
||||
case <-controller.stopped:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("managed database was not stopped after final lease release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverMetersTrafficAndStopsTrafficIdleSession(t *testing.T) {
|
||||
postgres := &config.PostgresConfig{
|
||||
Image: "postgres:18",
|
||||
Volume: "managed-data",
|
||||
PasswordEnv: "MANAGED_PASSWORD",
|
||||
TrafficIdleTimeout: 100 * time.Millisecond,
|
||||
}
|
||||
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: postgres}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)}
|
||||
resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: postgres}}, controller)
|
||||
|
||||
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(70 * time.Millisecond)
|
||||
resolver.RecordTraffic("managed", true, 7)
|
||||
resolver.RecordTraffic("managed", false, 11)
|
||||
stats, ok := resolver.TrafficStats("managed")
|
||||
if !ok || stats.ActiveConnections != 1 || stats.ClientBytes != 7 || stats.BackendBytes != 11 || stats.LastActivity.IsZero() {
|
||||
t.Errorf("TrafficStats() = %#v, %t", stats, ok)
|
||||
}
|
||||
select {
|
||||
case database := <-controller.stopped:
|
||||
t.Fatalf("stopped database %q despite recent traffic", database)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
select {
|
||||
case database := <-controller.stopped:
|
||||
if database != "managed" {
|
||||
t.Errorf("stopped database = %q", database)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("managed database was not stopped after traffic idle timeout")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEnsurer struct {
|
||||
mu sync.Mutex
|
||||
addresses map[string]string
|
||||
calls int
|
||||
active int
|
||||
maxActive int
|
||||
gate chan struct{}
|
||||
started chan struct{}
|
||||
startedOnce sync.Once
|
||||
stopped chan string
|
||||
}
|
||||
|
||||
func (f *fakeEnsurer) EnsureDatabase(_ context.Context, database string, _ config.PostgresConfig) (string, error) {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.active++
|
||||
if f.active > f.maxActive {
|
||||
f.maxActive = f.active
|
||||
}
|
||||
if f.started != nil {
|
||||
f.startedOnce.Do(func() { close(f.started) })
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.active--
|
||||
address := f.addresses[database]
|
||||
f.mu.Unlock()
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnsurer) StopDatabase(_ context.Context, database string) error {
|
||||
if f.stopped != nil {
|
||||
f.stopped <- database
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user