Skip to content

Commit aef24bb

Browse files
committed
Cluster: Add config option to sync and drop ProxySQL user accounts #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
1 parent d6a41e5 commit aef24bb

File tree

7 files changed

+563
-234
lines changed

7 files changed

+563
-234
lines changed

internal/config/flags.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,12 @@ var Flags = CliFlags{
938938
EnvVars: EnvVars("DATABASE_PROVISION_DSN"),
939939
Hidden: true,
940940
}}, {
941+
Flag: &cli.StringFlag{
942+
Name: "database-provision-proxy-dsn",
943+
Usage: "ProxySQL admin `DSN` (port 6032 by default) for keeping user accounts in sync",
944+
EnvVars: EnvVars("DATABASE_PROVISION_PROXY_DSN"),
945+
Hidden: true,
946+
}}, {
941947
Flag: &cli.StringFlag{
942948
Name: "ffmpeg-bin",
943949
Usage: "FFmpeg `COMMAND` for video transcoding and thumbnail extraction",

internal/config/options.go

Lines changed: 235 additions & 234 deletions
Large diffs are not rendered by default.

internal/config/report.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
199199
{"database-provision-driver", c.options.DatabaseProvisionDriver},
200200
{"database-provision-prefix", c.DatabaseProvisionPrefix()},
201201
{"database-provision-dsn", maskDatabaseProvisionDSN(c.options.DatabaseProvisionDSN)},
202+
{"database-provision-proxy-dsn", maskDatabaseProvisionDSN(c.options.DatabaseProvisionProxyDSN)},
202203
}...)
203204
}
204205

internal/service/cluster/provisioner/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,43 @@ The provisioner package manages per-node MariaDB schemas and users for cluster d
7070
7171
- Fast pass: `go test ./internal/service/cluster/provisioner -count=1`
7272
- End-to-end sanity with API: `go test ./internal/api -run 'ClusterNodesRegister' -count=1` (ensures the API cleanup helper stays aligned with the provisioner)
73+
74+
## ProxySQL Integration
75+
76+
Use ProxySQL to verify tenant provisioning stays in sync with the proxy in addition to MariaDB. The unit test suite ships with an opt-in integration test (`TestEnsureCredentials_ProxySQLIntegration`) that exercises the full flow once ProxySQL is available locally.
77+
78+
### One-Time Setup (inside the dev container)
79+
80+
1. Download and install ProxySQL (v3.0.2 shown here):
81+
```bash
82+
cd /tmp
83+
curl -fL -o proxysql_3.0.2-debian12_amd64.deb https://github.com/sysown/proxysql/releases/download/v3.0.2/proxysql_3.0.2-debian12_amd64.deb
84+
sudo dpkg -i proxysql_3.0.2-debian12_amd64.deb
85+
```
86+
2. Start ProxySQL as a daemon using the default config (/etc/proxysql.cnf ships with admin `admin:admin`):
87+
```bash
88+
sudo proxysql --config /etc/proxysql.cnf --pidfile /tmp/proxysql.pid --daemon
89+
```
90+
3. Confirm the admin listener is reachable:
91+
```bash
92+
sudo mysql --protocol=TCP --host=127.0.0.1 --port=6032 --user=admin --password=admin -e 'SELECT 1'
93+
```
94+
95+
The bundled MariaDB instance (credentials in `.my.cnf` at the repo root) is sufficient as a backend; no extra ProxySQL configuration is required for the integration test.
96+
97+
### Running the Integration Test
98+
99+
1. When ProxySQL is running, toggle the test via an environment variable:
100+
```bash
101+
PHOTOPRISM_TEST_PROXYSQL=1 go test ./internal/service/cluster/provisioner -run TestEnsureCredentials_ProxySQLIntegration -count=1
102+
```
103+
- Override the admin DSN with `PHOTOPRISM_TEST_PROXYSQL_DSN=user:pass@tcp(host:6032)/` if you changed the default credentials or port.
104+
2. The test provisions a tenant, verifies the ProxySQL `mysql_users` row, reruns the idempotent ensure path, and exercises `DropCredentials`. Cleanup hooks remove both the MariaDB schema/user and the ProxySQL account.
105+
106+
### Tearing Down / Restarting
107+
108+
- Stop ProxySQL when finished:
109+
```bash
110+
sudo kill "$(cat /tmp/proxysql.pid)"
111+
```
112+
- To restart, re-run the daemon command from the setup section. The generated SSL materials live under `/var/lib/proxysql`.

