Cluster: Change "photoprism_" database / user prefix to "cluster_" #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-31 18:18:18 +01:00
parent 755ebe0aee
commit 82f5c5f818
16 changed files with 32 additions and 30 deletions

View File

@@ -74,6 +74,7 @@ Thumbs.db
.heartbeat
.idea
.codex
.config
.local
*~
.goutputstream*

1
.gitignore vendored
View File

@@ -77,6 +77,7 @@ Thumbs.db
.glide
.idea
.codex
.config
.local
.project
.vscode

View File

@@ -426,6 +426,6 @@ Note: Across our public documentation, official images, and in production, the c
- 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`.
- 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.
- 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`.
- Provisioner & DSN: database/user names use UUID-based HMACs (`photoprism_d<hmac11>`, `photoprism_u<hmac11>`); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
- 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.
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- 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.

View File

@@ -103,7 +103,7 @@ Background Workers
- Auto indexer: `internal/workers/auto/*`.
Cluster / Portal
- Node types: `internal/service/cluster/const.go` (`cluster.RoleInstance`, `cluster.RolePortal`, `cluster.RoleService`).
- Node types: `internal/service/cluster/const.go` (`cluster.RoleApp`, `cluster.RolePortal`, `cluster.RoleService`).
- Node bootstrap & registration: `internal/service/cluster/node/*` (HTTP to Portal; do not import Portal internals).
- Registration now retries once on 401/403 by rotating the node client secret with the join token and persists the new credentials (falling back to in-memory storage if the secrets directory is read-only).
- Theme sync logs explicitly when refresh/rotation occurs so operators can trace credential churn in standard log levels.
@@ -201,8 +201,8 @@ Cluster Registry & Provisioner Cheatsheet
- UUIDfirst everywhere: API paths `{uuid}`, Registry `Get/Delete/RotateSecret` by UUID; explicit `FindByClientID` exists for OAuth.
- Node/DTO fields: `uuid` required; `clientId` optional; database metadata includes `driver`.
- Provisioner naming (no slugs):
- database: `photoprism_d<hmac11>`
- username: `photoprism_u<hmac11>`
- database: `cluster_d<hmac11>`
- username: `cluster_u<hmac11>`
HMAC is base32 of ClusterUUID+NodeUUID; drivers currently `mysql|mariadb`.
- DSN builder: `BuildDSN(driver, host, port, user, pass, name)`; warns and falls back to MySQL format for unsupported drivers.
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful) and name each subtest string in PascalCase.

View File

@@ -105,7 +105,7 @@ func TestGetMetrics(t *testing.T) {
name string
role string
}{
{"metrics-instance-1", string(cluster.RoleApp)},
{"metrics-app-1", string(cluster.RoleApp)},
{"metrics-service-1", string(cluster.RoleService)},
}

View File

@@ -304,7 +304,7 @@ func (c *Config) NodeName() string {
return "node-" + s
}
// NodeRole returns the cluster node ROLE (portal, instance, or service).
// NodeRole returns the cluster node role (portal, app, or service).
func (c *Config) NodeRole() string {
if c.Edition() == Portal {
c.options.NodeRole = cluster.RolePortal

View File

@@ -22,7 +22,7 @@ func TestInitConfig_NoPortal_NoOp(t *testing.T) {
c := config.NewMinimalTestConfigWithDb("bootstrap", t.TempDir())
defer c.CloseDb()
// Default NodeRole() resolves to instance; no Portal configured.
// Default NodeRole() resolves to app; no Portal configured.
assert.Equal(t, cluster.RoleApp, c.NodeRole())
assert.NoError(t, InitConfig(c))
}

View File

@@ -1,5 +1,5 @@
/*
Package node bootstraps a PhotoPrism node (instance or service) that joins a cluster Portal.
Package node bootstraps a PhotoPrism node (app or service) that joins a cluster Portal.
Responsibilities include:

View File

@@ -43,8 +43,8 @@ func TestOptionsUpdate_Apply(t *testing.T) {
update := cluster.OptionsUpdate{}
update.SetClusterUUID("4a47c940-d5de-41b3-88a2-eb816cc659ca")
update.SetClusterCIDR("192.0.2.0/24")
update.SetDatabaseName("photoprism_cluster")
update.SetDatabaseUser("photoprism_user")
update.SetDatabaseName("cluster_database")
update.SetDatabaseUser("cluster_user")
written, err := clusternode.ApplyOptionsUpdate(conf, update)
require.NoError(t, err)
@@ -59,8 +59,8 @@ func TestOptionsUpdate_Apply(t *testing.T) {
assert.Equal(t, "value", merged["Existing"])
assert.Equal(t, "4a47c940-d5de-41b3-88a2-eb816cc659ca", merged["ClusterUUID"])
assert.Equal(t, "192.0.2.0/24", merged["ClusterCIDR"])
assert.Equal(t, "photoprism_cluster", merged["DatabaseName"])
assert.Equal(t, "photoprism_user", merged["DatabaseUser"])
assert.Equal(t, "cluster_database", merged["DatabaseName"])
assert.Equal(t, "cluster_user", merged["DatabaseUser"])
// Applying an empty update should be a no-op.
empty := cluster.OptionsUpdate{}

View File

@@ -31,18 +31,18 @@ The provisioner package manages per-node MariaDB schemas and users for cluster d
- Connect from the dev container using `mariadb` (already configured to reach `mariadb:4001`). Common snippets:
```bash
cat <<'SQL' | mariadb
SHOW DATABASES LIKE 'photoprism_d%';
SHOW DATABASES LIKE 'cluster_d%';
SQL
```
```bash
cat <<'SQL' | mariadb
SELECT User, Host FROM mysql.user WHERE User LIKE 'photoprism_u%';
SELECT User, Host FROM mysql.user WHERE User LIKE 'cluster_u%';
SQL
```
- Manually drop leftover resources when iterating outside tests:
```bash
for db in $(cat <<'SQL' | mariadb --batch --skip-column-names
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'photoprism_d%';
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'cluster_d%';
SQL
); do
printf 'DROP DATABASE IF EXISTS `%s`;\\n' "$db" | mariadb
@@ -50,7 +50,7 @@ The provisioner package manages per-node MariaDB schemas and users for cluster d
```
```bash
for user in $(cat <<'SQL' | mariadb --batch --skip-column-names
SELECT User FROM mysql.user WHERE User LIKE 'photoprism_u%';
SELECT User FROM mysql.user WHERE User LIKE 'cluster_u%';
SQL
); do
cat <<SQL | mariadb
@@ -64,7 +64,7 @@ The provisioner package manages per-node MariaDB schemas and users for cluster d
- Always pair credential creation with `DropCredentials` inside `t.Cleanup` for tests and defer blocks for ad-hoc scripts.
- When troubleshooting API or CLI flows, capture the node UUID and name from the response and call `GenerateCredentials` to identify which schema/user to drop once finished.
- Before committing, run `SHOW DATABASES LIKE 'photoprism_d%';` and `SELECT User FROM mysql.user WHERE User LIKE 'photoprism_u%';` to verify the MariaDB instance is clean.
- Before committing, run `SHOW DATABASES LIKE 'cluster_d%';` and `SELECT User FROM mysql.user WHERE User LIKE 'cluster_u%';` to verify the MariaDB instance is clean.
## Focused Commands

View File

@@ -15,7 +15,7 @@ import (
func TestQuoteIdent_Valid(t *testing.T) {
cases := []string{
"photoprism_db",
"cluster_db",
"my.db-1_2",
"a",
"z9-._",
@@ -54,9 +54,9 @@ func TestQuoteString_RejectsNUL(t *testing.T) {
}
func TestQuoteAccount(t *testing.T) {
got, err := quoteAccount("%", "photoprism_user")
got, err := quoteAccount("%", "cluster_user")
assert.NoError(t, err)
assert.Equal(t, "'photoprism_user'@'%'", got)
assert.Equal(t, "'cluster_user'@'%'", got)
}
func TestQuoteAccount_Errors(t *testing.T) {

View File

@@ -14,8 +14,8 @@ import (
const (
// Name prefix for generated DB objects.
// Final pattern without slugs (UUID-based):
// database: photoprism_d<hmac11>
// username: photoprism_u<hmac11>
// database: cluster_d<hmac11>
// username: cluster_u<hmac11>
prefix = "photoprism_"
dbSuffix = 11
userSuffix = 11

View File

@@ -115,7 +115,7 @@ func (r *ClientRegistry) Put(n *Node) error {
m.SetRole(n.Role)
}
// Ensure a default scope for node clients (instance/service) if none is set.
// Ensure a default scope for node clients (app/service) if none is set.
// Always include "vision"; this only permits access to Vision endpoints WHEN the Portal enables them.
if m.Scope() == "" {
role := m.AclRole().String()

View File

@@ -50,7 +50,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
if assert.NotNil(t, got) {
assert.Equal(t, "service", got.Role)
}
// Change to instance
// Change to app
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Role: cluster.RoleApp}}
assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-role")

View File

@@ -120,8 +120,8 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
n := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: cluster.RoleApp}}
db := n.ensureDatabase()
db.Name = "photoprism_d123"
db.User = "photoprism_u123"
db.Name = "cluster_d123"
db.User = "cluster_u123"
db.Driver = "mysql"
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
assert.NoError(t, r.Put(n))
@@ -129,8 +129,8 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
got, err := r.FindByNodeUUID(n.UUID)
assert.NoError(t, err)
if assert.NotNil(t, got) {
assert.Equal(t, "photoprism_d123", got.Database.Name)
assert.Equal(t, "photoprism_u123", got.Database.User)
assert.Equal(t, "cluster_d123", got.Database.Name)
assert.Equal(t, "cluster_u123", got.Database.User)
assert.Equal(t, "mysql", got.Database.Driver)
}
@@ -138,6 +138,6 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
dto := BuildClusterNode(*got, NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true})
if assert.NotNil(t, dto.Database) {
assert.Equal(t, "mysql", dto.Database.Driver)
assert.Equal(t, "photoprism_d123", dto.Database.Name)
assert.Equal(t, "cluster_d123", dto.Database.Name)
}
}

View File

@@ -7,7 +7,7 @@ import (
type NodeRole = string
const (
RoleApp = NodeRole(acl.RoleApp) // A regular PhotoPrism instance that can join a cluster
RoleApp = NodeRole(acl.RoleApp) // A regular PhotoPrism app node that can join a cluster
RolePortal = NodeRole(acl.RolePortal) // A management portal for orchestrating a cluster
RoleService = NodeRole(acl.RoleService) // Other service used within a cluster, e.g. Ollama or Vision API
)