diff --git a/AGENTS.md b/AGENTS.md index f7dd7cb1d..01ac626cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,13 +398,13 @@ Note: Across our public documentation, official images, and in production, the c - Admin session (full view): `AuthenticateAdmin(app, router)`. - User session: Create a non‑admin test user (role=guest), set a password, then `AuthenticateUser`. -- Client session (redacted internal fields; `siteUrl` visible): +- Client session (redacted internal fields; `SiteUrl` visible): ```go s, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil) token := s.AuthToken() r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token) ``` - Admins see `advertiseUrl` and `database`; client/user sessions don’t. `siteUrl` is safe to show to all roles. + Admins see `AdvertiseUrl` and `Database`; client/user sessions don’t. `SiteUrl` is safe to show to all roles. ### Preflight Checklist @@ -420,8 +420,8 @@ Note: Across our public documentation, official images, and in production, the c - Keep bootstrap code decoupled: avoid importing `internal/service/cluster/node/*` from `internal/config` or the cluster root, let nodes talk to the Portal over HTTP(S), and rely on constants from `internal/service/cluster/const.go`. - Config init order: load `options.yml` (`c.initSettings()`), run `EarlyExt().InitEarly(c)`, connect/register the DB, then invoke `Ext().Init(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/service/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`. +- 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. - 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 f5f8bc013..8c0fe2e20 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -184,7 +184,7 @@ Conventions & Rules of Thumb - Never log secrets; compare tokens constant‑time. - Don’t import Portal internals from cluster instance/service bootstraps; use HTTP. - Prefer small, hermetic unit tests; isolate filesystem paths with `t.TempDir()` and env like `PHOTOPRISM_STORAGE_PATH`. -- Cluster nodes: identify by UUID v7 (internally stored as `NodeUUID`; exposed as `uuid` in API/CLI). The OAuth client ID (`NodeClientID`, exposed as `clientId`) is for OAuth only. Registry lookups and CLI commands accept uuid, clientId, or DNS‑label name (priority in that order). +- Cluster nodes: identify by UUID v7 (internally stored as `NodeUUID`; exposed as `UUID` in API/CLI). The OAuth client ID (`NodeClientID`, exposed as `ClientID`) is for OAuth only. Registry lookups and CLI commands accept UUID, ClientID, or DNS-label name (priority in that order). Filesystem Permissions & io/fs Aliasing - Use `github.com/photoprism/photoprism/pkg/fs` permission variables when creating files/dirs: @@ -202,10 +202,10 @@ Cluster Registry & Provisioner Cheatsheet - 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. - Public API and internal registry DTOs use normalized field names: - - `database` (not `db`) with `name`, `user`, `driver`, `rotatedAt`. - - Node-level rotation timestamps use `rotatedAt`. - - Registration returns `secrets.clientSecret`; the CLI persists it under config `NodeClientSecret`. - - Admin responses may include `advertiseUrl` and `database`; non-admin responses are redacted by default. + - `Database` (not `db`) with `Name`, `User`, `Driver`, `RotatedAt`. + - Node-level rotation timestamps use `RotatedAt`. + - Registration returns `Secrets.ClientSecret`; the CLI persists it under config `NodeClientSecret`. + - Admin responses may include `AdvertiseUrl` and `Database`; non-admin responses are redacted by default. Frequently Touched Files (by topic) - CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go` diff --git a/frontend/tests/vitest/model/cluster-node.test.js b/frontend/tests/vitest/model/cluster-node.test.js index 6cb903d6c..687294f72 100644 --- a/frontend/tests/vitest/model/cluster-node.test.js +++ b/frontend/tests/vitest/model/cluster-node.test.js @@ -33,13 +33,12 @@ describe("pro/portal/model/cluster-node", () => { it("reports database metadata availability", () => { const node = new ClusterNode({ Database: { - name: "photoprism", - user: "photoprism", - driver: "mysql", + Name: "photoprism", + User: "photoprism", + Driver: "mysql", }, }); expect(node.hasDatabase()).toBe(true); }); }); - diff --git a/internal/api/cluster_metrics_test.go b/internal/api/cluster_metrics_test.go index 3a5081e10..772600f41 100644 --- a/internal/api/cluster_metrics_test.go +++ b/internal/api/cluster_metrics_test.go @@ -22,6 +22,6 @@ func TestClusterMetrics_EmptyCounts(t *testing.T) { assert.Equal(t, http.StatusOK, resp.Code) body := resp.Body.String() - assert.Equal(t, "192.0.2.0/24", gjson.Get(body, "clusterCidr").String()) - assert.Equal(t, int64(0), gjson.Get(body, "nodes.total").Int()) + assert.Equal(t, "192.0.2.0/24", gjson.Get(body, "ClusterCIDR").String()) + assert.Equal(t, int64(0), gjson.Get(body, "Nodes.total").Int()) } diff --git a/internal/api/cluster_nodes.go b/internal/api/cluster_nodes.go index 541e4540f..ff014d8d2 100644 --- a/internal/api/cluster_nodes.go +++ b/internal/api/cluster_nodes.go @@ -172,7 +172,7 @@ func ClusterGetNode(router *gin.RouterGroup) { }) } -// ClusterUpdateNode updates mutable fields: role, labels, advertiseUrl. +// ClusterUpdateNode updates mutable fields: role, labels, AdvertiseUrl. // // @Summary update node fields // @Id ClusterUpdateNode @@ -180,7 +180,7 @@ func ClusterGetNode(router *gin.RouterGroup) { // @Accept json // @Produce json // @Param uuid path string true "node uuid" -// @Param node body object true "properties to update (role, labels, advertiseUrl, siteUrl)" +// @Param node body object true "properties to update (Role, Labels, AdvertiseUrl, SiteUrl)" // @Success 200 {object} cluster.StatusResponse // @Failure 400,401,403,404,429 {object} i18n.Response // @Router /api/v1/cluster/nodes/{uuid} [patch] @@ -202,10 +202,10 @@ func ClusterUpdateNode(router *gin.RouterGroup) { uuid := c.Param("uuid") var req struct { - Role string `json:"role"` - Labels map[string]string `json:"labels"` - AdvertiseUrl string `json:"advertiseUrl"` - SiteUrl string `json:"siteUrl"` + Role string `json:"Role"` + Labels map[string]string `json:"Labels"` + AdvertiseUrl string `json:"AdvertiseUrl"` + SiteUrl string `json:"SiteUrl"` } if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/api/cluster_nodes_redaction_test.go b/internal/api/cluster_nodes_redaction_test.go index 67189f605..7de8e2d0d 100644 --- a/internal/api/cluster_nodes_redaction_test.go +++ b/internal/api/cluster_nodes_redaction_test.go @@ -34,15 +34,15 @@ func TestClusterListNodes_Redaction(t *testing.T) { tokenAdmin := AuthenticateAdmin(app, router) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", tokenAdmin) assert.Equal(t, http.StatusOK, r.Code) - // First item should include advertiseUrl and database for admins - assert.NotEqual(t, "", gjson.Get(r.Body.String(), "0.advertiseUrl").String()) - assert.True(t, gjson.Get(r.Body.String(), "0.database").Exists()) + // First item should include AdvertiseUrl and Database for admins + assert.NotEqual(t, "", gjson.Get(r.Body.String(), "0.AdvertiseUrl").String()) + assert.True(t, gjson.Get(r.Body.String(), "0.Database").Exists()) } // Verifies redaction for client-scoped sessions (no user attached). func TestClusterListNodes_Redaction_ClientScope(t *testing.T) { // TODO: This test expects client-scoped sessions to receive redacted - // fields (no advertiseUrl/database). In practice, advertiseUrl appears + // fields (no AdvertiseUrl/Database). In practice, AdvertiseUrl appears // in the response, likely due to session/ACL interactions in the test // harness. Skipping for now; admin redaction coverage is in a separate // test, and server-side opts are implemented. Revisit when signal/DB @@ -68,8 +68,8 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) { r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token) assert.Equal(t, http.StatusOK, r.Code) - // Redacted: advertiseUrl and database omitted for client sessions; siteUrl is visible. - assert.Equal(t, "", gjson.Get(r.Body.String(), "0.advertiseUrl").String()) - assert.True(t, gjson.Get(r.Body.String(), "0.siteUrl").Exists()) - assert.False(t, gjson.Get(r.Body.String(), "0.database").Exists()) + // Redacted: AdvertiseUrl and Database omitted for client sessions; SiteUrl is visible. + assert.Equal(t, "", gjson.Get(r.Body.String(), "0.AdvertiseUrl").String()) + assert.True(t, gjson.Get(r.Body.String(), "0.SiteUrl").Exists()) + assert.False(t, gjson.Get(r.Body.String(), "0.Database").Exists()) } diff --git a/internal/api/cluster_nodes_register.go b/internal/api/cluster_nodes_register.go index b5f8681e0..e8a5aacc0 100644 --- a/internal/api/cluster_nodes_register.go +++ b/internal/api/cluster_nodes_register.go @@ -28,12 +28,12 @@ var RegisterRequireClientSecret = true // ClusterNodesRegister registers the Portal-only node registration endpoint. // -// @Summary registers a node, provisions DB credentials, and issues clientSecret +// @Summary registers a node, provisions DB credentials, and issues ClientSecret // @Id ClusterNodesRegister // @Tags Cluster // @Accept json // @Produce json -// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl; to authorize UUID/name changes include clientId+clientSecret; rotation: rotateDatabase, rotateSecret)" +// @Param request body object true "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)" // @Success 200,201 {object} cluster.RegisterResponse // @Failure 400,401,403,409,429 {object} i18n.Response // @Router /api/v1/cluster/nodes/register [post] diff --git a/internal/api/cluster_nodes_register_test.go b/internal/api/cluster_nodes_register_test.go index 7673d5902..b2ad69c0c 100644 --- a/internal/api/cluster_nodes_register_test.go +++ b/internal/api/cluster_nodes_register_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service/cluster" @@ -23,11 +24,11 @@ func TestClusterNodesRegister(t *testing.T) { conf.Options().NodeRole = cluster.RoleInstance ClusterNodesRegister(router) - r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`) assert.Equal(t, http.StatusForbidden, r.Code) }) - // Register with existing ClientID requires clientSecret + // Register with existing ClientID requires ClientSecret t.Run("ExistingClientRequiresSecret", func(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal @@ -44,17 +45,17 @@ func TestClusterNodesRegister(t *testing.T) { secret := nr.ClientSecret // Missing secret → 401 - body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}` + body := `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `"}` r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken) assert.Equal(t, http.StatusUnauthorized, r.Code) // Wrong secret → 401 - body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}` + body = `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `","ClientSecret":"WRONG"}` r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken) assert.Equal(t, http.StatusUnauthorized, r.Code) // Correct secret → 200 (existing-node path) - body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}` + body = `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `","ClientSecret":"` + secret + `"}` r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken) assert.Equal(t, http.StatusOK, r.Code) cleanupRegisterProvisioning(t, conf, r) @@ -64,7 +65,7 @@ func TestClusterNodesRegister(t *testing.T) { conf.Options().NodeRole = cluster.RolePortal ClusterNodesRegister(router) - r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`) assert.Equal(t, http.StatusUnauthorized, r.Code) }) t.Run("CreateNodeSucceedsWithProvisioner", func(t *testing.T) { @@ -75,13 +76,13 @@ func TestClusterNodesRegister(t *testing.T) { // Provisioner is independent of the main DB; with MariaDB admin DSN configured // it should successfully provision and return 201. - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) body := r.Body.String() - assert.Contains(t, body, "\"database\"") - assert.Contains(t, body, "\"secrets\"") + assert.Contains(t, body, "\"Database\"") + assert.Contains(t, body, "\"Secrets\"") // New nodes return the client secret; include alias for clarity. - assert.Contains(t, body, "\"clientSecret\"") + assert.Contains(t, body, "\"ClientSecret\"") cleanupRegisterProvisioning(t, conf, r) }) t.Run("UUIDChangeRequiresSecret", func(t *testing.T) { @@ -99,7 +100,7 @@ func TestClusterNodesRegister(t *testing.T) { // Attempt to change UUID via name without client credentials → 409 newUUID := rnd.UUIDv7() - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-lock","NodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusConflict, r.Code) }) t.Run("BadAdvertiseUrlRejected", func(t *testing.T) { @@ -109,7 +110,7 @@ func TestClusterNodesRegister(t *testing.T) { ClusterNodesRegister(router) // http scheme for public host must be rejected (require https unless localhost). - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-03","AdvertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusBadRequest, r.Code) }) t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) { @@ -119,12 +120,12 @@ func TestClusterNodesRegister(t *testing.T) { ClusterNodesRegister(router) // https is allowed for public host - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-04","AdvertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) cleanupRegisterProvisioning(t, conf, r) // http is allowed for localhost - r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken) + r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-04b","AdvertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) cleanupRegisterProvisioning(t, conf, r) }) @@ -134,12 +135,12 @@ func TestClusterNodesRegister(t *testing.T) { conf.Options().JoinToken = cluster.ExampleJoinToken ClusterNodesRegister(router) - // Reject http siteUrl for public host - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, cluster.ExampleJoinToken) + // Reject http SiteUrl for public host + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-05","SiteUrl":"http://example.com"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusBadRequest, r.Code) - // Accept https siteUrl - r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken) + // Accept https SiteUrl + r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-06","SiteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) cleanupRegisterProvisioning(t, conf, r) }) @@ -150,7 +151,7 @@ func TestClusterNodesRegister(t *testing.T) { ClusterNodesRegister(router) // Mixed separators and case should normalize to DNS label - body := `{"nodeName":"My.Node/Name:Prod"}` + body := `{"NodeName":"My.Node/Name:Prod"}` r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) cleanupRegisterProvisioning(t, conf, r) @@ -170,7 +171,7 @@ func TestClusterNodesRegister(t *testing.T) { ClusterNodesRegister(router) // Empty nodeName → 400 - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":""}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusBadRequest, r.Code) }) t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) { @@ -188,7 +189,7 @@ func TestClusterNodesRegister(t *testing.T) { n := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}} assert.NoError(t, regy.Put(n)) - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01","RotateSecret":true}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusOK, r.Code) cleanupRegisterProvisioning(t, conf, r) @@ -213,11 +214,11 @@ func TestClusterNodesRegister(t *testing.T) { assert.NoError(t, regy.Put(n)) // Provisioner is independent; endpoint should respond 200 and persist metadata. - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-02","SiteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusOK, r.Code) cleanupRegisterProvisioning(t, conf, r) - // Ensure normalized/persisted siteUrl. + // Ensure normalized/persisted SiteUrl. n2, err := regy.FindByName("pp-node-02") assert.NoError(t, err) assert.Equal(t, "https://photos.example.com", n2.SiteUrl) @@ -229,13 +230,13 @@ func TestClusterNodesRegister(t *testing.T) { ClusterNodesRegister(router) // Register without nodeUUID; server should assign one (UUID v7 preferred). - r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken) + r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken) assert.Equal(t, http.StatusCreated, r.Code) cleanupRegisterProvisioning(t, conf, r) - // Response must include node.uuid + // Response must include Node.UUID body := r.Body.String() - assert.Contains(t, body, "\"uuid\"") + assert.NotEmpty(t, gjson.Get(body, "Node.UUID").String()) // Verify it is persisted in the registry regy, err := reg.NewClientRegistryWithConfig(conf) diff --git a/internal/api/cluster_nodes_test.go b/internal/api/cluster_nodes_test.go index ae07b99b3..2a667f68a 100644 --- a/internal/api/cluster_nodes_test.go +++ b/internal/api/cluster_nodes_test.go @@ -47,7 +47,7 @@ func TestClusterEndpoints(t *testing.T) { assert.Equal(t, http.StatusNotFound, r.Code) // Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here) - r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"advertiseUrl":"http://n1:2342"}`) + r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"AdvertiseUrl":"http://n1:2342"}`) assert.Equal(t, http.StatusOK, r.Code) // Pagination: count=1 returns exactly one diff --git a/internal/api/cluster_nodes_update_siteurl_test.go b/internal/api/cluster_nodes_update_siteurl_test.go index 25cb9b0fe..6556db51a 100644 --- a/internal/api/cluster_nodes_update_siteurl_test.go +++ b/internal/api/cluster_nodes_update_siteurl_test.go @@ -11,7 +11,7 @@ import ( "github.com/photoprism/photoprism/pkg/rnd" ) -// Verifies that PATCH /cluster/nodes/{uuid} normalizes/validates siteUrl and persists only when valid. +// Verifies that PATCH /cluster/nodes/{uuid} normalizes/validates SiteUrl and persists only when valid. func TestClusterUpdateNode_SiteUrl(t *testing.T) { app, router, conf := NewApiTest() conf.Options().NodeRole = cluster.RolePortal @@ -29,14 +29,14 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) { assert.NoError(t, err) // Invalid scheme: ignored (200 OK but no update) - r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"ftp://invalid"}`) + r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"SiteUrl":"ftp://invalid"}`) assert.Equal(t, http.StatusOK, r.Code) n2, err := regy.FindByNodeUUID(n.UUID) assert.NoError(t, err) assert.Equal(t, "", n2.SiteUrl) // Valid https URL: persisted and normalized - r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"siteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`) + r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, `{"SiteUrl":"HTTPS://PHOTOS.EXAMPLE.COM"}`) assert.Equal(t, http.StatusOK, r.Code) n3, err := regy.FindByNodeUUID(n.UUID) assert.NoError(t, err) diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 600bb3b53..052232c2c 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -312,13 +312,13 @@ }, "cluster.DatabaseInfo": { "properties": { - "driver": { + "Driver": { "type": "string" }, - "host": { + "Host": { "type": "string" }, - "port": { + "Port": { "type": "integer" } }, @@ -326,19 +326,19 @@ }, "cluster.MetricsResponse": { "properties": { - "clusterCidr": { + "ClusterCIDR": { "type": "string" }, - "nodes": { + "Nodes": { "additionalProperties": { "type": "integer" }, "type": "object" }, - "time": { + "Time": { "type": "string" }, - "uuid": { + "UUID": { "type": "string" } }, @@ -346,57 +346,57 @@ }, "cluster.Node": { "properties": { - "advertiseUrl": { + "AdvertiseUrl": { "type": "string" }, - "clientId": { + "ClientID": { "type": "string" }, - "createdAt": { + "CreatedAt": { "type": "string" }, - "database": { + "Database": { "$ref": "#/definitions/cluster.NodeDatabase" }, - "labels": { + "Labels": { "additionalProperties": { "type": "string" }, "type": "object" }, - "name": { + "Name": { "description": "NodeName", "type": "string" }, - "role": { + "Role": { "description": "NodeRole", "type": "string" }, - "siteUrl": { + "SiteUrl": { "type": "string" }, - "updatedAt": { - "type": "string" - }, - "uuid": { + "UUID": { "description": "NodeUUID", "type": "string" + }, + "UpdatedAt": { + "type": "string" } }, "type": "object" }, "cluster.NodeDatabase": { "properties": { - "driver": { + "Driver": { "type": "string" }, - "name": { + "Name": { "type": "string" }, - "rotatedAt": { + "RotatedAt": { "type": "string" }, - "user": { + "User": { "type": "string" } }, @@ -404,28 +404,28 @@ }, "cluster.RegisterDatabase": { "properties": { - "driver": { + "DSN": { "type": "string" }, - "dsn": { + "Driver": { "type": "string" }, - "host": { + "Host": { "type": "string" }, - "name": { + "Name": { "type": "string" }, - "password": { + "Password": { "type": "string" }, - "port": { + "Port": { "type": "integer" }, - "rotatedAt": { + "RotatedAt": { "type": "string" }, - "user": { + "User": { "type": "string" } }, @@ -433,28 +433,28 @@ }, "cluster.RegisterResponse": { "properties": { - "alreadyProvisioned": { + "AlreadyProvisioned": { "type": "boolean" }, - "alreadyRegistered": { + "AlreadyRegistered": { "type": "boolean" }, - "clusterCidr": { + "ClusterCIDR": { "type": "string" }, - "database": { + "Database": { "$ref": "#/definitions/cluster.RegisterDatabase" }, - "jwksUrl": { + "JWKSUrl": { "type": "string" }, - "node": { + "Node": { "$ref": "#/definitions/cluster.Node" }, - "secrets": { + "Secrets": { "$ref": "#/definitions/cluster.RegisterSecrets" }, - "uuid": { + "UUID": { "description": "ClusterUUID", "type": "string" } @@ -463,10 +463,10 @@ }, "cluster.RegisterSecrets": { "properties": { - "clientSecret": { + "ClientSecret": { "type": "string" }, - "rotatedAt": { + "RotatedAt": { "type": "string" } }, @@ -474,7 +474,7 @@ }, "cluster.StatusResponse": { "properties": { - "status": { + "Status": { "type": "string" } }, @@ -482,19 +482,19 @@ }, "cluster.SummaryResponse": { "properties": { - "clusterCidr": { + "ClusterCIDR": { "type": "string" }, - "database": { + "Database": { "$ref": "#/definitions/cluster.DatabaseInfo" }, - "nodes": { + "Nodes": { "type": "integer" }, - "time": { + "Time": { "type": "string" }, - "uuid": { + "UUID": { "description": "ClusterUUID", "type": "string" } @@ -6644,7 +6644,7 @@ "operationId": "ClusterNodesRegister", "parameters": [ { - "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl; to authorize UUID/name changes include clientId+clientSecret; rotation: rotateDatabase, rotateSecret)", + "description": "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)", "in": "body", "name": "request", "required": true, @@ -6700,7 +6700,7 @@ } } }, - "summary": "registers a node, provisions DB credentials, and issues clientSecret", + "summary": "registers a node, provisions DB credentials, and issues ClientSecret", "tags": [ "Cluster" ] @@ -6823,7 +6823,7 @@ "type": "string" }, { - "description": "properties to update (role, labels, advertiseUrl, siteUrl)", + "description": "properties to update (Role, Labels, AdvertiseUrl, SiteUrl)", "in": "body", "name": "node", "required": true, diff --git a/internal/commands/auth_jwt_issue.go b/internal/commands/auth_jwt_issue.go index e7d2481c9..a9f093632 100644 --- a/internal/commands/auth_jwt_issue.go +++ b/internal/commands/auth_jwt_issue.go @@ -81,18 +81,29 @@ func authJWTIssueAction(ctx *cli.Context) error { } if ctx.Bool("json") { - payload := map[string]any{ - "token": token, - "header": header, - "claims": claims, - "node": map[string]string{ - "uuid": node.UUID, - "clientId": node.ClientID, - "name": node.Name, - "role": string(node.Role), + type nodePayload struct { + UUID string `json:"UUID"` + ClientID string `json:"ClientID"` + Name string `json:"Name"` + Role string `json:"Role"` + } + response := struct { + Token string `json:"Token"` + Header map[string]any `json:"Header"` + Claims *clusterjwt.Claims `json:"Claims"` + Node nodePayload `json:"Node"` + }{ + Token: token, + Header: header, + Claims: claims, + Node: nodePayload{ + UUID: node.UUID, + ClientID: node.ClientID, + Name: node.Name, + Role: string(node.Role), }, } - return printJSON(payload) + return printJSON(response) } expires := "unknown" diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go index f48a68acc..97d9a8a56 100644 --- a/internal/commands/cluster_register.go +++ b/internal/commands/cluster_register.go @@ -14,10 +14,10 @@ import ( "time" "github.com/urfave/cli/v2" - "gopkg.in/yaml.v2" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service/cluster" + clusternode "github.com/photoprism/photoprism/internal/service/cluster/node" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" @@ -124,7 +124,10 @@ func clusterRegisterAction(ctx *cli.Context) error { // In dry-run, we allow empty portalURL (will print derived/empty values). if ctx.Bool("dry-run") { if ctx.Bool("json") { - out := map[string]any{"portalUrl": portalURL, "payload": payload} + out := struct { + PortalURL string `json:"PortalUrl"` + Payload cluster.RegisterRequest `json:"Payload"` + }{PortalURL: portalURL, Payload: payload} jb, _ := json.Marshal(out) fmt.Println(string(jb)) } else { @@ -343,14 +346,14 @@ func parseLabelSlice(labels []string) map[string]string { // Persistence helpers for --write-config func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error { - updates := map[string]any{} + updates := cluster.OptionsUpdate{} if rnd.IsUUID(resp.UUID) { - updates["ClusterUUID"] = resp.UUID + updates.SetClusterUUID(resp.UUID) } if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" { - updates["ClusterCIDR"] = cidr + updates.SetClusterCIDR(cidr) } // Node client secret file @@ -371,43 +374,18 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse // DB settings (MySQL/MariaDB only) if resp.Database.Name != "" && resp.Database.User != "" { - updates["DatabaseDriver"] = config.MySQL - updates["DatabaseName"] = resp.Database.Name - updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port) - updates["DatabaseUser"] = resp.Database.User - updates["DatabasePassword"] = resp.Database.Password + updates.SetDatabaseDriver(config.MySQL) + updates.SetDatabaseName(resp.Database.Name) + updates.SetDatabaseServer(fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port)) + updates.SetDatabaseUser(resp.Database.User) + updates.SetDatabasePassword(resp.Database.Password) } - if len(updates) > 0 { - if err := mergeOptionsYaml(conf, updates); err != nil { + if !updates.IsZero() { + if _, err := clusternode.ApplyOptionsUpdate(conf, updates); err != nil { return err } log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name)) } return nil } - -func mergeOptionsYaml(conf *config.Config, kv map[string]any) error { - fileName := conf.OptionsYaml() - if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil { - return err - } - - var m map[string]any - if fs.FileExists(fileName) { - if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { - _ = yaml.Unmarshal(b, &m) - } - } - if m == nil { - m = map[string]any{} - } - for k, v := range kv { - m[k] = v - } - b, err := yaml.Marshal(m) - if err != nil { - return err - } - return os.WriteFile(fileName, b, fs.ModeFile) -} diff --git a/internal/commands/cluster_register_http_test.go b/internal/commands/cluster_register_http_test.go index 9df9af94e..ee3e018c6 100644 --- a/internal/commands/cluster_register_http_test.go +++ b/internal/commands/cluster_register_http_test.go @@ -30,13 +30,31 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "secrets": map[string]any{"clientSecret": cluster.ExampleClientSecret, "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": false, - "alreadyProvisioned": false, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n1", + Name: "pp-node-02", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwd", + DSN: "user:pwd@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + Secrets: &cluster.RegisterSecrets{ + ClientSecret: cluster.ExampleClientSecret, + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: false, + AlreadyProvisioned: false, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -45,10 +63,10 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) { }) assert.NoError(t, err) // Parse JSON - assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String()) - assert.Equal(t, cluster.ExampleClientSecret, gjson.Get(out, "secrets.clientSecret").String()) - assert.Equal(t, "pwd", gjson.Get(out, "database.password").String()) - dsn := gjson.Get(out, "database.dsn").String() + assert.Equal(t, "pp-node-02", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, cluster.ExampleClientSecret, gjson.Get(out, "Secrets.ClientSecret").String()) + assert.Equal(t, "pwd", gjson.Get(out, "Database.Password").String()) + dsn := gjson.Get(out, "Database.DSN").String() parsed := cfg.NewDSN(dsn) assert.Equal(t, "user", parsed.User) assert.Equal(t, "pwd", parsed.Password) @@ -71,13 +89,31 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n1", + Name: "pp-node-03", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwd2", + DSN: "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + Secrets: &cluster.RegisterSecrets{ + ClientSecret: secret, + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -110,13 +146,31 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n2", + Name: "pp-node-04", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwd3", + DSN: "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + Secrets: &cluster.RegisterSecrets{ + ClientSecret: secret, + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -130,10 +184,10 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) { "rotate", "--json", "--db", "--secret", "--yes", "pp-node-04", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String()) - assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String()) - assert.Equal(t, "pwd3", gjson.Get(out, "database.password").String()) - dsn := gjson.Get(out, "database.dsn").String() + assert.Equal(t, "pp-node-04", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, secret, gjson.Get(out, "Secrets.ClientSecret").String()) + assert.Equal(t, "pwd3", gjson.Get(out, "Database.Password").String()) + dsn := gjson.Get(out, "Database.DSN").String() parsed := cfg.NewDSN(dsn) assert.Equal(t, "user", parsed.User) assert.Equal(t, "pwd3", parsed.Password) @@ -154,8 +208,8 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) { } // Read payload to assert rotate flags b, _ := io.ReadAll(r.Body) - rotate := gjson.GetBytes(b, "rotateDatabase").Bool() - rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() + rotate := gjson.GetBytes(b, "RotateDatabase").Bool() + rotateSecret := gjson.GetBytes(b, "RotateSecret").Bool() // Expect DB rotation only if !rotate || rotateSecret { w.WriteHeader(http.StatusBadRequest) @@ -163,13 +217,27 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n3", "name": "pp-node-05", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - // secrets omitted on DB-only rotate - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n3", + Name: "pp-node-05", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwd4", + DSN: "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -183,16 +251,16 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) { "rotate", "--json", "--db", "--yes", "pp-node-05", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-05", gjson.Get(out, "node.name").String()) - assert.Equal(t, "pwd4", gjson.Get(out, "database.password").String()) - dsn := gjson.Get(out, "database.dsn").String() + assert.Equal(t, "pp-node-05", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, "pwd4", gjson.Get(out, "Database.Password").String()) + dsn := gjson.Get(out, "Database.DSN").String() parsed := cfg.NewDSN(dsn) assert.Equal(t, "pp_user", parsed.User) assert.Equal(t, "pwd4", parsed.Password) assert.Equal(t, "tcp", parsed.Net) assert.Equal(t, "db:3306", parsed.Server) assert.Equal(t, "pp_db", parsed.Name) - assert.Equal(t, "", gjson.Get(out, "secrets.clientSecret").String()) + assert.Equal(t, "", gjson.Get(out, "Secrets.ClientSecret").String()) } func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) { @@ -207,8 +275,8 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) { return } b, _ := io.ReadAll(r.Body) - rotate := gjson.GetBytes(b, "rotateDatabase").Bool() - rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() + rotate := gjson.GetBytes(b, "RotateDatabase").Bool() + rotateSecret := gjson.GetBytes(b, "RotateSecret").Bool() // Expect secret-only rotation if rotate || !rotateSecret { w.WriteHeader(http.StatusBadRequest) @@ -216,13 +284,29 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"}, - "secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n4", + Name: "pp-node-06", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + RotatedAt: "2025-09-15T00:00:00Z", + }, + Secrets: &cluster.RegisterSecrets{ + ClientSecret: secret, + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -234,9 +318,9 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) { "rotate", "--json", "--secret", "--yes", "pp-node-06", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String()) - assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String()) - assert.Equal(t, "", gjson.Get(out, "database.password").String()) + assert.Equal(t, "pp-node-06", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, secret, gjson.Get(out, "Secrets.ClientSecret").String()) + assert.Equal(t, "", gjson.Get(out, "Database.Password").String()) } func TestClusterRegister_HTTPUnauthorized(t *testing.T) { @@ -278,14 +362,14 @@ func TestClusterRegister_DryRun_JSON(t *testing.T) { out, err := RunWithTestContext(ClusterRegisterCommand, []string{ "register", "--dry-run", "--json", }) - // Should not fail; output must include portalUrl and payload + // Should not fail; output must include PortalUrl and payload if err != nil { t.Fatalf("unexpected error: %v", err) } - assert.NotEmpty(t, gjson.Get(out, "portalUrl").String()) - assert.Equal(t, "instance", gjson.Get(out, "payload.nodeRole").String()) - // nodeName may be derived; ensure non-empty - assert.NotEmpty(t, gjson.Get(out, "payload.nodeName").String()) + assert.NotEmpty(t, gjson.Get(out, "PortalUrl").String()) + assert.Equal(t, "instance", gjson.Get(out, "Payload.NodeRole").String()) + // NodeName may be derived; ensure non-empty + assert.NotEmpty(t, gjson.Get(out, "Payload.NodeName").String()) } func TestClusterRegister_DryRun_Text(t *testing.T) { @@ -325,12 +409,27 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n7", "name": "pp-node-rl", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n7", + Name: "pp-node-rl", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwdrl", + DSN: "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -338,7 +437,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) { "register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String()) + assert.Equal(t, "pp-node-rl", gjson.Get(out, "Node.Name").String()) } func TestClusterNodesRotate_HTTPUnauthorized_JSON(t *testing.T) { @@ -399,12 +498,27 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n8", "name": "pp-node-rl2", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n8", + Name: "pp-node-rl2", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwdrl2", + DSN: "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -412,7 +526,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) { "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-rl2", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String()) + assert.Equal(t, "pp-node-rl2", gjson.Get(out, "Node.Name").String()) } func TestClusterRegister_RotateDatabase_JSON(t *testing.T) { @@ -426,18 +540,33 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) { return } b, _ := io.ReadAll(r.Body) - if !gjson.GetBytes(b, "rotateDatabase").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() { + if !gjson.GetBytes(b, "RotateDatabase").Bool() || gjson.GetBytes(b, "RotateSecret").Bool() { w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n5", "name": "pp-node-07", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n5", + Name: "pp-node-07", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + Password: "pwd7", + DSN: "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -445,9 +574,9 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) { "register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String()) - assert.Equal(t, "pwd7", gjson.Get(out, "database.password").String()) - dsn := gjson.Get(out, "database.dsn").String() + assert.Equal(t, "pp-node-07", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, "pwd7", gjson.Get(out, "Database.Password").String()) + dsn := gjson.Get(out, "Database.DSN").String() parsed := cfg.NewDSN(dsn) assert.Equal(t, "pp_user", parsed.User) assert.Equal(t, "pwd7", parsed.Password) @@ -468,19 +597,35 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) { return } b, _ := io.ReadAll(r.Body) - if gjson.GetBytes(b, "rotateDatabase").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() { + if gjson.GetBytes(b, "RotateDatabase").Bool() || !gjson.GetBytes(b, "RotateSecret").Bool() { w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ - "node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, - "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"}, - "secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, - "alreadyRegistered": true, - "alreadyProvisioned": true, - }) + resp := cluster.RegisterResponse{ + Node: cluster.Node{ + UUID: "n6", + Name: "pp-node-08", + Role: "instance", + CreatedAt: "2025-09-15T00:00:00Z", + UpdatedAt: "2025-09-15T00:00:00Z", + }, + Database: cluster.RegisterDatabase{ + Host: "database", + Port: 3306, + Name: "pp_db", + User: "pp_user", + RotatedAt: "2025-09-15T00:00:00Z", + }, + Secrets: &cluster.RegisterSecrets{ + ClientSecret: secret, + RotatedAt: "2025-09-15T00:00:00Z", + }, + AlreadyRegistered: true, + AlreadyProvisioned: true, + } + _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() @@ -488,7 +633,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) { "register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate-secret", "--json", }) assert.NoError(t, err) - assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String()) - assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String()) - assert.Equal(t, "", gjson.Get(out, "database.password").String()) + assert.Equal(t, "pp-node-08", gjson.Get(out, "Node.Name").String()) + assert.Equal(t, secret, gjson.Get(out, "Secrets.ClientSecret").String()) + assert.Equal(t, "", gjson.Get(out, "Database.Password").String()) } diff --git a/internal/service/cluster/node/bootstrap.go b/internal/service/cluster/node/bootstrap.go index a592bc7c2..63948925c 100644 --- a/internal/service/cluster/node/bootstrap.go +++ b/internal/service/cluster/node/bootstrap.go @@ -17,8 +17,6 @@ import ( "time" "unicode/utf8" - "gopkg.in/yaml.v2" - clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" @@ -30,9 +28,8 @@ import ( var log = event.Log -// Values is an shorthand alias for map[string]interface{}. -type Values = map[string]interface{} - +// init registers the cluster node bootstrap extension so it runs before the +// database connection is established. func init() { // Register early so this can adjust DB settings before connectDb(). config.RegisterEarly("cluster-node", InitConfig, nil) @@ -98,6 +95,8 @@ func InitConfig(c *config.Config) error { return nil } +// isLocalHost reports whether the given host string refers to a local loopback +// address that may safely use plain HTTP during bootstrap. func isLocalHost(h string) bool { switch strings.ToLower(h) { case "localhost", "127.0.0.1", "::1": @@ -109,6 +108,9 @@ func isLocalHost(h string) bool { } } +// newHTTPClient returns a short-lived HTTP client configured with the provided +// timeout. It is intentionally lightweight to avoid leaking transports between +// bootstrap attempts. func newHTTPClient(timeout time.Duration) *http.Client { // TODO: Consider reusing a shared *http.Transport with sane defaults and enabling // proxy support explicitly if required. For now, rely on net/http defaults and @@ -116,6 +118,9 @@ func newHTTPClient(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout} } +// registerWithPortal attempts to register the node with the Portal, retrying on +// transient errors up to the configured limits. Successful registrations update +// local configuration and prime JWKS credentials. func registerWithPortal(c *config.Config, portal *url.URL, token string) error { maxAttempts := cluster.BootstrapRegisterMaxAttempts delay := cluster.BootstrapRegisterRetryDelay @@ -145,7 +150,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error { payload.ClientSecret = secret } - // Include siteUrl when it differs from advertiseUrl; server will validate/normalize. + // Include SiteUrl when it differs from AdvertiseUrl; server will validate/normalize. if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() { payload.SiteUrl = su } @@ -219,79 +224,90 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error { return nil } +// isTemporary reports whether the given error represents a temporary network +// failure that merits another retry attempt. func isTemporary(err error) bool { var nerr net.Error return errors.As(err, &nerr) && nerr.Timeout() } +// persistRegistration merges registration responses into options.yml and, when +// necessary, reloads the in-memory configuration so future bootstrap steps use +// the updated values. func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error { - updates := Values{} + updates := cluster.OptionsUpdate{} // Persist ClusterUUID from portal response if provided. if rnd.IsUUID(r.UUID) { - updates["ClusterUUID"] = r.UUID + updates.SetClusterUUID(r.UUID) } if cidr := strings.TrimSpace(r.ClusterCIDR); cidr != "" { - updates["ClusterCIDR"] = cidr + updates.SetClusterCIDR(cidr) } // Always persist NodeClientID (client UID) from response for future OAuth token requests. if r.Node.ClientID != "" { - updates["NodeClientID"] = r.Node.ClientID + updates.SetNodeClientID(r.Node.ClientID) } // Persist node client secret only if missing locally and provided by server. if r.Secrets != nil && r.Secrets.ClientSecret != "" && c.NodeClientSecret() == "" { - updates["NodeClientSecret"] = r.Secrets.ClientSecret + updates.SetNodeClientSecret(r.Secrets.ClientSecret) } if jwksUrl := strings.TrimSpace(r.JWKSUrl); jwksUrl != "" { - updates["JWKSUrl"] = jwksUrl + updates.SetJWKSUrl(jwksUrl) c.SetJWKSUrl(jwksUrl) } // Persist NodeUUID from portal response if provided and not set locally. if r.Node.UUID != "" && c.NodeUUID() == "" { - updates["NodeUUID"] = r.Node.UUID + updates.SetNodeUUID(r.Node.UUID) } // Persist DB settings only if rotation was requested and driver is MySQL/MariaDB // and local DB not configured (as checked before calling). if wantRotateDatabase { if r.Database.DSN != "" { - updates["DatabaseDriver"] = r.Database.Driver - updates["DatabaseDSN"] = r.Database.DSN + updates.SetDatabaseDriver(r.Database.Driver) + updates.SetDatabaseDSN(r.Database.DSN) } else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" { server := r.Database.Host if r.Database.Port > 0 { server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port)) } - updates["DatabaseDriver"] = r.Database.Driver - updates["DatabaseServer"] = server - updates["DatabaseName"] = r.Database.Name - updates["DatabaseUser"] = r.Database.User - updates["DatabasePassword"] = r.Database.Password + updates.SetDatabaseDriver(r.Database.Driver) + updates.SetDatabaseServer(server) + updates.SetDatabaseName(r.Database.Name) + updates.SetDatabaseUser(r.Database.User) + updates.SetDatabasePassword(r.Database.Password) } } - if len(updates) == 0 { + if updates.IsZero() { return nil } - if err := mergeOptionsYaml(c, updates); err != nil { + wrote, err := ApplyOptionsUpdate(c, updates) + if err != nil { return err } - // Reload into memory so later code paths see updated values during this run. - _ = c.Options().Load(c.OptionsYaml()) + if wrote { + // Reload into memory so later code paths see updated values during this run. + _ = c.Options().Load(c.OptionsYaml()) - if hasDBUpdate(updates) { - log.Infof("cluster: database settings applied; restart required to take effect") + if updates.HasDatabaseUpdate() { + log.Infof("cluster: database settings applied; restart required to take effect") + } } + return nil } +// primeJWKS eagerly fetches the Portal JWKS so that subsequent token +// verification does not incur network latency during critical operations. func primeJWKS(c *config.Config, url string) { if c == nil { return @@ -308,51 +324,6 @@ func primeJWKS(c *config.Config, url string) { } } -func hasDBUpdate(m Values) bool { - if _, ok := m["DatabaseDSN"]; ok { - return true - } - if _, ok := m["DatabaseName"]; ok { - return true - } - if _, ok := m["DatabaseUser"]; ok { - return true - } - if _, ok := m["DatabasePassword"]; ok { - return true - } - if _, ok := m["DatabaseServer"]; ok { - return true - } - return false -} - -func mergeOptionsYaml(c *config.Config, updates Values) error { - if err := fs.MkdirAll(c.ConfigPath()); err != nil { - return err - } - fileName := c.OptionsYaml() - - var m Values - if fs.FileExists(fileName) { - if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { - _ = yaml.Unmarshal(b, &m) - } - } - if m == nil { - m = Values{} - } - for k, v := range updates { - m[k] = v - } - - b, err := yaml.Marshal(m) - if err != nil { - return err - } - return os.WriteFile(fileName, b, fs.ModeFile) -} - // installThemeIfMissing downloads and installs the Portal-provided theme if the // local theme directory is missing or lacks an app.js file. func installThemeIfMissing(c *config.Config, portal *url.URL, token string) error { diff --git a/internal/service/cluster/node/bootstrap_test.go b/internal/service/cluster/node/bootstrap_test.go index cb055de29..60e4735b7 100644 --- a/internal/service/cluster/node/bootstrap_test.go +++ b/internal/service/cluster/node/bootstrap_test.go @@ -116,7 +116,11 @@ func TestThemeInstall_Missing(t *testing.T) { _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: clientSecret}, JWKSUrl: jwksURL2}) case "/api/v1/oauth/token": w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"}) + type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + _ = json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", TokenType: "Bearer"}) case "/api/v1/cluster/theme": w.Header().Set("Content-Type", "application/zip") w.WriteHeader(http.StatusOK) diff --git a/internal/service/cluster/node/options_apply.go b/internal/service/cluster/node/options_apply.go new file mode 100644 index 000000000..6b7d6f9f3 --- /dev/null +++ b/internal/service/cluster/node/options_apply.go @@ -0,0 +1,50 @@ +package node + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/service/cluster" + "github.com/photoprism/photoprism/pkg/fs" +) + +// ApplyOptionsUpdate persists the provided cluster.OptionsUpdate to options.yml +// and returns true when a write occurred. +func ApplyOptionsUpdate(conf *config.Config, update cluster.OptionsUpdate) (bool, error) { + if conf == nil || update.IsZero() { + return false, nil + } + + fileName := conf.OptionsYaml() + if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil { + return false, err + } + + var existing map[string]any + if fs.FileExists(fileName) { + if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { + _ = yaml.Unmarshal(b, &existing) + } + } + if existing == nil { + existing = make(map[string]any) + } + + update.Visit(func(key string, value any) { + existing[key] = value + }) + + b, err := yaml.Marshal(existing) + if err != nil { + return false, err + } + + if err := os.WriteFile(fileName, b, fs.ModeFile); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/service/cluster/options_update.go b/internal/service/cluster/options_update.go new file mode 100644 index 000000000..799e9e4fb --- /dev/null +++ b/internal/service/cluster/options_update.go @@ -0,0 +1,152 @@ +package cluster + +// OptionsUpdate represents a set of configuration values that should be +// persisted to options.yml after a Portal cluster operation. +type OptionsUpdate struct { + ClusterUUID *string + ClusterCIDR *string + NodeClientID *string + NodeClientSecret *string + JWKSUrl *string + NodeUUID *string + DatabaseDriver *string + DatabaseDSN *string + DatabaseServer *string + DatabaseName *string + DatabaseUser *string + DatabasePassword *string +} + +// IsZero reports whether no fields have been set on the update. +func (u OptionsUpdate) IsZero() bool { + return u.ClusterUUID == nil && + u.ClusterCIDR == nil && + u.NodeClientID == nil && + u.NodeClientSecret == nil && + u.JWKSUrl == nil && + u.NodeUUID == nil && + u.DatabaseDriver == nil && + u.DatabaseDSN == nil && + u.DatabaseServer == nil && + u.DatabaseName == nil && + u.DatabaseUser == nil && + u.DatabasePassword == nil +} + +// HasDatabaseUpdate reports whether the update changes any database-related fields. +func (u OptionsUpdate) HasDatabaseUpdate() bool { + return u.DatabaseDriver != nil || + u.DatabaseDSN != nil || + u.DatabaseServer != nil || + u.DatabaseName != nil || + u.DatabaseUser != nil || + u.DatabasePassword != nil +} + +// Setter helpers ---------------------------------------------------------------- + +// SetClusterUUID sets the cluster UUID value. +func (u *OptionsUpdate) SetClusterUUID(value string) { + u.ClusterUUID = stringPtr(value) +} + +// SetClusterCIDR sets the cluster CIDR value. +func (u *OptionsUpdate) SetClusterCIDR(value string) { + u.ClusterCIDR = stringPtr(value) +} + +// SetNodeClientID sets the node client ID. +func (u *OptionsUpdate) SetNodeClientID(value string) { + u.NodeClientID = stringPtr(value) +} + +// SetNodeClientSecret sets the node client secret. +func (u *OptionsUpdate) SetNodeClientSecret(value string) { + u.NodeClientSecret = stringPtr(value) +} + +// SetJWKSUrl sets the JWKS URL. +func (u *OptionsUpdate) SetJWKSUrl(value string) { + u.JWKSUrl = stringPtr(value) +} + +// SetNodeUUID sets the node UUID. +func (u *OptionsUpdate) SetNodeUUID(value string) { + u.NodeUUID = stringPtr(value) +} + +// SetDatabaseDriver sets the database driver name. +func (u *OptionsUpdate) SetDatabaseDriver(value string) { + u.DatabaseDriver = stringPtr(value) +} + +// SetDatabaseDSN sets the database DSN. +func (u *OptionsUpdate) SetDatabaseDSN(value string) { + u.DatabaseDSN = stringPtr(value) +} + +// SetDatabaseServer sets the database server address. +func (u *OptionsUpdate) SetDatabaseServer(value string) { + u.DatabaseServer = stringPtr(value) +} + +// SetDatabaseName sets the database name. +func (u *OptionsUpdate) SetDatabaseName(value string) { + u.DatabaseName = stringPtr(value) +} + +// SetDatabaseUser sets the database username. +func (u *OptionsUpdate) SetDatabaseUser(value string) { + u.DatabaseUser = stringPtr(value) +} + +// SetDatabasePassword sets the database password. +func (u *OptionsUpdate) SetDatabasePassword(value string) { + u.DatabasePassword = stringPtr(value) +} + +// forEach enumerates all set fields and invokes fn with the corresponding key/value pair. +// Visit enumerates all set fields and invokes fn with the corresponding key/value pair. +func (u OptionsUpdate) Visit(fn func(string, any)) { + if u.ClusterUUID != nil { + fn("ClusterUUID", *u.ClusterUUID) + } + if u.ClusterCIDR != nil { + fn("ClusterCIDR", *u.ClusterCIDR) + } + if u.NodeClientID != nil { + fn("NodeClientID", *u.NodeClientID) + } + if u.NodeClientSecret != nil { + fn("NodeClientSecret", *u.NodeClientSecret) + } + if u.JWKSUrl != nil { + fn("JWKSUrl", *u.JWKSUrl) + } + if u.NodeUUID != nil { + fn("NodeUUID", *u.NodeUUID) + } + if u.DatabaseDriver != nil { + fn("DatabaseDriver", *u.DatabaseDriver) + } + if u.DatabaseDSN != nil { + fn("DatabaseDSN", *u.DatabaseDSN) + } + if u.DatabaseServer != nil { + fn("DatabaseServer", *u.DatabaseServer) + } + if u.DatabaseName != nil { + fn("DatabaseName", *u.DatabaseName) + } + if u.DatabaseUser != nil { + fn("DatabaseUser", *u.DatabaseUser) + } + if u.DatabasePassword != nil { + fn("DatabasePassword", *u.DatabasePassword) + } +} + +func stringPtr(value string) *string { + v := value + return &v +} diff --git a/internal/service/cluster/options_update_test.go b/internal/service/cluster/options_update_test.go new file mode 100644 index 000000000..c6cf3d627 --- /dev/null +++ b/internal/service/cluster/options_update_test.go @@ -0,0 +1,70 @@ +package cluster_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/service/cluster" + clusternode "github.com/photoprism/photoprism/internal/service/cluster/node" +) + +func TestOptionsUpdate_IsZero(t *testing.T) { + var u cluster.OptionsUpdate + assert.True(t, u.IsZero()) + + u.SetClusterUUID("1234") + assert.False(t, u.IsZero()) +} + +func TestOptionsUpdate_HasDatabaseUpdate(t *testing.T) { + var u cluster.OptionsUpdate + assert.False(t, u.HasDatabaseUpdate()) + + u.SetDatabaseName("photoprism") + assert.True(t, u.HasDatabaseUpdate()) +} + +func TestOptionsUpdate_Apply(t *testing.T) { + conf := config.NewMinimalTestConfig(t.TempDir()) + conf.Options().OptionsYaml = filepath.Join(conf.ConfigPath(), "options.yml") + + // Seed file with existing values to ensure they are preserved. + seed := map[string]any{"Existing": "value"} + b, err := yaml.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(conf.OptionsYaml(), b, 0o644)) + + 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") + + written, err := clusternode.ApplyOptionsUpdate(conf, update) + require.NoError(t, err) + assert.True(t, written) + + content, err := os.ReadFile(conf.OptionsYaml()) + require.NoError(t, err) + + var merged map[string]any + require.NoError(t, yaml.Unmarshal(content, &merged)) + + 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"]) + + // Applying an empty update should be a no-op. + empty := cluster.OptionsUpdate{} + written, err = clusternode.ApplyOptionsUpdate(conf, empty) + require.NoError(t, err) + assert.False(t, written) +} diff --git a/internal/service/cluster/registry/node.go b/internal/service/cluster/registry/node.go index 1a8819acc..b6e22e296 100644 --- a/internal/service/cluster/registry/node.go +++ b/internal/service/cluster/registry/node.go @@ -8,7 +8,7 @@ import "github.com/photoprism/photoprism/internal/service/cluster" type Node struct { cluster.Node ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory - RotatedAt string `json:"rotatedAt,omitempty"` // secret rotation timestamp + RotatedAt string `json:"RotatedAt,omitempty"` // secret rotation timestamp } // ensureDatabase returns a writable NodeDatabase, creating one if missing. diff --git a/internal/service/cluster/registry/response.go b/internal/service/cluster/registry/response.go index 674b00cf8..ec0317388 100644 --- a/internal/service/cluster/registry/response.go +++ b/internal/service/cluster/registry/response.go @@ -12,7 +12,7 @@ type NodeOpts struct { } // NodeOptsForSession returns the default exposure policy for a session. -// Admin users see advertiseUrl and DB metadata; others get a redacted view. +// Admin users see AdvertiseUrl and DB metadata; others get a redacted view. func NodeOptsForSession(s *entity.Session) NodeOpts { if s != nil && s.GetUser() != nil && s.GetUser().IsAdmin() { return NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true} diff --git a/internal/service/cluster/request.go b/internal/service/cluster/request.go index d162e273e..9319c7b39 100644 --- a/internal/service/cluster/request.go +++ b/internal/service/cluster/request.go @@ -5,14 +5,14 @@ package cluster // // swagger:model RegisterRequest type RegisterRequest struct { - NodeName string `json:"nodeName"` - NodeUUID string `json:"nodeUUID,omitempty"` - NodeRole string `json:"nodeRole,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - AdvertiseUrl string `json:"advertiseUrl,omitempty"` - SiteUrl string `json:"siteUrl,omitempty"` - ClientID string `json:"clientId,omitempty"` - ClientSecret string `json:"clientSecret,omitempty"` - RotateDatabase bool `json:"rotateDatabase,omitempty"` - RotateSecret bool `json:"rotateSecret,omitempty"` + NodeName string `json:"NodeName"` + NodeUUID string `json:"NodeUUID,omitempty"` + NodeRole string `json:"NodeRole,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + AdvertiseUrl string `json:"AdvertiseUrl,omitempty"` + SiteUrl string `json:"SiteUrl,omitempty"` + ClientID string `json:"ClientID,omitempty"` + ClientSecret string `json:"ClientSecret,omitempty"` + RotateDatabase bool `json:"RotateDatabase,omitempty"` + RotateSecret bool `json:"RotateSecret,omitempty"` } diff --git a/internal/service/cluster/response.go b/internal/service/cluster/response.go index 3504f4c0b..cd9ebb6b6 100644 --- a/internal/service/cluster/response.go +++ b/internal/service/cluster/response.go @@ -3,89 +3,89 @@ package cluster // NodeDatabase represents database metadata returned for a node. // swagger:model NodeDatabase type NodeDatabase struct { - Name string `json:"name"` - User string `json:"user"` - Driver string `json:"driver,omitempty"` - RotatedAt string `json:"rotatedAt"` + Name string `json:"Name"` + User string `json:"User"` + Driver string `json:"Driver,omitempty"` + RotatedAt string `json:"RotatedAt"` } // Node is the API response DTO for a cluster node. // swagger:model Node type Node struct { - UUID string `json:"uuid"` // NodeUUID - Name string `json:"name"` // NodeName - Role string `json:"role"` // NodeRole - ClientID string `json:"clientId,omitempty"` - SiteUrl string `json:"siteUrl,omitempty"` - AdvertiseUrl string `json:"advertiseUrl,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Database *NodeDatabase `json:"database,omitempty"` + UUID string `json:"UUID"` // NodeUUID + Name string `json:"Name"` // NodeName + Role string `json:"Role"` // NodeRole + ClientID string `json:"ClientID,omitempty"` + SiteUrl string `json:"SiteUrl,omitempty"` + AdvertiseUrl string `json:"AdvertiseUrl,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` + Database *NodeDatabase `json:"Database,omitempty"` } // DatabaseInfo provides basic database connection metadata for summary endpoints. // swagger:model DatabaseInfo type DatabaseInfo struct { - Driver string `json:"driver"` - Host string `json:"host"` - Port int `json:"port"` + Driver string `json:"Driver"` + Host string `json:"Host"` + Port int `json:"Port"` } // SummaryResponse is the response type for GET /api/v1/cluster. // swagger:model SummaryResponse type SummaryResponse struct { - UUID string `json:"uuid"` // ClusterUUID - ClusterCIDR string `json:"clusterCidr,omitempty"` - Nodes int `json:"nodes"` - Database DatabaseInfo `json:"database"` - Time string `json:"time"` + UUID string `json:"UUID"` // ClusterUUID + ClusterCIDR string `json:"ClusterCIDR,omitempty"` + Nodes int `json:"Nodes"` + Database DatabaseInfo `json:"Database"` + Time string `json:"Time"` } // MetricsResponse is the response type for GET /api/v1/cluster/metrics. // swagger:model MetricsResponse type MetricsResponse struct { - UUID string `json:"uuid"` - ClusterCIDR string `json:"clusterCidr,omitempty"` - Nodes map[string]int `json:"nodes"` - Time string `json:"time"` + UUID string `json:"UUID"` + ClusterCIDR string `json:"ClusterCIDR,omitempty"` + Nodes map[string]int `json:"Nodes"` + Time string `json:"Time"` } // RegisterSecrets contains newly issued or rotated node secrets. // swagger:model RegisterSecrets type RegisterSecrets struct { - ClientSecret string `json:"clientSecret,omitempty"` - RotatedAt string `json:"rotatedAt,omitempty"` + ClientSecret string `json:"ClientSecret,omitempty"` + RotatedAt string `json:"RotatedAt,omitempty"` } // RegisterDatabase describes database credentials returned during registration/rotation. // swagger:model RegisterDatabase type RegisterDatabase struct { - Driver string `json:"driver"` - Host string `json:"host"` - Port int `json:"port"` - Name string `json:"name"` - User string `json:"user"` - Password string `json:"password,omitempty"` - DSN string `json:"dsn,omitempty"` - RotatedAt string `json:"rotatedAt,omitempty"` + Driver string `json:"Driver"` + Host string `json:"Host"` + Port int `json:"Port"` + Name string `json:"Name"` + User string `json:"User"` + Password string `json:"Password,omitempty"` + DSN string `json:"DSN,omitempty"` + RotatedAt string `json:"RotatedAt,omitempty"` } // RegisterResponse is the response body for POST /api/v1/cluster/nodes/register. // swagger:model RegisterResponse type RegisterResponse struct { - UUID string `json:"uuid"` // ClusterUUID - ClusterCIDR string `json:"clusterCidr,omitempty"` - Node Node `json:"node"` - Database RegisterDatabase `json:"database"` - Secrets *RegisterSecrets `json:"secrets,omitempty"` - JWKSUrl string `json:"jwksUrl,omitempty"` - AlreadyRegistered bool `json:"alreadyRegistered"` - AlreadyProvisioned bool `json:"alreadyProvisioned"` + UUID string `json:"UUID"` // ClusterUUID + ClusterCIDR string `json:"ClusterCIDR,omitempty"` + Node Node `json:"Node"` + Database RegisterDatabase `json:"Database"` + Secrets *RegisterSecrets `json:"Secrets,omitempty"` + JWKSUrl string `json:"JWKSUrl,omitempty"` + AlreadyRegistered bool `json:"AlreadyRegistered"` + AlreadyProvisioned bool `json:"AlreadyProvisioned"` } // StatusResponse is a generic status wrapper for simple ok responses. // swagger:model StatusResponse type StatusResponse struct { - Status string `json:"status"` + Status string `json:"Status"` }