diff --git a/.dockerignore b/.dockerignore index d70452c58..6b3fceaf7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -74,6 +74,7 @@ Thumbs.db .heartbeat .idea .codex +.config .local *~ .goutputstream* diff --git a/.gitignore b/.gitignore index cf7738be7..e0796c4a5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ Thumbs.db .glide .idea .codex +.config .local .project .vscode diff --git a/AGENTS.md b/AGENTS.md index b6e22ded0..e304f1ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, `photoprism_u`); `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`, `cluster_u`); `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. diff --git a/CODEMAP.md b/CODEMAP.md index 2b9f2082b..1028d11fc 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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 - UUID‑first 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` - - username: `photoprism_u` + - database: `cluster_d` + - username: `cluster_u` 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/.go`, add tests in `path/to/pkg/_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. diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go index ac9ea2031..997bf0dbd 100644 --- a/internal/api/metrics_test.go +++ b/internal/api/metrics_test.go @@ -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)}, } diff --git a/internal/config/config_cluster.go b/internal/config/config_cluster.go index da53803b9..e70af155e 100644 --- a/internal/config/config_cluster.go +++ b/internal/config/config_cluster.go @@ -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 diff --git a/internal/service/cluster/node/bootstrap_test.go b/internal/service/cluster/node/bootstrap_test.go index 9b5663f0d..29b4f7881 100644 --- a/internal/service/cluster/node/bootstrap_test.go +++ b/internal/service/cluster/node/bootstrap_test.go @@ -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)) } diff --git a/internal/service/cluster/node/node.go b/internal/service/cluster/node/node.go index 9bb91a499..207b5a798 100644 --- a/internal/service/cluster/node/node.go +++ b/internal/service/cluster/node/node.go @@ -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: diff --git a/internal/service/cluster/options_update_test.go b/internal/service/cluster/options_update_test.go index c6cf3d627..d81ce9d38 100644 --- a/internal/service/cluster/options_update_test.go +++ b/internal/service/cluster/options_update_test.go @@ -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{} diff --git a/internal/service/cluster/provisioner/README.md b/internal/service/cluster/provisioner/README.md index 2674b5467..3cc95742c 100644 --- a/internal/service/cluster/provisioner/README.md +++ b/internal/service/cluster/provisioner/README.md @@ -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 < - // username: photoprism_u + // database: cluster_d + // username: cluster_u prefix = "photoprism_" dbSuffix = 11 userSuffix = 11 diff --git a/internal/service/cluster/registry/client.go b/internal/service/cluster/registry/client.go index e5b086dd8..5c5c94bf2 100644 --- a/internal/service/cluster/registry/client.go +++ b/internal/service/cluster/registry/client.go @@ -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() diff --git a/internal/service/cluster/registry/client_more_test.go b/internal/service/cluster/registry/client_more_test.go index 59919fa05..aef39fb5e 100644 --- a/internal/service/cluster/registry/client_more_test.go +++ b/internal/service/cluster/registry/client_more_test.go @@ -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") diff --git a/internal/service/cluster/registry/registry_clientid_test.go b/internal/service/cluster/registry/registry_clientid_test.go index e29b69d34..9a8f3f828 100644 --- a/internal/service/cluster/registry/registry_clientid_test.go +++ b/internal/service/cluster/registry/registry_clientid_test.go @@ -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) } } diff --git a/internal/service/cluster/roles.go b/internal/service/cluster/roles.go index 0b7f4430d..5d3771930 100644 --- a/internal/service/cluster/roles.go +++ b/internal/service/cluster/roles.go @@ -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 )