1 Commits

Author SHA1 Message Date
3a631f18f6 feat: read managed database passwords from secrets
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m12s
Test and Release PawSQL / test (push) Successful in 49s
Test and Release PawSQL / release (push) Successful in 9s
2026-09-16 15:04:37 -04:00
12 changed files with 120 additions and 38 deletions

View File

@@ -7,7 +7,7 @@ PawSQL is a TLS-terminating PostgreSQL router. It accepts PostgreSQL clients on
- Go 1.24 or later to build and run PawSQL natively. - Go 1.24 or later to build and run PawSQL natively.
- Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers. - Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers.
- Optional: a TLS certificate and private key readable by PawSQL. Omit the `tls` block to serve plaintext PostgreSQL; with TLS, the certificate must cover every hostname clients use for SNI routing. - Optional: a TLS certificate and private key readable by PawSQL. Omit the `tls` block to serve plaintext PostgreSQL; with TLS, the certificate must cover every hostname clients use for SNI routing.
- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`. - Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`; each route also requires a mounted Docker secret file.
## Build, configure, and run ## Build, configure, and run
@@ -38,6 +38,7 @@ docker run --rm --publish 5432:5432 \
--volume "$PWD/Barkfile:/etc/pawsql/Barkfile:ro" \ --volume "$PWD/Barkfile:/etc/pawsql/Barkfile:ro" \
--volume "$PWD/tls:/etc/pawsql/tls:ro" \ --volume "$PWD/tls:/etc/pawsql/tls:ro" \
--volume /var/run/docker.sock:/var/run/docker.sock \ --volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$PWD/secrets:/run/secrets:ro" \
pawsql pawsql
``` ```
@@ -66,7 +67,7 @@ pawsql {
postgres { postgres {
image postgres:17 image postgres:17
volume pawsql-application-data volume pawsql-application-data
password_env APPLICATION_POSTGRES_PASSWORD password_secret application_postgres_password
idle_timeout 10m idle_timeout 10m
traffic_idle_timeout 1h traffic_idle_timeout 1h
} }
@@ -76,7 +77,15 @@ pawsql {
`listen` is PawSQL's TCP address. `cert` and `key` identify the client-facing TLS certificate and key. `hostname` is optional; it is used only for SNI routing. `upstream` is the address of an existing PostgreSQL server. `listen` is PawSQL's TCP address. `cert` and `key` identify the client-facing TLS certificate and key. `hostname` is optional; it is used only for SNI routing. `upstream` is the address of an existing PostgreSQL server.
For a managed `postgres` route, `image`, `volume`, and `password_env` are required. On first use, PawSQL reads the named environment variable to create the database container and configures the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data. Set the password environment variable in PawSQL's environment, not in the Barkfile. For a managed `postgres` route, `image`, `volume`, and `password_secret` are required. `password_secret` is a short lowercase reference; PawSQL reads it from `/run/secrets/barkstack_<reference>`. The example therefore reads `/run/secrets/barkstack_application_postgres_password`. On first use, PawSQL uses that value to configure the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data.
Create the Docker Swarm secret before running `barkstack init`; Barkstack verifies that every referenced secret exists and mounts it into the PawSQL service:
```sh
docker secret create barkstack_application_postgres_password /secure/path/application-postgres-password
```
For a standalone PawSQL container, mount a directory containing the equivalently named file at `/run/secrets`, as shown above. Never put the password value in the Barkfile.
See [`examples/Barkfile`](examples/Barkfile) and its accompanying [`examples/docker-compose.yml`](examples/docker-compose.yml) for a two-route external PostgreSQL example with locally generated development certificates: See [`examples/Barkfile`](examples/Barkfile) and its accompanying [`examples/docker-compose.yml`](examples/docker-compose.yml) for a two-route external PostgreSQL example with locally generated development certificates:

View File

@@ -14,7 +14,7 @@ import (
"syscall" "syscall"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"github.com/barkstack/pawsql/internal/adminui" "github.com/barkstack/pawsql/internal/adminui"
"github.com/barkstack/pawsql/internal/postgres" "github.com/barkstack/pawsql/internal/postgres"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"

2
go.mod
View File

@@ -2,4 +2,4 @@ module github.com/barkstack/pawsql
go 1.24 go 1.24
require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 require cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0

4
go.sum
View File

@@ -1,2 +1,2 @@
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 h1:ULdpvY1VC1d5M8VHx8SqacVLmk5SgvDYABDuzewebjc= cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0 h1:oXVNd8wwR06OaBMDPCVr4zAWZw6L0VBCODkXaHeMI/I=
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0/go.mod h1:um5p/9XtFqZqhh087AlPB2nr4NxhrQxw5sxe32uMbzw=

View File

@@ -9,10 +9,11 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
) )
const managedDatabaseLabel = "io.barkstack.pawsql.database" const managedDatabaseLabel = "io.barkstack.pawsql.database"
@@ -20,8 +21,9 @@ const managedDatabaseLabel = "io.barkstack.pawsql.database"
// Provisioner ensures configured PostgreSQL containers exist and exposes their // Provisioner ensures configured PostgreSQL containers exist and exposes their
// loopback-published PostgreSQL address to PawSQL's static router. // loopback-published PostgreSQL address to PawSQL's static router.
type Provisioner struct { type Provisioner struct {
DockerPath string DockerPath string
Logger *slog.Logger SecretDirectory string
Logger *slog.Logger
} }
// NewProvisioner creates a Docker CLI-backed provisioner. // NewProvisioner creates a Docker CLI-backed provisioner.
@@ -29,7 +31,7 @@ func NewProvisioner(logger *slog.Logger) *Provisioner {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return &Provisioner{DockerPath: "docker", Logger: logger} return &Provisioner{DockerPath: "docker", SecretDirectory: "/run/secrets", Logger: logger}
} }
// Ensure provisions each PostgreSQL-backed database and returns configuration // Ensure provisions each PostgreSQL-backed database and returns configuration
@@ -65,13 +67,13 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg
return "", err return "", err
} }
if !exists { if !exists {
password, ok := os.LookupEnv(postgres.PasswordEnv) password, err := p.readSecret(postgres.PasswordSecret)
if !ok || password == "" { if err != nil {
return "", fmt.Errorf("environment variable %q is required to create the container", postgres.PasswordEnv) return "", err
} }
p.Logger.Info("creating PostgreSQL container", "database", database, "container", name, "image", postgres.Image, "volume", postgres.Volume) p.Logger.Info("creating PostgreSQL container", "database", database, "container", name, "image", postgres.Image, "volume", postgres.Volume)
mount := postgresDataMount(postgres.Image) mount := postgresDataMount(postgres.Image)
_, err := p.run(ctx, []string{"POSTGRES_PASSWORD=" + password}, _, err = p.run(ctx, []string{"POSTGRES_PASSWORD=" + password},
"container", "create", "container", "create",
"--name", name, "--name", name,
"--label", managedDatabaseLabel+"="+database, "--label", managedDatabaseLabel+"="+database,
@@ -110,6 +112,26 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg
return address, nil return address, nil
} }
func (p *Provisioner) readSecret(reference string) (string, error) {
if !config.ValidSecretReference(reference) {
return "", fmt.Errorf("invalid password secret reference %q", reference)
}
directory := p.SecretDirectory
if directory == "" {
directory = "/run/secrets"
}
name := config.DockerSecretName(reference)
contents, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
return "", fmt.Errorf("read Docker secret %q: %w", name, err)
}
password := strings.TrimRight(string(contents), "\r\n")
if password == "" {
return "", fmt.Errorf("Docker secret %q is empty", name)
}
return password, nil
}
// StopDatabase stops a managed PostgreSQL container without removing its data volume. // StopDatabase stops a managed PostgreSQL container without removing its data volume.
func (p *Provisioner) StopDatabase(ctx context.Context, database string) error { func (p *Provisioner) StopDatabase(ctx context.Context, database string) error {
name := containerName(database) name := containerName(database)

View File

@@ -5,20 +5,26 @@ package postgres
import ( import (
"context" "context"
"net" "net"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
) )
func TestProvisionerCreatesPersistentPostgres18(t *testing.T) { func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {
const ( const (
database = "pawsql_integration_test" database = "pawsql_integration_test"
volume = "pawsql-integration-test-data" volume = "pawsql-integration-test-data"
envName = "PAWSQL_INTEGRATION_POSTGRES_PASSWORD" secretRef = "integration_postgres_password"
) )
t.Setenv(envName, "integration-test-password") secretDirectory := t.TempDir()
if err := os.WriteFile(filepath.Join(secretDirectory, config.DockerSecretName(secretRef)), []byte("integration-test-password"), 0o600); err != nil {
t.Fatal(err)
}
provisioner := NewProvisioner(nil) provisioner := NewProvisioner(nil)
provisioner.SecretDirectory = secretDirectory
defer func() { defer func() {
_, _ = provisioner.run(context.Background(), nil, "container", "rm", "--force", containerName(database)) _, _ = provisioner.run(context.Background(), nil, "container", "rm", "--force", containerName(database))
_, _ = provisioner.run(context.Background(), nil, "volume", "rm", "--force", volume) _, _ = provisioner.run(context.Background(), nil, "volume", "rm", "--force", volume)
@@ -27,9 +33,9 @@ func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {
cfg := config.Config{Databases: []config.DatabaseConfig{{ cfg := config.Config{Databases: []config.DatabaseConfig{{
Name: database, Name: database,
Postgres: &config.PostgresConfig{ Postgres: &config.PostgresConfig{
Image: "postgres:18", Image: "postgres:18",
Volume: volume, Volume: volume,
PasswordEnv: envName, PasswordSecret: secretRef,
}, },
}}} }}}
resolved, err := provisioner.Ensure(context.Background(), cfg) resolved, err := provisioner.Ensure(context.Background(), cfg)

View File

@@ -1,6 +1,13 @@
package postgres package postgres
import "testing" import (
"os"
"path/filepath"
"strings"
"testing"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
)
func TestPostgresDataMount(t *testing.T) { func TestPostgresDataMount(t *testing.T) {
tests := []struct { tests := []struct {
@@ -17,3 +24,41 @@ func TestPostgresDataMount(t *testing.T) {
} }
} }
} }
func TestReadSecretUsesPrefixedDockerName(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, config.DockerSecretName("application_password"))
if err := os.WriteFile(path, []byte("correct horse battery staple\n"), 0o600); err != nil {
t.Fatal(err)
}
provisioner := &Provisioner{SecretDirectory: directory}
password, err := provisioner.readSecret("application_password")
if err != nil {
t.Fatal(err)
}
if password != "correct horse battery staple" {
t.Fatalf("password = %q", password)
}
}
func TestReadSecretRejectsUnsafeReference(t *testing.T) {
provisioner := &Provisioner{SecretDirectory: t.TempDir()}
_, err := provisioner.readSecret("../../etc/passwd")
if err == nil || !strings.Contains(err.Error(), "invalid password secret reference") {
t.Fatalf("readSecret() error = %v", err)
}
}
func TestReadSecretRejectsMissingAndEmptySecret(t *testing.T) {
directory := t.TempDir()
provisioner := &Provisioner{SecretDirectory: directory}
if _, err := provisioner.readSecret("missing"); err == nil || !strings.Contains(err.Error(), "barkstack_missing") {
t.Fatalf("missing secret error = %v", err)
}
if err := os.WriteFile(filepath.Join(directory, config.DockerSecretName("empty")), []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := provisioner.readSecret("empty"); err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("empty secret error = %v", err)
}
}

View File

@@ -6,7 +6,7 @@ import (
"sync" "sync"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )

View File

@@ -6,14 +6,14 @@ import (
"testing" "testing"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )
func TestResolverDefersManagedDatabaseStartup(t *testing.T) { func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
routes, err := router.NewStaticResolver([]config.DatabaseConfig{ routes, err := router.NewStaticResolver([]config.DatabaseConfig{
{Name: "external", Upstream: "192.0.2.1:5432"}, {Name: "external", Upstream: "192.0.2.1:5432"},
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}, {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -21,7 +21,7 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}} ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}}
resolver := NewResolver(routes, []config.DatabaseConfig{ resolver := NewResolver(routes, []config.DatabaseConfig{
{Name: "external", Upstream: "192.0.2.1:5432"}, {Name: "external", Upstream: "192.0.2.1:5432"},
{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}, {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}},
}, ensurer) }, ensurer)
backend, err := resolver.ResolveDatabase(context.Background(), "external") backend, err := resolver.ResolveDatabase(context.Background(), "external")
@@ -42,12 +42,12 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) {
} }
func TestResolverSerializesManagedStartup(t *testing.T) { 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"}}}) routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, gate: make(chan struct{}), started: make(chan struct{})} 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) resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}}, ensurer)
var wg sync.WaitGroup var wg sync.WaitGroup
for range 2 { for range 2 {
@@ -69,12 +69,12 @@ func TestResolverSerializesManagedStartup(t *testing.T) {
} }
func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) { 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}}}) routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)} 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) resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}}, controller)
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil { if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -91,12 +91,12 @@ func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) {
} }
func TestResolverCancelsIdleStopForNewConnection(t *testing.T) { 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}}}) routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)} 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) resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}}, controller)
if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil { if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -122,7 +122,7 @@ func TestResolverMetersTrafficAndStopsTrafficIdleSession(t *testing.T) {
postgres := &config.PostgresConfig{ postgres := &config.PostgresConfig{
Image: "postgres:18", Image: "postgres:18",
Volume: "managed-data", Volume: "managed-data",
PasswordEnv: "MANAGED_PASSWORD", PasswordSecret: "managed_password",
TrafficIdleTimeout: 100 * time.Millisecond, TrafficIdleTimeout: 100 * time.Millisecond,
} }
routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: postgres}}) routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: postgres}})

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
) )
var ( var (

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"testing" "testing"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
) )
func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) { func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) {

View File

@@ -14,7 +14,7 @@ import (
"testing" "testing"
"time" "time"
config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"github.com/barkstack/pawsql/internal/pgwire" "github.com/barkstack/pawsql/internal/pgwire"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )