Cluster: Refactor request/response structs and JSON serialization

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-18 17:42:22 +02:00
parent 04f015bdf0
commit b47ee0fddc
23 changed files with 762 additions and 381 deletions

View File

@@ -398,13 +398,13 @@ Note: Across our public documentation, official images, and in production, the c
- Admin session (full view): `AuthenticateAdmin(app, router)`. - Admin session (full view): `AuthenticateAdmin(app, router)`.
- User session: Create a nonadmin test user (role=guest), set a password, then `AuthenticateUser`. - User session: Create a nonadmin test user (role=guest), set a password, then `AuthenticateUser`.
- Client session (redacted internal fields; `siteUrl` visible): - Client session (redacted internal fields; `SiteUrl` visible):
```go ```go
s, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil) s, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil)
token := s.AuthToken() token := s.AuthToken()
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token)
``` ```
Admins see `advertiseUrl` and `database`; client/user sessions dont. `siteUrl` is safe to show to all roles. Admins see `AdvertiseUrl` and `Database`; client/user sessions dont. `SiteUrl` is safe to show to all roles.
### Preflight Checklist ### 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`. - 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)`. - 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`. - 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. - 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`. - 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 (`photoprism_d<hmac11>`, `photoprism_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. - 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. - 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

@@ -184,7 +184,7 @@ Conventions & Rules of Thumb
- Never log secrets; compare tokens constanttime. - Never log secrets; compare tokens constanttime.
- Dont import Portal internals from cluster instance/service bootstraps; use HTTP. - Dont 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`. - 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 DNSlabel 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 Filesystem Permissions & io/fs Aliasing
- Use `github.com/photoprism/photoprism/pkg/fs` permission variables when creating files/dirs: - 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. - 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. - 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.
- Public API and internal registry DTOs use normalized field names: - Public API and internal registry DTOs use normalized field names:
- `database` (not `db`) with `name`, `user`, `driver`, `rotatedAt`. - `Database` (not `db`) with `Name`, `User`, `Driver`, `RotatedAt`.
- Node-level rotation timestamps use `rotatedAt`. - Node-level rotation timestamps use `RotatedAt`.
- Registration returns `secrets.clientSecret`; the CLI persists it under config `NodeClientSecret`. - 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. - Admin responses may include `AdvertiseUrl` and `Database`; non-admin responses are redacted by default.
Frequently Touched Files (by topic) Frequently Touched Files (by topic)
- CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go` - CLI wiring: `cmd/photoprism/photoprism.go`, `internal/commands/commands.go`

View File

@@ -33,13 +33,12 @@ describe("pro/portal/model/cluster-node", () => {
it("reports database metadata availability", () => { it("reports database metadata availability", () => {
const node = new ClusterNode({ const node = new ClusterNode({
Database: { Database: {
name: "photoprism", Name: "photoprism",
user: "photoprism", User: "photoprism",
driver: "mysql", Driver: "mysql",
}, },
}); });
expect(node.hasDatabase()).toBe(true); expect(node.hasDatabase()).toBe(true);
}); });
}); });

View File

@@ -22,6 +22,6 @@ func TestClusterMetrics_EmptyCounts(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, http.StatusOK, resp.Code)
body := resp.Body.String() body := resp.Body.String()
assert.Equal(t, "192.0.2.0/24", gjson.Get(body, "clusterCidr").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, int64(0), gjson.Get(body, "Nodes.total").Int())
} }

View File

