Skip to content

Commit de18fd7

Browse files
gustavobertoiclaude
andcommitted
feat(provision): M2 idempotent per-project Postgres role/db (spec 03, D8)
Per-project data isolation on the shared Postgres via existence-guarded SQL (NOT initdb.d, which only runs on a first-init empty PGDATA): - Postgres.EnsureProject: guarded CREATE/ALTER ROLE (password kept in sync), guarded CREATE DATABASE OWNER, REVOKE PUBLIC + GRANT — idempotent because CREATE ROLE/DATABASE are not. SQL logic sits behind the Conn interface so it is unit-testable without a live server. - pgx/v5-backed Conn (pure-Go, preserves the static binary) + a DSN builder. - Safe identifier/literal quoting (hyphen→underscore idents, escaped password literal since it cannot be a bind param in CREATE ROLE). Returns Credentials so the orchestrator injects them via exec env (the password is a secret — never written to a generated file, §7.5). Unit-tested: fresh vs idempotent paths, quoting, DSN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5ae40d8 commit de18fd7

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/go-playground/validator/v10 v10.30.3
1212
github.com/goccy/go-yaml v1.19.2
1313
github.com/gofrs/flock v0.13.0
14+
github.com/jackc/pgx/v5 v5.10.0
1415
github.com/moby/moby/client v0.4.1
1516
github.com/spf13/cobra v1.10.2
1617
modernc.org/sqlite v1.52.0
@@ -43,6 +44,8 @@ require (
4344
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
4445
github.com/google/uuid v1.6.0 // indirect
4546
github.com/inconshreveable/mousetrap v1.1.0 // indirect
47+
github.com/jackc/pgpassfile v1.0.0 // indirect
48+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
4649
github.com/leodido/go-urn v1.4.0 // indirect
4750
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4851
github.com/mattn/go-isatty v0.0.20 // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
8181
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
8282
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
8383
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
84+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
85+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
86+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
87+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
88+
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
89+
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
90+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
91+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
8492
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
8593
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
8694
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -129,6 +137,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
129137
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
130138
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
131139
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
140+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
132141
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
133142
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
134143
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=

internal/provision/pgx.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package provision
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
9+
"github.com/jackc/pgx/v5"
10+
)
11+
12+
// PgxConn is the pgx/v5-backed Conn used against a real shared Postgres. pgx is
13+
// pure-Go, so it preserves the CGO-free static binary (DECISIONS D8).
14+
type PgxConn struct {
15+
conn *pgx.Conn
16+
}
17+
18+
// Connect dials Postgres at dsn (as the superuser) and returns a Conn plus a
19+
// close func. The caller holds the flock around the provisioning it drives.
20+
func Connect(ctx context.Context, dsn string) (*PgxConn, func() error, error) {
21+
c, err := pgx.Connect(ctx, dsn)
22+
if err != nil {
23+
return nil, nil, fmt.Errorf("connect to shared postgres: %w", err)
24+
}
25+
return &PgxConn{conn: c}, func() error { return c.Close(ctx) }, nil
26+
}
27+
28+
func (p *PgxConn) Exec(ctx context.Context, sql string, args ...any) error {
29+
_, err := p.conn.Exec(ctx, sql, args...)
30+
return err
31+
}
32+
33+
func (p *PgxConn) Exists(ctx context.Context, sql string, args ...any) (bool, error) {
34+
var one int
35+
err := p.conn.QueryRow(ctx, sql, args...).Scan(&one)
36+
if errors.Is(err, pgx.ErrNoRows) {
37+
return false, nil
38+
}
39+
if err != nil {
40+
return false, err
41+
}
42+
return true, nil
43+
}
44+
45+
// DSN builds a libpq URL for connecting as a superuser to a shared Postgres
46+
// reached at host:port. sslmode=disable is correct for the local shared network.
47+
func DSN(host string, port int, user, password, database string) string {
48+
u := url.URL{
49+
Scheme: "postgres",
50+
User: url.UserPassword(user, password),
51+
Host: fmt.Sprintf("%s:%d", host, port),
52+
Path: "/" + database,
53+
RawQuery: "sslmode=disable",
54+
}
55+
return u.String()
56+
}

