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"
|
||||
}
|
||||
Reference in New Issue
Block a user