@@ -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 // @Summary update node fields
// @Id ClusterUpdateNode // @Id ClusterUpdateNode
@@ -180,7 +180,7 @@ func ClusterGetNode(router *gin.RouterGroup) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param uuid path string true "node uuid" // @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 // @Success 200 {object} cluster.StatusResponse
// @Failure 400,401,403,404,429 {object} i18n.Response // @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{uuid} [patch] // @Router /api/v1/cluster/nodes/{uuid} [patch]
@@ -202,10 +202,10 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req struct { var req struct {
Role string `json:"role"` Role string `json:"Role"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"Labels"`
AdvertiseUrl string `json:"advertiseUrl"` AdvertiseUrl string `json:"AdvertiseUrl"`
SiteUrl string `json:"siteUrl"` SiteUrl string `json:"SiteUrl"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {

View File

@@ -34,15 +34,15 @@ func TestClusterListNodes_Redaction(t *testing.T) {
tokenAdmin := AuthenticateAdmin(app, router) tokenAdmin := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", tokenAdmin) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", tokenAdmin)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
// First item should include advertiseUrl and database for admins // First item should include AdvertiseUrl and Database for admins
assert.NotEqual(t, "", gjson.Get(r.Body.String(), "0.advertiseUrl").String()) assert.NotEqual(t, "", gjson.Get(r.Body.String(), "0.AdvertiseUrl").String())
assert.True(t, gjson.Get(r.Body.String(), "0.database").Exists()) assert.True(t, gjson.Get(r.Body.String(), "0.Database").Exists())
} }
// Verifies redaction for client-scoped sessions (no user attached). // Verifies redaction for client-scoped sessions (no user attached).
func TestClusterListNodes_Redaction_ClientScope(t *testing.T) { func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
// TODO: This test expects client-scoped sessions to receive redacted // 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 // in the response, likely due to session/ACL interactions in the test
// harness. Skipping for now; admin redaction coverage is in a separate // harness. Skipping for now; admin redaction coverage is in a separate
// test, and server-side opts are implemented. Revisit when signal/DB // 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) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
// Redacted: advertiseUrl and database omitted for client sessions; siteUrl is visible. // Redacted: AdvertiseUrl and Database omitted for client sessions; SiteUrl is visible.
assert.Equal(t, "", gjson.Get(r.Body.String(), "0.advertiseUrl").String()) assert.Equal(t, "", gjson.Get(r.Body.String(), "0.AdvertiseUrl").String())
assert.True(t, gjson.Get(r.Body.String(), "0.siteUrl").Exists()) assert.True(t, gjson.Get(r.Body.String(), "0.SiteUrl").Exists())
assert.False(t, gjson.Get(r.Body.String(), "0.database").Exists()) assert.False(t, gjson.Get(r.Body.String(), "0.Database").Exists())
} }

View File

@@ -28,12 +28,12 @@ var RegisterRequireClientSecret = true
// ClusterNodesRegister registers the Portal-only node registration endpoint. // 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 // @Id ClusterNodesRegister
// @Tags Cluster // @Tags Cluster
// @Accept json // @Accept json
// @Produce 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 // @Success 200,201 {object} cluster.RegisterResponse
// @Failure 400,401,403,409,429 {object} i18n.Response // @Failure 400,401,403,409,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/register [post] // @Router /api/v1/cluster/nodes/register [post]

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/internal/service/cluster"
@@ -23,11 +24,11 @@ func TestClusterNodesRegister(t *testing.T) {
conf.Options().NodeRole = cluster.RoleInstance conf.Options().NodeRole = cluster.RoleInstance
ClusterNodesRegister(router) 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) 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) { t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal conf.Options().NodeRole = cluster.RolePortal
@@ -44,17 +45,17 @@ func TestClusterNodesRegister(t *testing.T) {
secret := nr.ClientSecret secret := nr.ClientSecret
// Missing secret → 401 // 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) r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code) assert.Equal(t, http.StatusUnauthorized, r.Code)
// Wrong secret → 401 // 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) r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code) assert.Equal(t, http.StatusUnauthorized, r.Code)
// Correct secret → 200 (existing-node path) // 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) r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
@@ -64,7 +65,7 @@ func TestClusterNodesRegister(t *testing.T) {
conf.Options().NodeRole = cluster.RolePortal conf.Options().NodeRole = cluster.RolePortal
ClusterNodesRegister(router) 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) assert.Equal(t, http.StatusUnauthorized, r.Code)
}) })
t.Run("CreateNodeSucceedsWithProvisioner", func(t *testing.T) { 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 // Provisioner is independent of the main DB; with MariaDB admin DSN configured
// it should successfully provision and return 201. // 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) assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String() body := r.Body.String()
assert.Contains(t, body, "\"database\"") assert.Contains(t, body, "\"Database\"")
assert.Contains(t, body, "\"secrets\"") assert.Contains(t, body, "\"Secrets\"")
// New nodes return the client secret; include alias for clarity. // New nodes return the client secret; include alias for clarity.
assert.Contains(t, body, "\"clientSecret\"") assert.Contains(t, body, "\"ClientSecret\"")
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
}) })
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) { 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 // Attempt to change UUID via name without client credentials → 409
newUUID := rnd.UUIDv7() 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) assert.Equal(t, http.StatusConflict, r.Code)
}) })
t.Run("BadAdvertiseUrlRejected", func(t *testing.T) { t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
@@ -109,7 +110,7 @@ func TestClusterNodesRegister(t *testing.T) {
ClusterNodesRegister(router) ClusterNodesRegister(router)
// http scheme for public host must be rejected (require https unless localhost). // 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) assert.Equal(t, http.StatusBadRequest, r.Code)
}) })
t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) { t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
@@ -119,12 +120,12 @@ func TestClusterNodesRegister(t *testing.T) {
ClusterNodesRegister(router) ClusterNodesRegister(router)
// https is allowed for public host // 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) assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
// http is allowed for localhost // 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) assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
}) })
@@ -134,12 +135,12 @@ func TestClusterNodesRegister(t *testing.T) {
conf.Options().JoinToken = cluster.ExampleJoinToken conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Reject http siteUrl for public host // 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) 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) assert.Equal(t, http.StatusBadRequest, r.Code)
// Accept https siteUrl // Accept https SiteUrl
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken) 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) assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
}) })
@@ -150,7 +151,7 @@ func TestClusterNodesRegister(t *testing.T) {
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Mixed separators and case should normalize to DNS label // 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) r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code) assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
@@ -170,7 +171,7 @@ func TestClusterNodesRegister(t *testing.T) {
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Empty nodeName → 400 // 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) assert.Equal(t, http.StatusBadRequest, r.Code)
}) })
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) { t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
@@ -188,7 +189,7 @@ func TestClusterNodesRegister(t *testing.T) {
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}} n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
assert.NoError(t, regy.Put(n)) 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) assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
@@ -213,11 +214,11 @@ func TestClusterNodesRegister(t *testing.T) {
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Provisioner is independent; endpoint should respond 200 and persist metadata. // 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) assert.Equal(t, http.StatusOK, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
// Ensure normalized/persisted siteUrl. // Ensure normalized/persisted SiteUrl.
n2, err := regy.FindByName("pp-node-02") n2, err := regy.FindByName("pp-node-02")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "https://photos.example.com", n2.SiteUrl) assert.Equal(t, "https://photos.example.com", n2.SiteUrl)
@@ -229,13 +230,13 @@ func TestClusterNodesRegister(t *testing.T) {
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Register without nodeUUID; server should assign one (UUID v7 preferred). // 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) assert.Equal(t, http.StatusCreated, r.Code)
cleanupRegisterProvisioning(t, conf, r) cleanupRegisterProvisioning(t, conf, r)
// Response must include node.uuid // Response must include Node.UUID
body := r.Body.String() 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 // Verify it is persisted in the registry
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)

View File

@@ -47,7 +47,7 @@ func TestClusterEndpoints(t *testing.T) {
assert.Equal(t, http.StatusNotFound, r.Code) assert.Equal(t, http.StatusNotFound, r.Code)
// Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here) // 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) assert.Equal(t, http.StatusOK, r.Code)
// Pagination: count=1 returns exactly one // Pagination: count=1 returns exactly one

View File

@@ -11,7 +11,7 @@ import (
"github.com/photoprism/photoprism/pkg/rnd" "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) { func TestClusterUpdateNode_SiteUrl(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal conf.Options().NodeRole = cluster.RolePortal
@@ -29,14 +29,14 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Invalid scheme: ignored (200 OK but no update) // 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) assert.Equal(t, http.StatusOK, r.Code)
n2, err := regy.FindByNodeUUID(n.UUID) n2, err := regy.FindByNodeUUID(n.UUID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "", n2.SiteUrl) assert.Equal(t, "", n2.SiteUrl)
// Valid https URL: persisted and normalized // 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) assert.Equal(t, http.StatusOK, r.Code)
n3, err := regy.FindByNodeUUID(n.UUID) n3, err := regy.FindByNodeUUID(n.UUID)
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -312,13 +312,13 @@
}, },
"cluster.DatabaseInfo": { "cluster.DatabaseInfo": {
"properties": { "properties": {
"driver": { "Driver": {
"type": "string" "type": "string"
}, },
"host": { "Host": {
"type": "string" "type": "string"
}, },
"port": { "Port": {
"type": "integer" "type": "integer"
} }
}, },
@@ -326,19 +326,19 @@
}, },
"cluster.MetricsResponse": { "cluster.MetricsResponse": {
"properties": { "properties": {
"clusterCidr": { "ClusterCIDR": {
"type": "string" "type": "string"
}, },
"nodes": { "Nodes": {
"additionalProperties": { "additionalProperties": {
"type": "integer" "type": "integer"
}, },
"type": "object" "type": "object"
}, },
"time": { "Time": {
"type": "string" "type": "string"
}, },
"uuid": { "UUID": {
"type": "string" "type": "string"
} }
}, },
@@ -346,57 +346,57 @@
}, },
"cluster.Node": { "cluster.Node": {
"properties": { "properties": {
"advertiseUrl": { "AdvertiseUrl": {
"type": "string" "type": "string"
}, },
"clientId": { "ClientID": {
"type": "string" "type": "string"
}, },
"createdAt": { "CreatedAt": {
"type": "string" "type": "string"
}, },
"database": { "Database": {
"$ref": "#/definitions/cluster.NodeDatabase" "$ref": "#/definitions/cluster.NodeDatabase"
}, },
"labels": { "Labels": {
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
}, },
"type": "object" "type": "object"
}, },
"name": { "Name": {
"description": "NodeName", "description": "NodeName",
"type": "string" "type": "string"
}, },
"role": { "Role": {
"description": "NodeRole", "description": "NodeRole",
"type": "string" "type": "string"
}, },
"siteUrl": { "SiteUrl": {
"type": "string" "type": "string"
}, },
"updatedAt": { "UUID": {
"type": "string"
},
"uuid": {
"description": "NodeUUID", "description": "NodeUUID",
"type": "string" "type": "string"
},
"UpdatedAt": {
"type": "string"
} }
}, },
"type": "object" "type": "object"
}, },
"cluster.NodeDatabase": { "cluster.NodeDatabase": {
"properties": { "properties": {
"driver": { "Driver": {
"type": "string" "type": "string"
}, },
"name": { "Name": {
"type": "string" "type": "string"
}, },
"rotatedAt": { "RotatedAt": {
"type": "string" "type": "string"
}, },
"user": { "User": {
"type": "string" "type": "string"
} }
}, },
@@ -404,28 +404,28 @@
}, },
"cluster.RegisterDatabase": { "cluster.RegisterDatabase": {
"properties": { "properties": {
"driver": { "DSN": {
"type": "string" "type": "string"
}, },
"dsn": { "Driver": {
"type": "string" "type": "string"
}, },
"host": { "Host": {
"type": "string" "type": "string"
}, },
"name": { "Name": {
"type": "string" "type": "string"
}, },
"password": { "Password": {
"type": "string" "type": "string"
}, },
"port": { "Port": {
"type": "integer" "type": "integer"
}, },
"rotatedAt": { "RotatedAt": {
"type": "string" "type": "string"
}, },
"user": { "User": {
"type": "string" "type": "string"
} }
}, },
@@ -433,28 +433,28 @@
}, },
"cluster.RegisterResponse": { "cluster.RegisterResponse": {
"properties": { "properties": {
"alreadyProvisioned": { "AlreadyProvisioned": {
"type": "boolean" "type": "boolean"
}, },
"alreadyRegistered": { "AlreadyRegistered": {
"type": "boolean" "type": "boolean"
}, },
"clusterCidr": { "ClusterCIDR": {
"type": "string" "type": "string"
}, },
"database": { "Database": {
"$ref": "#/definitions/cluster.RegisterDatabase" "$ref": "#/definitions/cluster.RegisterDatabase"
}, },
"jwksUrl": { "JWKSUrl": {
"type": "string" "type": "string"
}, },
"node": { "Node": {
"$ref": "#/definitions/cluster.Node" "$ref": "#/definitions/cluster.Node"
}, },
"secrets": { "Secrets": {
"$ref": "#/definitions/cluster.RegisterSecrets" "$ref": "#/definitions/cluster.RegisterSecrets"
}, },
"uuid": { "UUID": {
"description": "ClusterUUID", "description": "ClusterUUID",
"type": "string" "type": "string"
} }
@@ -463,10 +463,10 @@
}, },
"cluster.RegisterSecrets": { "cluster.RegisterSecrets": {
"properties": { "properties": {
"clientSecret": { "ClientSecret": {
"type": "string" "type": "string"
}, },
"rotatedAt": { "RotatedAt": {
"type": "string" "type": "string"
} }
}, },
@@ -474,7 +474,7 @@
}, },
"cluster.StatusResponse": { "cluster.StatusResponse": {
"properties": { "properties": {
"status": { "Status": {
"type": "string" "type": "string"
} }
}, },
@@ -482,19 +482,19 @@
}, },
"cluster.SummaryResponse": { "cluster.SummaryResponse": {
"properties": { "properties": {
"clusterCidr": { "ClusterCIDR": {
"type": "string" "type": "string"
}, },
"database": { "Database": {
"$ref": "#/definitions/cluster.DatabaseInfo" "$ref": "#/definitions/cluster.DatabaseInfo"
}, },
"nodes": { "Nodes": {
"type": "integer" "type": "integer"
}, },
"time": { "Time": {
"type": "string" "type": "string"
}, },
"uuid": { "UUID": {
"description": "ClusterUUID", "description": "ClusterUUID",
"type": "string" "type": "string"
} }
@@ -6644,7 +6644,7 @@
"operationId": "ClusterNodesRegister", "operationId": "ClusterNodesRegister",
"parameters": [ "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", "in": "body",
"name": "request", "name": "request",
"required": true, "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": [ "tags": [
"Cluster" "Cluster"
] ]
@@ -6823,7 +6823,7 @@
"type": "string" "type": "string"
}, },
{ {
"description": "properties to update (role, labels, advertiseUrl, siteUrl)", "description": "properties to update (Role, Labels, AdvertiseUrl, SiteUrl)",
"in": "body", "in": "body",
"name": "node", "name": "node",
"required": true, "required": true,

View File

@@ -81,18 +81,29 @@ func authJWTIssueAction(ctx *cli.Context) error {
} }
if ctx.Bool("json") { if ctx.Bool("json") {
payload := map[string]any{ type nodePayload struct {
"token": token, UUID string `json:"UUID"`
"header": header, ClientID string `json:"ClientID"`
"claims": claims, Name string `json:"Name"`
"node": map[string]string{ Role string `json:"Role"`
"uuid": node.UUID, }
"clientId": node.ClientID, response := struct {
"name": node.Name, Token string `json:"Token"`
"role": string(node.Role), 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" expires := "unknown"

View File

@@ -14,10 +14,10 @@ import (
"time" "time"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster" "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/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd" "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). // In dry-run, we allow empty portalURL (will print derived/empty values).
if ctx.Bool("dry-run") { if ctx.Bool("dry-run") {
if ctx.Bool("json") { 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) jb, _ := json.Marshal(out)
fmt.Println(string(jb)) fmt.Println(string(jb))
} else { } else {
@@ -343,14 +346,14 @@ func parseLabelSlice(labels []string) map[string]string {
// Persistence helpers for --write-config // Persistence helpers for --write-config
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error { func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
updates := map[string]any{} updates := cluster.OptionsUpdate{}
if rnd.IsUUID(resp.UUID) { if rnd.IsUUID(resp.UUID) {
updates["ClusterUUID"] = resp.UUID updates.SetClusterUUID(resp.UUID)
} }
if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" { if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" {
updates["ClusterCIDR"] = cidr updates.SetClusterCIDR(cidr)
} }
// Node client secret file // Node client secret file
@@ -371,43 +374,18 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse
// DB settings (MySQL/MariaDB only) // DB settings (MySQL/MariaDB only)
if resp.Database.Name != "" && resp.Database.User != "" { if resp.Database.Name != "" && resp.Database.User != "" {
updates["DatabaseDriver"] = config.MySQL updates.SetDatabaseDriver(config.MySQL)
updates["DatabaseName"] = resp.Database.Name updates.SetDatabaseName(resp.Database.Name)
updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port) updates.SetDatabaseServer(fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port))
updates["DatabaseUser"] = resp.Database.User updates.SetDatabaseUser(resp.Database.User)
updates["DatabasePassword"] = resp.Database.Password updates.SetDatabasePassword(resp.Database.Password)
} }
if len(updates) > 0 { if !updates.IsZero() {
if err := mergeOptionsYaml(conf, updates); err != nil { if _, err := clusternode.ApplyOptionsUpdate(conf, updates); err != nil {
return err return err
} }
log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name)) log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name))
} }
return nil 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)
}

View File

@@ -30,13 +30,31 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n1",
"secrets": map[string]any{"clientSecret": cluster.ExampleClientSecret, "rotatedAt": "2025-09-15T00:00:00Z"}, Name: "pp-node-02",
"alreadyRegistered": false, Role: "instance",
"alreadyProvisioned": false, 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() defer ts.Close()
@@ -45,10 +63,10 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
}) })
assert.NoError(t, err) assert.NoError(t, err)
// Parse JSON // Parse JSON
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").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, cluster.ExampleClientSecret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "database.password").String()) assert.Equal(t, "pwd", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "database.dsn").String() dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn) parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User) assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd", parsed.Password) assert.Equal(t, "pwd", parsed.Password)
@@ -71,13 +89,31 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n1",
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, Name: "pp-node-03",
"alreadyRegistered": true, Role: "instance",
"alreadyProvisioned": true, 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() defer ts.Close()
@@ -110,13 +146,31 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n2",
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, Name: "pp-node-04",
"alreadyRegistered": true, Role: "instance",
"alreadyProvisioned": true, 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() defer ts.Close()
@@ -130,10 +184,10 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
"rotate", "--json", "--db", "--secret", "--yes", "pp-node-04", "rotate", "--json", "--db", "--secret", "--yes", "pp-node-04",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").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, secret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "database.password").String()) assert.Equal(t, "pwd3", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "database.dsn").String() dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn) parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User) assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd3", parsed.Password) assert.Equal(t, "pwd3", parsed.Password)
@@ -154,8 +208,8 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
} }
// Read payload to assert rotate flags // Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotateDatabase").Bool() rotate := gjson.GetBytes(b, "RotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() rotateSecret := gjson.GetBytes(b, "RotateSecret").Bool()
// Expect DB rotation only // Expect DB rotation only
if !rotate || rotateSecret { if !rotate || rotateSecret {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -163,13 +217,27 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n3", "name": "pp-node-05", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n3",
// secrets omitted on DB-only rotate Name: "pp-node-05",
"alreadyRegistered": true, Role: "instance",
"alreadyProvisioned": true, 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() defer ts.Close()
@@ -183,16 +251,16 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
"rotate", "--json", "--db", "--yes", "pp-node-05", "rotate", "--json", "--db", "--yes", "pp-node-05",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "pp-node-05", gjson.Get(out, "node.name").String()) assert.Equal(t, "pp-node-05", gjson.Get(out, "Node.Name").String())
assert.Equal(t, "pwd4", gjson.Get(out, "database.password").String()) assert.Equal(t, "pwd4", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "database.dsn").String() dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn) parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User) assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd4", parsed.Password) assert.Equal(t, "pwd4", parsed.Password)
assert.Equal(t, "tcp", parsed.Net) assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server) assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name) 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) { func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
@@ -207,8 +275,8 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotateDatabase").Bool() rotate := gjson.GetBytes(b, "RotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() rotateSecret := gjson.GetBytes(b, "RotateSecret").Bool()
// Expect secret-only rotation // Expect secret-only rotation
if rotate || !rotateSecret { if rotate || !rotateSecret {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -216,13 +284,29 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"}, UUID: "n4",
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, Name: "pp-node-06",
"alreadyRegistered": true, Role: "instance",
"alreadyProvisioned": true, 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() defer ts.Close()
@@ -234,9 +318,9 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
"rotate", "--json", "--secret", "--yes", "pp-node-06", "rotate", "--json", "--secret", "--yes", "pp-node-06",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").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, secret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String()) assert.Equal(t, "", gjson.Get(out, "Database.Password").String())
} }
func TestClusterRegister_HTTPUnauthorized(t *testing.T) { func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
@@ -278,14 +362,14 @@ func TestClusterRegister_DryRun_JSON(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{ out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--dry-run", "--json", "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 { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
assert.NotEmpty(t, gjson.Get(out, "portalUrl").String()) assert.NotEmpty(t, gjson.Get(out, "PortalUrl").String())
assert.Equal(t, "instance", gjson.Get(out, "payload.nodeRole").String()) assert.Equal(t, "instance", gjson.Get(out, "Payload.NodeRole").String())
// nodeName may be derived; ensure non-empty // NodeName may be derived; ensure non-empty
assert.NotEmpty(t, gjson.Get(out, "payload.nodeName").String()) assert.NotEmpty(t, gjson.Get(out, "Payload.NodeName").String())
} }
func TestClusterRegister_DryRun_Text(t *testing.T) { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n7",
"alreadyRegistered": true, Name: "pp-node-rl",
"alreadyProvisioned": true, 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() 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", "register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
}) })
assert.NoError(t, err) 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) { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n8",
"alreadyRegistered": true, Name: "pp-node-rl2",
"alreadyProvisioned": true, 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() 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", "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-rl2",
}) })
assert.NoError(t, err) 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) { func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
@@ -426,18 +540,33 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) 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) w.WriteHeader(http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n5", "name": "pp-node-07", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"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"}, UUID: "n5",
"alreadyRegistered": true, Name: "pp-node-07",
"alreadyProvisioned": true, 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() 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", "register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String()) assert.Equal(t, "pp-node-07", gjson.Get(out, "Node.Name").String())
assert.Equal(t, "pwd7", gjson.Get(out, "database.password").String()) assert.Equal(t, "pwd7", gjson.Get(out, "Database.Password").String())
dsn := gjson.Get(out, "database.dsn").String() dsn := gjson.Get(out, "Database.DSN").String()
parsed := cfg.NewDSN(dsn) parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User) assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd7", parsed.Password) assert.Equal(t, "pwd7", parsed.Password)
@@ -468,19 +597,35 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) 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) w.WriteHeader(http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{ resp := cluster.RegisterResponse{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, Node: cluster.Node{
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"}, UUID: "n6",
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"}, Name: "pp-node-08",
"alreadyRegistered": true, Role: "instance",
"alreadyProvisioned": true, 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() 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", "register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate-secret", "--json",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").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, secret, gjson.Get(out, "Secrets.ClientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String()) assert.Equal(t, "", gjson.Get(out, "Database.Password").String())
} }

View File

@@ -17,8 +17,6 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"gopkg.in/yaml.v2"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt" clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
@@ -30,9 +28,8 @@ import (
var log = event.Log var log = event.Log
// Values is an shorthand alias for map[string]interface{}. // init registers the cluster node bootstrap extension so it runs before the
type Values = map[string]interface{} // database connection is established.
func init() { func init() {
// Register early so this can adjust DB settings before connectDb(). // Register early so this can adjust DB settings before connectDb().
config.RegisterEarly("cluster-node", InitConfig, nil) config.RegisterEarly("cluster-node", InitConfig, nil)
@@ -98,6 +95,8 @@ func InitConfig(c *config.Config) error {
return nil 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 { func isLocalHost(h string) bool {
switch strings.ToLower(h) { switch strings.ToLower(h) {
case "localhost", "127.0.0.1", "::1": 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 { func newHTTPClient(timeout time.Duration) *http.Client {
// TODO: Consider reusing a shared *http.Transport with sane defaults and enabling // 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 // 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} 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 { func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
maxAttempts := cluster.BootstrapRegisterMaxAttempts maxAttempts := cluster.BootstrapRegisterMaxAttempts
delay := cluster.BootstrapRegisterRetryDelay delay := cluster.BootstrapRegisterRetryDelay
@@ -145,7 +150,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
payload.ClientSecret = secret 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() { if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() {
payload.SiteUrl = su payload.SiteUrl = su
} }
@@ -219,79 +224,90 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
return nil return nil
} }
// isTemporary reports whether the given error represents a temporary network
// failure that merits another retry attempt.
func isTemporary(err error) bool { func isTemporary(err error) bool {
var nerr net.Error var nerr net.Error
return errors.As(err, &nerr) && nerr.Timeout() 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 { func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error {
updates := Values{} updates := cluster.OptionsUpdate{}
// Persist ClusterUUID from portal response if provided. // Persist ClusterUUID from portal response if provided.
if rnd.IsUUID(r.UUID) { if rnd.IsUUID(r.UUID) {
updates["ClusterUUID"] = r.UUID updates.SetClusterUUID(r.UUID)
} }
if cidr := strings.TrimSpace(r.ClusterCIDR); cidr != "" { 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. // Always persist NodeClientID (client UID) from response for future OAuth token requests.
if r.Node.ClientID != "" { 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. // Persist node client secret only if missing locally and provided by server.
if r.Secrets != nil && r.Secrets.ClientSecret != "" && c.NodeClientSecret() == "" { 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 != "" { if jwksUrl := strings.TrimSpace(r.JWKSUrl); jwksUrl != "" {
updates["JWKSUrl"] = jwksUrl updates.SetJWKSUrl(jwksUrl)
c.SetJWKSUrl(jwksUrl) c.SetJWKSUrl(jwksUrl)
} }
// Persist NodeUUID from portal response if provided and not set locally. // Persist NodeUUID from portal response if provided and not set locally.
if r.Node.UUID != "" && c.NodeUUID() == "" { 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 // Persist DB settings only if rotation was requested and driver is MySQL/MariaDB
// and local DB not configured (as checked before calling). // and local DB not configured (as checked before calling).
if wantRotateDatabase { if wantRotateDatabase {
if r.Database.DSN != "" { if r.Database.DSN != "" {
updates["DatabaseDriver"] = r.Database.Driver updates.SetDatabaseDriver(r.Database.Driver)
updates["DatabaseDSN"] = r.Database.DSN updates.SetDatabaseDSN(r.Database.DSN)
} else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" { } else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" {
server := r.Database.Host server := r.Database.Host
if r.Database.Port > 0 { if r.Database.Port > 0 {
server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port)) server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port))
} }
updates["DatabaseDriver"] = r.Database.Driver updates.SetDatabaseDriver(r.Database.Driver)
updates["DatabaseServer"] = server updates.SetDatabaseServer(server)
updates["DatabaseName"] = r.Database.Name updates.SetDatabaseName(r.Database.Name)
updates["DatabaseUser"] = r.Database.User updates.SetDatabaseUser(r.Database.User)
updates["DatabasePassword"] = r.Database.Password updates.SetDatabasePassword(r.Database.Password)
} }
} }
if len(updates) == 0 { if updates.IsZero() {
return nil return nil
} }
if err := mergeOptionsYaml(c, updates); err != nil { wrote, err := ApplyOptionsUpdate(c, updates)
if err != nil {
return err return err
} }
// Reload into memory so later code paths see updated values during this run. if wrote {
_ = c.Options().Load(c.OptionsYaml()) // Reload into memory so later code paths see updated values during this run.
_ = c.Options().Load(c.OptionsYaml())
if hasDBUpdate(updates) { if updates.HasDatabaseUpdate() {
log.Infof("cluster: database settings applied; restart required to take effect") log.Infof("cluster: database settings applied; restart required to take effect")
}
} }
return nil 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) { func primeJWKS(c *config.Config, url string) {
if c == nil { if c == nil {
return 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 // installThemeIfMissing downloads and installs the Portal-provided theme if the
// local theme directory is missing or lacks an app.js file. // local theme directory is missing or lacks an app.js file.
func installThemeIfMissing(c *config.Config, portal *url.URL, token string) error { func installThemeIfMissing(c *config.Config, portal *url.URL, token string) error {

View File

@@ -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}) _ = 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": case "/api/v1/oauth/token":
w.Header().Set("Content-Type", "application/json") 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": case "/api/v1/cluster/theme":
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -8,7 +8,7 @@ import "github.com/photoprism/photoprism/internal/service/cluster"
type Node struct { type Node struct {
cluster.Node cluster.Node
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory 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. // ensureDatabase returns a writable NodeDatabase, creating one if missing.

View File

@@ -12,7 +12,7 @@ type NodeOpts struct {
} }
// NodeOptsForSession returns the default exposure policy for a session. // 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 { func NodeOptsForSession(s *entity.Session) NodeOpts {
if s != nil && s.GetUser() != nil && s.GetUser().IsAdmin() { if s != nil && s.GetUser() != nil && s.GetUser().IsAdmin() {
return NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true} return NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}

View File

@@ -5,14 +5,14 @@ package cluster
// //
// swagger:model RegisterRequest // swagger:model RegisterRequest
type RegisterRequest struct { type RegisterRequest struct {
NodeName string `json:"nodeName"` NodeName string `json:"NodeName"`
NodeUUID string `json:"nodeUUID,omitempty"` NodeUUID string `json:"NodeUUID,omitempty"`
NodeRole string `json:"nodeRole,omitempty"` NodeRole string `json:"NodeRole,omitempty"`
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
AdvertiseUrl string `json:"advertiseUrl,omitempty"` AdvertiseUrl string `json:"AdvertiseUrl,omitempty"`
SiteUrl string `json:"siteUrl,omitempty"` SiteUrl string `json:"SiteUrl,omitempty"`
ClientID string `json:"clientId,omitempty"` ClientID string `json:"ClientID,omitempty"`
ClientSecret string `json:"clientSecret,omitempty"` ClientSecret string `json:"ClientSecret,omitempty"`
RotateDatabase bool `json:"rotateDatabase,omitempty"` RotateDatabase bool `json:"RotateDatabase,omitempty"`
RotateSecret bool `json:"rotateSecret,omitempty"` RotateSecret bool `json:"RotateSecret,omitempty"`
} }

View File

@@ -3,89 +3,89 @@ package cluster
// NodeDatabase represents database metadata returned for a node. // NodeDatabase represents database metadata returned for a node.
// swagger:model NodeDatabase // swagger:model NodeDatabase
type NodeDatabase struct { type NodeDatabase struct {
Name string `json:"name"` Name string `json:"Name"`
User string `json:"user"` User string `json:"User"`
Driver string `json:"driver,omitempty"` Driver string `json:"Driver,omitempty"`
RotatedAt string `json:"rotatedAt"` RotatedAt string `json:"RotatedAt"`
} }
// Node is the API response DTO for a cluster node. // Node is the API response DTO for a cluster node.
// swagger:model Node // swagger:model Node
type Node struct { type Node struct {
UUID string `json:"uuid"` // NodeUUID UUID string `json:"UUID"` // NodeUUID
Name string `json:"name"` // NodeName Name string `json:"Name"` // NodeName
Role string `json:"role"` // NodeRole Role string `json:"Role"` // NodeRole
ClientID string `json:"clientId,omitempty"` ClientID string `json:"ClientID,omitempty"`
SiteUrl string `json:"siteUrl,omitempty"` SiteUrl string `json:"SiteUrl,omitempty"`
AdvertiseUrl string `json:"advertiseUrl,omitempty"` AdvertiseUrl string `json:"AdvertiseUrl,omitempty"`
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"UpdatedAt"`
Database *NodeDatabase `json:"database,omitempty"` Database *NodeDatabase `json:"Database,omitempty"`
} }
// DatabaseInfo provides basic database connection metadata for summary endpoints. // DatabaseInfo provides basic database connection metadata for summary endpoints.
// swagger:model DatabaseInfo // swagger:model DatabaseInfo
type DatabaseInfo struct { type DatabaseInfo struct {
Driver string `json:"driver"` Driver string `json:"Driver"`
Host string `json:"host"` Host string `json:"Host"`
Port int `json:"port"` Port int `json:"Port"`
} }
// SummaryResponse is the response type for GET /api/v1/cluster. // SummaryResponse is the response type for GET /api/v1/cluster.
// swagger:model SummaryResponse // swagger:model SummaryResponse
type SummaryResponse struct { type SummaryResponse struct {
UUID string `json:"uuid"` // ClusterUUID UUID string `json:"UUID"` // ClusterUUID
ClusterCIDR string `json:"clusterCidr,omitempty"` ClusterCIDR string `json:"ClusterCIDR,omitempty"`
Nodes int `json:"nodes"` Nodes int `json:"Nodes"`
Database DatabaseInfo `json:"database"` Database DatabaseInfo `json:"Database"`
Time string `json:"time"` Time string `json:"Time"`
} }
// MetricsResponse is the response type for GET /api/v1/cluster/metrics. // MetricsResponse is the response type for GET /api/v1/cluster/metrics.
// swagger:model MetricsResponse // swagger:model MetricsResponse
type MetricsResponse struct { type MetricsResponse struct {
UUID string `json:"uuid"` UUID string `json:"UUID"`
ClusterCIDR string `json:"clusterCidr,omitempty"` ClusterCIDR string `json:"ClusterCIDR,omitempty"`
Nodes map[string]int `json:"nodes"` Nodes map[string]int `json:"Nodes"`
Time string `json:"time"` Time string `json:"Time"`
} }
// RegisterSecrets contains newly issued or rotated node secrets. // RegisterSecrets contains newly issued or rotated node secrets.
// swagger:model RegisterSecrets // swagger:model RegisterSecrets
type RegisterSecrets struct { type RegisterSecrets struct {
ClientSecret string `json:"clientSecret,omitempty"` ClientSecret string `json:"ClientSecret,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"` RotatedAt string `json:"RotatedAt,omitempty"`
} }
// RegisterDatabase describes database credentials returned during registration/rotation. // RegisterDatabase describes database credentials returned during registration/rotation.
// swagger:model RegisterDatabase // swagger:model RegisterDatabase
type RegisterDatabase struct { type RegisterDatabase struct {
Driver string `json:"driver"` Driver string `json:"Driver"`
Host string `json:"host"` Host string `json:"Host"`
Port int `json:"port"` Port int `json:"Port"`
Name string `json:"name"` Name string `json:"Name"`
User string `json:"user"` User string `json:"User"`
Password string `json:"password,omitempty"` Password string `json:"Password,omitempty"`
DSN string `json:"dsn,omitempty"` DSN string `json:"DSN,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"` RotatedAt string `json:"RotatedAt,omitempty"`
} }
// RegisterResponse is the response body for POST /api/v1/cluster/nodes/register. // RegisterResponse is the response body for POST /api/v1/cluster/nodes/register.
// swagger:model RegisterResponse // swagger:model RegisterResponse
type RegisterResponse struct { type RegisterResponse struct {
UUID string `json:"uuid"` // ClusterUUID UUID string `json:"UUID"` // ClusterUUID
ClusterCIDR string `json:"clusterCidr,omitempty"` ClusterCIDR string `json:"ClusterCIDR,omitempty"`
Node Node `json:"node"` Node Node `json:"Node"`
Database RegisterDatabase `json:"database"` Database RegisterDatabase `json:"Database"`
Secrets *RegisterSecrets `json:"secrets,omitempty"` Secrets *RegisterSecrets `json:"Secrets,omitempty"`
JWKSUrl string `json:"jwksUrl,omitempty"` JWKSUrl string `json:"JWKSUrl,omitempty"`
AlreadyRegistered bool `json:"alreadyRegistered"` AlreadyRegistered bool `json:"AlreadyRegistered"`
AlreadyProvisioned bool `json:"alreadyProvisioned"` AlreadyProvisioned bool `json:"AlreadyProvisioned"`
} }
// StatusResponse is a generic status wrapper for simple ok responses. // StatusResponse is a generic status wrapper for simple ok responses.
// swagger:model StatusResponse // swagger:model StatusResponse
type StatusResponse struct { type StatusResponse struct {
Status string `json:"status"` Status string `json:"Status"`
} }