internal/provision/provision.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Package provision creates per-project data isolation on the shared engines
2+
// (spec 03, ARCHITECTURE §5): an idempotent, existence-guarded Postgres role +
3+
// database per project (NOT initdb.d, which only runs on a first-init empty
4+
// PGDATA). The SQL logic sits behind the Conn interface so it is unit-testable
5+
// without a live server; the pgx-backed Conn (pgx.go) is the real path.
6+
//
7+
// All provisioning mutations happen while the caller holds the machine-global
8+
// flock (CREATE ROLE / CREATE DATABASE race otherwise — DECISIONS D7/D8).
9+
package provision
10+
11+
import (
12+
"context"
13+
"fmt"
14+
"strings"
15+
)
16+
17+
// Conn is the minimal Postgres surface the provisioner needs.
18+
type Conn interface {
19+
// Exec runs a statement (DDL/GRANT). CREATE DATABASE cannot run in a
20+
// transaction, so implementations must execute statements unwrapped.
21+
Exec(ctx context.Context, sql string, args ...any) error
22+
// Exists reports whether a guard query (e.g. SELECT 1 FROM pg_roles ...)
23+
// returns at least one row.
24+
Exists(ctx context.Context, sql string, args ...any) (bool, error)
25+
}
26+
27+
// Credentials is the per-project Postgres connection identity, returned so the
28+
// orchestrator can inject it into the consuming service's env (the password is a
29+
// secret — passed via exec env, never written to a generated file, §7.5).
30+
type Credentials struct {
31+
Role string
32+
Database string
33+
Password string
34+
}
35+
36+
// Postgres provisions per-project roles and databases on a shared Postgres.
37+
type Postgres struct{}
38+
39+
// EnsureProject idempotently ensures a login role and an owned database exist for
40+
// project, with password kept in sync, and locks down PUBLIC so each role sees
41+
// only its own database. Existence-guarded because CREATE ROLE / CREATE DATABASE
42+
// are not idempotent (DECISIONS D8). Returns the resolved credentials.
43+
func (Postgres) EnsureProject(ctx context.Context, conn Conn, project, password string) (Credentials, error) {
44+
role := pgIdent(project)
45+
db := role // per-project database shares the role's name
46+
47+
// 1. Role — create or keep its password in sync.
48+
roleExists, err := conn.Exists(ctx, `SELECT 1 FROM pg_roles WHERE rolname = $1`, role)
49+
if err != nil {
50+
return Credentials{}, fmt.Errorf("check role %q: %w", role, err)
51+
}
52+
if roleExists {
53+
if err := conn.Exec(ctx, `ALTER ROLE `+quoteIdent(role)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil {
54+
return Credentials{}, fmt.Errorf("alter role %q: %w", role, err)
55+
}
56+
} else {
57+
if err := conn.Exec(ctx, `CREATE ROLE `+quoteIdent(role)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil {
58+
return Credentials{}, fmt.Errorf("create role %q: %w", role, err)
59+
}
60+
}
61+
62+
// 2. Database — guarded create (CREATE DATABASE is not idempotent and cannot
63+
// run in a transaction).
64+
dbExists, err := conn.Exists(ctx, `SELECT 1 FROM pg_database WHERE datname = $1`, db)
65+
if err != nil {
66+
return Credentials{}, fmt.Errorf("check database %q: %w", db, err)
67+
}
68+
if !dbExists {
69+
if err := conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(db)+` OWNER `+quoteIdent(role)); err != nil {
70+
return Credentials{}, fmt.Errorf("create database %q: %w", db, err)
71+
}
72+
}
73+
74+
// 3. Privileges — revoke PUBLIC, grant the owning role (idempotent).
75+
for _, stmt := range []string{
76+
`REVOKE ALL ON DATABASE ` + quoteIdent(db) + ` FROM PUBLIC`,
77+
`GRANT ALL ON DATABASE ` + quoteIdent(db) + ` TO ` + quoteIdent(role),
78+
} {
79+
if err := conn.Exec(ctx, stmt); err != nil {
80+
return Credentials{}, fmt.Errorf("grant on %q: %w", db, err)
81+
}
82+
}
83+
84+
return Credentials{Role: role, Database: db, Password: password}, nil
85+
}
86+
87+
// pgIdent maps a (dsname-validated) project name to a safe unquoted-friendly
88+
// Postgres identifier: hyphens become underscores. The result is still quoted at
89+
// use so any residual characters are handled.
90+
func pgIdent(project string) string {
91+
return strings.ReplaceAll(project, "-", "_")
92+
}
93+
94+
// quoteIdent double-quotes a Postgres identifier, doubling embedded quotes.
95+
func quoteIdent(s string) string {
96+
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
97+
}
98+
99+
// quoteLiteral single-quotes a Postgres string literal, doubling embedded
100+
// quotes. Used for the role password (which cannot be a bind parameter in
101+
// CREATE/ALTER ROLE).
102+
func quoteLiteral(s string) string {
103+
return `'` + strings.ReplaceAll(s, `'`, `''`) + `'`
104+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package provision
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// mockConn records executed statements and answers existence guards from a set.
10+
type mockConn struct {
11+
execs []string
12+
existing map[string]bool
13+
}
14+
15+
func (m *mockConn) Exec(_ context.Context, sql string, _ ...any) error {
16+
m.execs = append(m.execs, sql)
17+
return nil
18+
}
19+
20+
func (m *mockConn) Exists(_ context.Context, _ string, args ...any) (bool, error) {
21+
if len(args) == 0 {
22+
return false, nil
23+
}
24+
name, _ := args[0].(string)
25+
return m.existing[name], nil
26+
}
27+
28+
func (m *mockConn) joined() string { return strings.Join(m.execs, "\n") }
29+
30+
func TestEnsureProjectFresh(t *testing.T) {
31+
m := &mockConn{existing: map[string]bool{}}
32+
creds, err := Postgres{}.EnsureProject(context.Background(), m, "api", "s3cr3t")
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
if creds.Role != "api" || creds.Database != "api" || creds.Password != "s3cr3t" {
37+
t.Fatalf("creds = %+v", creds)
38+
}
39+
sql := m.joined()
40+
for _, want := range []string{
41+
`CREATE ROLE "api" WITH LOGIN PASSWORD 's3cr3t'`,
42+
`CREATE DATABASE "api" OWNER "api"`,
43+
`REVOKE ALL ON DATABASE "api" FROM PUBLIC`,
44+
`GRANT ALL ON DATABASE "api" TO "api"`,
45+
} {
46+
if !strings.Contains(sql, want) {
47+
t.Errorf("missing statement %q in:\n%s", want, sql)
48+
}
49+
}
50+
if strings.Contains(sql, "ALTER ROLE") {
51+
t.Error("fresh provision should CREATE, not ALTER")
52+
}
53+
}
54+
55+
func TestEnsureProjectIdempotent(t *testing.T) {
56+
// Role + db already exist → ALTER (keep password in sync), no CREATE DATABASE.
57+
m := &mockConn{existing: map[string]bool{"api": true}}
58+
if _, err := (Postgres{}).EnsureProject(context.Background(), m, "api", "newpw"); err != nil {
59+
t.Fatal(err)
60+
}
61+
sql := m.joined()
62+
if !strings.Contains(sql, `ALTER ROLE "api" WITH LOGIN PASSWORD 'newpw'`) {
63+
t.Errorf("expected ALTER ROLE, got:\n%s", sql)
64+
}
65+
if strings.Contains(sql, "CREATE ROLE") || strings.Contains(sql, "CREATE DATABASE") {
66+
t.Errorf("should not CREATE when role/db exist:\n%s", sql)
67+
}
68+
}
69+
70+
func TestIdentifierAndLiteralQuoting(t *testing.T) {
71+
// Hyphenated project → underscore identifier; password with a quote escaped.
72+
m := &mockConn{existing: map[string]bool{}}
73+
creds, err := Postgres{}.EnsureProject(context.Background(), m, "my-app", "pa'ss")
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if creds.Role != "my_app" {
78+
t.Errorf("role = %q, want my_app", creds.Role)
79+
}
80+
sql := m.joined()
81+
if !strings.Contains(sql, `CREATE ROLE "my_app" WITH LOGIN PASSWORD 'pa''ss'`) {
82+
t.Errorf("quoting wrong:\n%s", sql)
83+
}
84+
}
85+
86+
func TestDSN(t *testing.T) {
87+
got := DSN("shared-postgres", 5432, "devstack", "p@ss word", "postgres")
88+
if !strings.HasPrefix(got, "postgres://devstack:") || !strings.Contains(got, "@shared-postgres:5432/postgres") {
89+
t.Errorf("DSN = %q", got)
90+
}
91+
if !strings.Contains(got, "sslmode=disable") {
92+
t.Errorf("DSN missing sslmode: %q", got)
93+
}
94+
}

0 commit comments

Comments
 (0)