Skip to content

Commit f399649

Browse files
committed
Cluster: Allow configuration of database and user prefix #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
1 parent c6c501c commit f399649

File tree

10 files changed

+165
-8
lines changed

10 files changed

+165
-8
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,6 @@ Note: Across our public documentation, official images, and in production, the c
426426
- Theme endpoint: `GET /api/v1/cluster/theme` streams a zip from `conf.ThemePath()`; only reinstall when `app.js` is missing and always use the header helpers in `pkg/http/header`.
427427
- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, include `ClientID` + `ClientSecret` when renaming an existing node, and persist only newly generated secrets or DB settings.
428428
- Registry & DTOs: use the client-backed registry (`NewClientRegistryWithConfig`)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`), the registry interface stays UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`), CLI lookups resolve `uuid → ClientID → name`, and DTOs normalize `Database.{Name,User,Driver,RotatedAt}` while exposing `ClientSecret` only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows, admin responses may include `AdvertiseUrl`/`Database`, client/user sessions stay redacted, registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600), and `ClientData` no longer stores `NodeUUID`.
429-
- Provisioner & DSN: database/user names use UUID-based HMACs (`cluster_d<hmac11>`, `cluster_u<hmac11>`); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
429+
- Provisioner & DSN: database/user names use UUID-based HMACs (`<prefix>d<hmac11>`, `<prefix>u<hmac11>` where the prefix defaults to `cluster_` but may be overridden via the portal-only `database-provision-prefix` flag); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
430430
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
431431
- Testing: exercise Portal endpoints with `httptest`, guard extraction paths with `pkg/fs.Unzip` size caps, and expect admin-only fields to disappear when authenticated as a client/user session.

internal/config/config_db.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/photoprism/photoprism/internal/entity/migrate"
2020
"github.com/photoprism/photoprism/internal/event"
2121
"github.com/photoprism/photoprism/internal/mutex"
22+
"github.com/photoprism/photoprism/internal/service/cluster"
2223
"github.com/photoprism/photoprism/pkg/clean"
2324
)
2425

@@ -303,6 +304,55 @@ func (c *Config) DatabasePassword() string {
303304
}
304305
}
305306