internal/service/cluster/provisioner/credentials.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ func EnsureCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeN
118118
return out, created, err
119119
}
120120

121+
// 7) Provision ProxySQL user account if ProvisionProxyDSN is set.
122+
if ProvisionProxyDSN != "" {
123+
proxyPass := ""
124+
if rotate || created {
125+
proxyPass = dbPass
126+
}
127+
128+
if err = SyncProxyUser(ctx, ProvisionProxyDSN, dbName, dbUser, proxyPass, ProvisionProxyOptions); err != nil {
129+
return out, created, fmt.Errorf("proxysql: %w", err)
130+
}
131+
}
132+
121133
// Compose credentials.
122134
out.Host = DatabaseHost
123135
out.Port = DatabasePort
@@ -167,6 +179,12 @@ func DropCredentials(ctx context.Context, dbName, user string) error {
167179
}
168180
}
169181

182+
if ProvisionProxyDSN != "" && user != "" {
183+
if err := DropProxyUser(ctx, ProvisionProxyDSN, user); err != nil {
184+
errs = append(errs, fmt.Sprintf("proxysql: %v", err))
185+
}
186+
}
187+
170188
if len(errs) > 0 {
171189
return fmt.Errorf("drop credentials: %s", strings.Join(errs, "; "))
172190
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package provisioner
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"strings"
8+
)
9+
10+
const (
11+
// DefaultProxyHostgroup routes tenant connections to the primary (writer) backend hostgroup.
12+
DefaultProxyHostgroup = 10
13+
// DefaultProxyFrontend enables clients to authenticate through ProxySQL; required for tenant users.
14+
DefaultProxyFrontend = 1
15+
// DefaultProxyBackend keeps tenant users from authenticating against upstream servers directly.
16+
DefaultProxyBackend = 0
17+
// DefaultProxyMaxConnections caps concurrent connections per tenant to avoid exhausting ProxySQL.
18+
DefaultProxyMaxConnections = 200
19+
// DefaultProxyUseSSL toggles ProxySQL's SSL flag for tenant accounts (0 = disabled by default).
20+
DefaultProxyUseSSL = 0
21+
// DefaultProxyComment labels provisioned users so operators can distinguish auto-managed accounts.
22+
DefaultProxyComment = "Portal provisioned tenant"
23+
)
24+
25+
// ProxyOptions describes the ProxySQL mysql_users attributes to apply when syncing tenant accounts.
26+
type ProxyOptions struct {
27+
Hostgroup int
28+
Frontend int
29+
Backend int
30+
MaxConnections int
31+
UseSSL int
32+
Comment string
33+
}
34+
35+
// ProvisionProxyDSN specifies the optional ProxySQL admin DSN (port 6032 by default) for keeping user accounts in sync.
36+
var ProvisionProxyDSN = ""
37+
38+
// ProvisionProxyOptions stores the current defaults used when synchronizing ProxySQL tenant accounts.
39+
var ProvisionProxyOptions = ProxyOptions{
40+
Hostgroup: DefaultProxyHostgroup,
41+
Frontend: DefaultProxyFrontend,
42+
Backend: DefaultProxyBackend,
43+
MaxConnections: DefaultProxyMaxConnections,
44+
UseSSL: DefaultProxyUseSSL,
45+
Comment: DefaultProxyComment,
46+
}
47+
48+
// SyncProxyUser ensures the ProxySQL mysql_users entry matches the provided schema and credentials.
49+
// When pass is empty the existing password is preserved, allowing non-rotating syncs that only adjust metadata.
50+
func SyncProxyUser(ctx context.Context, proxyDSN, schema, user, pass string, opts ProxyOptions) error {
51+
db, err := sql.Open("mysql", normalizeProxyDSN(proxyDSN))
52+
if err != nil {
53+
return err
54+
}
55+
defer db.Close()
56+
57+
password := pass
58+
if password == "" {
59+
if err := db.QueryRowContext(ctx, "SELECT password FROM mysql_users WHERE username = ?", user).Scan(&password); err != nil {
60+
if errors.Is(err, sql.ErrNoRows) {
61+
return errors.New("proxysql: existing user not found and password not provided")
62+
}
63+
return err
64+
}
65+
}
66+
67+
if opts.Comment == "" {
68+
opts.Comment = DefaultProxyComment
69+
}
70+
71+
if _, err = db.ExecContext(ctx, "DELETE FROM mysql_users WHERE username = ?", user); err != nil {
72+
return err
73+
}
74+
75+
if _, err = db.ExecContext(ctx, `
76+
INSERT INTO mysql_users (
77+
username, password, active, use_ssl, default_hostgroup,
78+
default_schema, schema_locked, transaction_persistent,
79+
fast_forward, backend, frontend, max_connections, attributes, comment
80+
) VALUES (
81+
?, ?, 1, ?, ?, ?,
82+
0, 1,
83+
0, ?, ?, ?, '{}', ?
84+
)
85+
`, user, password, opts.UseSSL, opts.Hostgroup, schema, opts.Backend, opts.Frontend, opts.MaxConnections, opts.Comment); err != nil {
86+
return err
87+
}
88+
89+
return applyProxySQL(ctx, db)
90+
}
91+
92+
// DropProxyUser removes the mysql_users record for a tenant and reloads ProxySQL runtime/disk.
93+
func DropProxyUser(ctx context.Context, proxyDSN, user string) error {
94+
db, err := sql.Open("mysql", normalizeProxyDSN(proxyDSN))
95+
if err != nil {
96+
return err
97+
}
98+
defer db.Close()
99+
100+
if _, err = db.ExecContext(ctx, "DELETE FROM mysql_users WHERE username = ?", user); err != nil {
101+
return err
102+
}
103+
104+
return applyProxySQL(ctx, db)
105+
}
106+
107+
// applyProxySQL reloads mysql_users into ProxySQL runtime and persists the changes to disk.
108+
func applyProxySQL(ctx context.Context, db *sql.DB) error {
109+
for _, stmt := range []string{
110+
"LOAD MYSQL USERS TO RUNTIME",
111+
"SAVE MYSQL USERS TO DISK",
112+
} {
113+
if _, err := db.ExecContext(ctx, stmt); err != nil {
114+
return err
115+
}
116+
}
117+
return nil
118+
}
119+
120+
// normalizeProxyDSN adds interpolateParams to ProxySQL admin DSNs when missing so prepared statements work.
121+
func normalizeProxyDSN(dsn string) string {
122+
if dsn == "" || strings.Contains(dsn, "interpolateParams=") {
123+
return dsn
124+
}
125+
126+
sep := "?"
127+
if strings.Contains(dsn, "?") {
128+
if strings.HasSuffix(dsn, "?") || strings.HasSuffix(dsn, "&") {
129+
sep = ""
130+
} else {
131+
sep = "&"
132+
}
133+
}
134+
135+
return dsn + sep + "interpolateParams=true"
136+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package provisioner
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"os"
7+
"testing"
8+
"time"
9+
10+
"github.com/photoprism/photoprism/internal/config"
11+
)
12+
13+
func TestEnsureCredentials_ProxySQLIntegration(t *testing.T) {
14+
if os.Getenv("PHOTOPRISM_TEST_PROXYSQL") == "" {
15+
t.Skip("PHOTOPRISM_TEST_PROXYSQL not set; skipping ProxySQL integration test")
16+
}
17+
18+
ctx := context.Background()
19+
20+
proxyDSN := os.Getenv("PHOTOPRISM_TEST_PROXYSQL_DSN")
21+
if proxyDSN == "" {
22+
proxyDSN = "admin:admin@tcp(127.0.0.1:6032)/"
23+
}
24+
25+
adminDB, err := sql.Open("mysql", normalizeProxyDSN(proxyDSN))
26+
if err != nil {
27+
t.Skipf("proxy DSN not openable: %v", err)
28+
}
29+
t.Cleanup(func() { _ = adminDB.Close() })
30+
31+
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
32+
if err := adminDB.PingContext(pingCtx); err != nil {
33+
cancel()
34+
t.Skipf("proxy DSN not reachable: %v", err)
35+
}
36+
cancel()
37+
38+
origDSN := ProvisionProxyDSN
39+
origOpts := ProvisionProxyOptions
40+
ProvisionProxyDSN = proxyDSN
41+
ProvisionProxyOptions = ProxyOptions{
42+
Hostgroup: DefaultProxyHostgroup,
43+
Frontend: DefaultProxyFrontend,
44+
Backend: DefaultProxyBackend,
45+
MaxConnections: DefaultProxyMaxConnections,
46+
UseSSL: DefaultProxyUseSSL,
47+
Comment: DefaultProxyComment,
48+
}
49+
t.Cleanup(func() {
50+
ProvisionProxyDSN = origDSN
51+
ProvisionProxyOptions = origOpts
52+
})
53+
54+
conf := config.NewConfig(config.CliTestContext())
55+
conf.Options().ClusterUUID = time.Now().UTC().Format("20060102-150405.000000000")
56+
57+
nodeUUID := "11111111-1111-4111-8111-333333333333"
58+
nodeName := "pp-proxy-itest"
59+
60+
creds, _, err := EnsureCredentials(ctx, conf, nodeUUID, nodeName, true)
61+
if err != nil {
62+
t.Fatalf("EnsureCredentials with ProxySQL error: %v", err)
63+
}
64+
65+
t.Cleanup(func() {
66+
if creds.Name == "" || creds.User == "" {
67+
return
68+
}
69+
if dropErr := DropCredentials(ctx, creds.Name, creds.User); dropErr != nil {
70+
t.Logf("cleanup drop credentials: %v", dropErr)
71+
}
72+
})
73+
74+
var defaultSchema, comment string
75+
var frontend, backend, maxConnections int
76+
77+
err = adminDB.QueryRowContext(ctx, `
78+
SELECT default_schema, frontend, backend, max_connections, comment
79+
FROM mysql_users WHERE username = ?
80+
`, creds.User).Scan(&defaultSchema, &frontend, &backend, &maxConnections, &comment)
81+
if err != nil {
82+
t.Fatalf("mysql_users lookup: %v", err)
83+
}
84+
85+
if defaultSchema != creds.Name {
86+
t.Fatalf("expected default_schema %q, got %q", creds.Name, defaultSchema)
87+
}
88+
if frontend != ProvisionProxyOptions.Frontend {
89+
t.Fatalf("expected frontend=%d, got %d", ProvisionProxyOptions.Frontend, frontend)
90+
}
91+
if backend != ProvisionProxyOptions.Backend {
92+
t.Fatalf("expected backend=%d, got %d", ProvisionProxyOptions.Backend, backend)
93+
}
94+
if maxConnections != ProvisionProxyOptions.MaxConnections {
95+
t.Fatalf("expected max_connections=%d, got %d", ProvisionProxyOptions.MaxConnections, maxConnections)
96+
}
97+
if comment != ProvisionProxyOptions.Comment {
98+
t.Fatalf("expected comment %q, got %q", ProvisionProxyOptions.Comment, comment)
99+
}
100+
101+
if _, _, err := EnsureCredentials(ctx, conf, nodeUUID, nodeName, false); err != nil {
102+
t.Fatalf("EnsureCredentials (rotate=false) error: %v", err)
103+
}
104+
105+
var count int
106+
if err := adminDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM mysql_users WHERE username = ?", creds.User).Scan(&count); err != nil {
107+
t.Fatalf("count mysql_users: %v", err)
108+
}
109+
if count != 1 {
110+
t.Fatalf("expected mysql_users count 1 after idempotent ensure, got %d", count)
111+
}
112+
113+
nodeUser := creds.User
114+
nodeSchema := creds.Name
115+
116+
if err := DropCredentials(ctx, nodeSchema, nodeUser); err != nil {
117+
t.Fatalf("DropCredentials error: %v", err)
118+
}
119+
creds.Name, creds.User = "", ""
120+
121+
if err := adminDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM mysql_users WHERE username = ?", nodeUser).Scan(&count); err != nil {
122+
t.Fatalf("count mysql_users after drop: %v", err)
123+
}
124+
if count != 0 {
125+
t.Fatalf("expected mysql_users count 0 after drop, got %d", count)
126+
}
127+
}

0 commit comments

Comments
 (0)