Files
pawsql/internal/postgres/provisioner.go
Shaun Campbell 865f7c26c9
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
feat: add PawSQL docs examples and image CI
2026-09-15 18:49:26 -04:00

218 lines
6.8 KiB
Go

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