307+
// DatabaseProvisionPrefix returns the sanitized prefix for provisioned database names and users.
308+
func (c *Config) DatabaseProvisionPrefix() string {
309+
prefix := strings.TrimSpace(c.options.DatabaseProvisionPrefix)
310+
311+
if prefix == "" {
312+
return cluster.DefaultDatabaseProvisionPrefix
313+
}
314+
315+
prefix = strings.ToLower(prefix)
316+
317+
cleaned := make([]rune, 0, len(prefix))
318+
prevUnderscore := false
319+
320+
for _, r := range prefix {
321+
switch {
322+
case r >= 'a' && r <= 'z':
323+
cleaned = append(cleaned, r)
324+
prevUnderscore = false
325+
case r >= '0' && r <= '9':
326+
if len(cleaned) == 0 {
327+
continue
328+
}
329+
cleaned = append(cleaned, r)
330+
prevUnderscore = false
331+
case r == '_' || r == '-' || r == ' ':
332+
if len(cleaned) == 0 || prevUnderscore {
333+
continue
334+
}
335+
cleaned = append(cleaned, '_')
336+
prevUnderscore = true
337+
default:
338+
continue
339+
}
340+
341+
if len(cleaned) >= cluster.DatabaseProvisionPrefixMaxLen {
342+
break
343+
}
344+
}
345+
346+
if len(cleaned) == 0 {
347+
return cluster.DefaultDatabaseProvisionPrefix
348+
}
349+
350+
result := string(cleaned)
351+
c.options.DatabaseProvisionPrefix = result
352+
353+
return result
354+
}
355+
306356
// ShouldAutoRotateDatabase decides whether callers should request DB rotation automatically.
307357
// It is used by both the CLI and node bootstrap to avoid unnecessary provisioning calls.
308358
func (c *Config) ShouldAutoRotateDatabase() bool {

internal/config/config_db_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ func TestConfig_DatabasePassword(t *testing.T) {
199199
assert.Equal(t, "", c.DatabasePassword())
200200
}
201201

202+
func TestDatabaseProvisionPrefix(t *testing.T) {
203+
t.Run("Default", func(t *testing.T) {
204+
conf := NewConfig(CliTestContext())
205+
resetDatabaseOptions(conf)
206+
assert.Equal(t, cluster.DefaultDatabaseProvisionPrefix, conf.DatabaseProvisionPrefix())
207+
})
208+
t.Run("SanitizeAndTrim", func(t *testing.T) {
209+
conf := NewConfig(CliTestContext())
210+
resetDatabaseOptions(conf)
211+
conf.options.DatabaseProvisionPrefix = " My Custom-Prefix!! "
212+
213+
got := conf.DatabaseProvisionPrefix()
214+
215+
assert.Equal(t, "my_custom_prefix", got)
216+
assert.LessOrEqual(t, len(got), cluster.DatabaseProvisionPrefixMaxLen)
217+
assert.Equal(t, got, conf.options.DatabaseProvisionPrefix)
218+
})
219+
}
220+
202221
func TestShouldAutoRotateDatabase(t *testing.T) {
203222
t.Run("PortalAlwaysFalse", func(t *testing.T) {
204223
conf := NewMinimalTestConfig(t.TempDir())

internal/config/flags.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,13 @@ var Flags = CliFlags{
921921
EnvVars: EnvVars("DATABASE_PROVISION_DRIVER"),
922922
Hidden: true,
923923
}}, {
924+
Flag: &cli.StringFlag{
925+
Name: "database-provision-prefix",
926+
Usage: "auto-provisioning name `PREFIX` for generated database names and users",
927+
Value: cluster.DefaultDatabaseProvisionPrefix,
928+
EnvVars: EnvVars("DATABASE_PROVISION_PREFIX"),
929+
Hidden: true,
930+
}}, {
924931
Flag: &cli.StringFlag{
925932
Name: "database-provision-dsn",
926933
Usage: "auto-provisioning `DSN`",

internal/config/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ type Options struct {
189189
DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"`
190190
DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"`
191191
DatabaseProvisionDriver string `yaml:"DatabaseProvisionDriver" json:"-" flag:"database-provision-driver"`
192+
DatabaseProvisionPrefix string `yaml:"DatabaseProvisionPrefix" json:"-" flag:"database-provision-prefix"`
192193
DatabaseProvisionDSN string `yaml:"DatabaseProvisionDSN" json:"-" flag:"database-provision-dsn"`
193194
FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"`
194195
FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"`

internal/config/report.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,17 @@ func (c *Config) Report() (rows [][]string, cols []string) {
192192
{"jwt-scope", c.JWTAllowedScopes().String()},
193193
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
194194
{"advertise-url", c.AdvertiseUrl()},
195+
}...)
195196

197+
if c.Portal() {
198+
rows = append(rows, [][]string{
199+
{"database-provision-driver", c.options.DatabaseProvisionDriver},
200+
{"database-provision-prefix", c.DatabaseProvisionPrefix()},
201+
{"database-provision-dsn", maskDatabaseProvisionDSN(c.options.DatabaseProvisionDSN)},
202+
}...)
203+
}
204+
205+
rows = append(rows, [][]string{
196206
// Proxy Servers.
197207
{"https-proxy", c.HttpsProxy()},
198208
{"https-proxy-insecure", fmt.Sprintf("%t", c.HttpsProxyInsecure())},
@@ -345,3 +355,21 @@ func (c *Config) Report() (rows [][]string, cols []string) {
345355

346356
return rows, cols
347357
}
358+
359+
func maskDatabaseProvisionDSN(dsn string) string {
360+
if dsn == "" {
361+
return ""
362+
}
363+
364+
ds := NewDSN(dsn)
365+
if ds.Password == "" {
366+
return dsn
367+
}
368+
369+
needle := ":" + ds.Password + "@"
370+
if strings.Contains(dsn, needle) {
371+
return strings.Replace(dsn, needle, ":***@", 1)
372+
}
373+
374+
return dsn
375+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package cluster
22

3+
// Default values used by cluster provisioning helpers.
4+
const (
5+
// DefaultDatabaseProvisionPrefix is applied to auto-provisioned database names and user accounts.
6+
DefaultDatabaseProvisionPrefix = "cluster_"
7+
// DatabaseProvisionPrefixMaxLen keeps generated usernames within the MySQL 32 character budget.
8+
DatabaseProvisionPrefixMaxLen = 20
9+
)
10+
311
// Example values used by documentation and tests to illustrate cluster tokens.
412
const (
513
// ExampleJoinToken represents a valid portal join token.

internal/service/cluster/provisioner/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
## Overview
44

5-
The provisioner package manages per-node MariaDB schemas and users for cluster deployments. It derives deterministic identifiers from the cluster UUID and node name, creates or rotates credentials via the admin DSN, and exposes helpers (`EnsureCredentials`, `DropCredentials`, `GenerateCredentials`) that API and CLI layers can reuse when onboarding or rotating nodes.
5+
The provisioner package manages per-node MariaDB schemas and users for cluster deployments. It derives deterministic identifiers from the cluster UUID and node name using a configurable prefix (default `cluster_`), creates or rotates credentials via the admin DSN, and exposes helpers (`EnsureCredentials`, `DropCredentials`, `GenerateCredentials`) that API and CLI layers can reuse when onboarding or rotating nodes.
66

77
## Development Workflow
88

99
- Configuration lives in `database.go`. The admin connection string is `ProvisionDSN` (default `root:photoprism@tcp(mariadb:4001)/photoprism?...`). Adjust only when running against a different host or password.
1010
- `EnsureCredentials` accepts node UUID and name, creates the schema if needed, and returns credentials plus rotation metadata. `DropCredentials` revokes grants, drops the user, and removes the schema. Both functions require a context; prefer `context.WithTimeout` in callers.
11-
- Identifier generation is centralized in `GenerateCredentials`. Call it instead of handcrafting database or user names so tests, CLI, and API stay aligned.
11+
- Identifier generation is centralized in `GenerateCredentials`. Call it instead of handcrafting database or user names so tests, CLI, and API stay aligned. The resulting identifiers follow `<prefix>d<hmac11>` for schemas and `<prefix>u<hmac11>` for users. Portal deployments may override the prefix via the `database-provision-prefix` flag; defaults are `cluster_d…` / `cluster_u…`.
1212

1313
## Testing Guidelines
1414

@@ -31,12 +31,12 @@ The provisioner package manages per-node MariaDB schemas and users for cluster d
3131
- Connect from the dev container using `mariadb` (already configured to reach `mariadb:4001`). Common snippets:
3232
```bash
3333
cat <<'SQL' | mariadb
34-
SHOW DATABASES LIKE 'cluster_d%';
34+
SHOW DATABASES LIKE 'cluster_d%'; -- adjust prefix if database-provision-prefix overrides the default
3535
SQL
3636
```
3737
```bash
3838
cat <<'SQL' | mariadb
39-
SELECT User, Host FROM mysql.user WHERE User LIKE 'cluster_u%';
39+
SELECT User, Host FROM mysql.user WHERE User LIKE 'cluster_u%'; -- adjust prefix if needed
4040
SQL
4141
```
4242
- Manually drop leftover resources when iterating outside tests:

internal/service/cluster/provisioner/naming.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/photoprism/photoprism/internal/config"
11+
"github.com/photoprism/photoprism/internal/service/cluster"
1112
"github.com/photoprism/photoprism/pkg/rnd"
1213
)
1314

@@ -16,20 +17,38 @@ const (
1617
// Final pattern without slugs (UUID-based):
1718
// database: cluster_d<hmac11>
1819
// username: cluster_u<hmac11>
19-
prefix = "photoprism_"
2020
dbSuffix = 11
2121
userSuffix = 11
2222
// Budgets: keep user conservative for MySQL compatibility; MariaDB allows more.
2323
userMax = 32
2424
dbMax = 64
25+
// prefixMax ensures usernames remain within the MySQL identifier limit.
26+
prefixMax = cluster.DatabaseProvisionPrefixMaxLen
2527
)
2628

29+
// DatabasePrefix stores the default identifier prefix for provisioned databases and users.
30+
// Portal deployments override this value during initialization based on configuration.
31+
var DatabasePrefix = cluster.DefaultDatabaseProvisionPrefix
32+
2733
// GenerateCredentials computes deterministic database name and user for a node under the given portal
2834
// plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes
2935
// if the cluster UUID or node UUID changes.
3036
func GenerateCredentials(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
3137
clusterUUID := conf.ClusterUUID()
3238

39+
prefix := DatabasePrefix
40+
if conf != nil {
41+
if p := conf.DatabaseProvisionPrefix(); p != "" {
42+
prefix = p
43+
}
44+
}
45+
if prefix == "" {
46+
prefix = cluster.DefaultDatabaseProvisionPrefix
47+
}
48+
if len(prefix) > prefixMax {
49+
prefix = prefix[:prefixMax]
50+
}
51+
3352
// Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID.
3453
sName := hmacBase32("db-name:"+clusterUUID, nodeUUID)
3554
sUser := hmacBase32("db-user:"+clusterUUID, nodeUUID)

internal/service/cluster/provisioner/naming_test.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import (
88
"github.com/stretchr/testify/assert"
99

1010
"github.com/photoprism/photoprism/internal/config"
11+
"github.com/photoprism/photoprism/internal/service/cluster"
1112
)
1213

1314
func TestGenerateCredentials_StabilityAndBudgets(t *testing.T) {
15+
DatabasePrefix = cluster.DefaultDatabaseProvisionPrefix
16+
1417
c := config.NewConfig(config.CliTestContext())
1518
// Fix the cluster UUID via options to ensure determinism.
1619
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
@@ -26,11 +29,13 @@ func TestGenerateCredentials_StabilityAndBudgets(t *testing.T) {
2629
// Budgets and patterns.
2730
assert.LessOrEqual(t, len(user1), 32)
2831
assert.LessOrEqual(t, len(db1), 64)
29-
assert.Contains(t, db1, "photoprism_")
30-
assert.Contains(t, user1, "photoprism_")
32+
assert.Contains(t, db1, cluster.DefaultDatabaseProvisionPrefix)
33+
assert.Contains(t, user1, cluster.DefaultDatabaseProvisionPrefix)
3134
}
3235

3336
func TestGenerateCredentials_DifferentPortal(t *testing.T) {
37+
DatabasePrefix = cluster.DefaultDatabaseProvisionPrefix
38+
3439
c1 := config.NewConfig(config.CliTestContext())
3540
c2 := config.NewConfig(config.CliTestContext())
3641
c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
@@ -44,6 +49,8 @@ func TestGenerateCredentials_DifferentPortal(t *testing.T) {
4449
}
4550

4651
func TestGenerateCredentials_Truncation(t *testing.T) {
52+
DatabasePrefix = cluster.DefaultDatabaseProvisionPrefix
53+
4754
c := config.NewConfig(config.CliTestContext())
4855
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
4956
longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets"
@@ -53,6 +60,24 @@ func TestGenerateCredentials_Truncation(t *testing.T) {
5360
assert.LessOrEqual(t, len(db), 64)
5461
}
5562

63+
func TestGenerateCredentials_CustomPrefix(t *testing.T) {
64+
DatabasePrefix = cluster.DefaultDatabaseProvisionPrefix
65+
66+
c := config.NewConfig(config.CliTestContext())
67+
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
68+
c.Options().DatabaseProvisionPrefix = "My-Custom Prefix!"
69+
70+
prefix := c.DatabaseProvisionPrefix()
71+
assert.Equal(t, "my_custom_prefix", prefix)
72+
73+
db, user, _ := GenerateCredentials(c, "11111111-1111-4111-8111-222222222222", "pp-node-02")
74+
75+
assert.True(t, strings.HasPrefix(db, prefix+"d"))
76+
assert.True(t, strings.HasPrefix(user, prefix+"u"))
77+
assert.LessOrEqual(t, len(user), 32)
78+
assert.LessOrEqual(t, len(db), 64)
79+
}
80+
5681
func TestBuildDSN(t *testing.T) {
5782
dsn := BuildDSN("mysql", "mariadb", 3306, "user", "pass", "dbname")
5883
assert.Contains(t, dsn, "user:pass@tcp(mariadb:3306)/dbname")

0 commit comments

Comments
 (0)