feat: add PawSQL docs examples and image CI
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s

This commit is contained in:
2026-09-15 18:49:26 -04:00
commit 865f7c26c9
25 changed files with 2811 additions and 0 deletions

View 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"
}

View 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)
}
}

View 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)
}
}
}

View 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)
}
}

View 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
}