mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Cluster: Refactor request/response structs and JSON serialization
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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 non‑admin test user (role=guest), set a password, then `AuthenticateUser`.
|
- 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
|
```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 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
|
### 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.
|
||||||
|
|||||||
10
CODEMAP.md
10
CODEMAP.md
@@ -184,7 +184,7 @@ Conventions & Rules of Thumb
|
|||||||
- Never log secrets; compare tokens constant‑time.
|
- Never log secrets; compare tokens constant‑time.
|
||||||
- Don’t import Portal internals from cluster instance/service bootstraps; use HTTP.
|
- 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`.
|
- 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
|
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`
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
|
n := ®.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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
50
internal/service/cluster/node/options_apply.go
Normal file
50
internal/service/cluster/node/options_apply.go
Normal 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
|
||||||
|
}
|
||||||
152
internal/service/cluster/options_update.go
Normal file
152
internal/service/cluster/options_update.go
Normal 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
|
||||||
|
}
|
||||||
70
internal/service/cluster/options_update_test.go
Normal file
70
internal/service/cluster/options_update_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user