API: Update entity.Client and cluster config options #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-19 01:13:32 +02:00
parent f6f4b85e66
commit 13e1c751d4
42 changed files with 612 additions and 528 deletions

View File

@@ -172,7 +172,7 @@ func ClusterGetNode(router *gin.RouterGroup) {
}) })
} }
// ClusterUpdateNode updates mutable fields: type, labels, internalUrl. // 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 id path string true "node id" // @Param id path string true "node id"
// @Param node body object true "properties to update (type, labels, internalUrl)" // @Param node body object true "properties to update (role, labels, advertiseUrl)"
// @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/{id} [patch] // @Router /api/v1/cluster/nodes/{id} [patch]
@@ -202,9 +202,9 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
id := c.Param("id") id := c.Param("id")
var req struct { var req struct {
Type string `json:"type"` Role string `json:"role"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
InternalUrl string `json:"internalUrl"` AdvertiseUrl string `json:"advertiseUrl"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -226,16 +226,16 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
return return
} }
if req.Type != "" { if req.Role != "" {
n.Type = clean.TypeLowerDash(req.Type) n.Role = clean.TypeLowerDash(req.Role)
} }
if req.Labels != nil { if req.Labels != nil {
n.Labels = req.Labels n.Labels = req.Labels
} }
if req.InternalUrl != "" { if req.AdvertiseUrl != "" {
n.Internal = req.InternalUrl n.AdvertiseUrl = req.AdvertiseUrl
} }
n.UpdatedAt = time.Now().UTC().Format(time.RFC3339) n.UpdatedAt = time.Now().UTC().Format(time.RFC3339)

View File

@@ -25,7 +25,7 @@ import (
// @Tags Cluster // @Tags Cluster
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body object true "registration payload (nodeName required; optional: nodeType, labels, internalUrl, rotate, rotateSecret)" // @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, rotate, 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]
@@ -50,7 +50,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
} }
// Token check (Bearer). // Token check (Bearer).
expected := conf.PortalToken() expected := conf.JoinToken()
token := header.BearerToken(c) token := header.BearerToken(c)
if expected == "" || token == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 { if expected == "" || token == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 {
@@ -62,12 +62,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Parse request. // Parse request.
var req struct { var req struct {
NodeName string `json:"nodeName"` NodeName string `json:"nodeName"`
NodeType string `json:"nodeType"` NodeRole string `json:"nodeRole"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
InternalUrl string `json:"internalUrl"` AdvertiseUrl string `json:"advertiseUrl"`
RotateDB bool `json:"rotate"` RotateDatabase bool `json:"rotateDatabase"`
RotateSecret bool `json:"rotateSecret"` RotateSecret bool `json:"rotateSecret"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -115,15 +115,15 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
} }
// Ensure that a database for this node exists (rotation optional). // Ensure that a database for this node exists (rotation optional).
creds, _, credsErr := provisioner.EnsureNodeDB(c, conf, name, req.RotateDB) creds, _, credsErr := provisioner.EnsureNodeDatabase(c, conf, name, req.RotateDatabase)
if credsErr != nil { if credsErr != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure db", event.Failed, "%s"}, clean.Error(credsErr)) event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(credsErr))
c.JSON(http.StatusConflict, gin.H{"error": credsErr.Error()}) c.JSON(http.StatusConflict, gin.H{"error": credsErr.Error()})
return return
} }
if req.RotateDB { if req.RotateDatabase {
n.DB.RotAt = creds.LastRotatedAt n.DB.RotAt = creds.LastRotatedAt
if putErr := regy.Put(n); putErr != nil { if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr)) event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr))
@@ -137,17 +137,17 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
Node: reg.BuildClusterNode(*n, opts), Node: reg.BuildClusterNode(*n, opts),
DB: cluster.RegisterDB{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User}, Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User},
Secrets: respSecret, Secrets: respSecret,
AlreadyRegistered: true, AlreadyRegistered: true,
AlreadyProvisioned: true, AlreadyProvisioned: true,
} }
// Include password/dsn only if rotated now. // Include password/dsn only if rotated now.
if req.RotateDB { if req.RotateDatabase {
resp.DB.Password = creds.Password resp.Database.Password = creds.Password
resp.DB.DSN = creds.DSN resp.Database.DSN = creds.DSN
resp.DB.DBLastRotatedAt = creds.LastRotatedAt resp.Database.RotatedAt = creds.LastRotatedAt
} }
c.Header(header.CacheControl, header.CacheControlNoStore) c.Header(header.CacheControl, header.CacheControlNoStore)
@@ -157,11 +157,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// New node. // New node.
n := &reg.Node{ n := &reg.Node{
ID: rnd.UUID(), ID: rnd.UUID(),
Name: name, Name: name,
Type: clean.TypeLowerDash(req.NodeType), Role: clean.TypeLowerDash(req.NodeRole),
Labels: req.Labels, Labels: req.Labels,
Internal: req.InternalUrl, AdvertiseUrl: req.AdvertiseUrl,
} }
// Generate node secret. // Generate node secret.
@@ -169,9 +169,9 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
n.SecretRot = nowRFC3339() n.SecretRot = nowRFC3339()
// Ensure DB (force rotation at create path to return password). // Ensure DB (force rotation at create path to return password).
creds, _, err := provisioner.EnsureNodeDB(c, conf, name, true) creds, _, err := provisioner.EnsureNodeDatabase(c, conf, name, true)
if err != nil { if err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure db", event.Failed, "%s"}, clean.Error(err)) event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(err))
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return return
} }
@@ -186,7 +186,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)), Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{NodeSecret: n.Secret, NodeSecretLastRotatedAt: n.SecretRot}, Secrets: &cluster.RegisterSecrets{NodeSecret: n.Secret, NodeSecretLastRotatedAt: n.SecretRot},
DB: cluster.RegisterDB{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, DBLastRotatedAt: creds.LastRotatedAt}, Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.LastRotatedAt},
AlreadyRegistered: false, AlreadyRegistered: false,
AlreadyProvisioned: false, AlreadyProvisioned: false,
} }

View File

@@ -13,7 +13,7 @@ import (
func TestClusterNodesRegister(t *testing.T) { func TestClusterNodesRegister(t *testing.T) {
t.Run("FeatureDisabled", func(t *testing.T) { t.Run("FeatureDisabled", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Instance 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"}`)
@@ -22,7 +22,7 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("MissingToken", func(t *testing.T) { t.Run("MissingToken", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal 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"}`)
@@ -31,8 +31,8 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("DriverConflict", func(t *testing.T) { t.Run("DriverConflict", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
conf.Options().PortalToken = "t0k3n" conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router) ClusterNodesRegister(router)
// With SQLite driver in tests, provisioning should fail with conflict. // With SQLite driver in tests, provisioning should fail with conflict.
@@ -43,8 +43,8 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("BadName", func(t *testing.T) { t.Run("BadName", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
conf.Options().PortalToken = "t0k3n" conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Empty nodeName → 400 // Empty nodeName → 400
@@ -54,15 +54,15 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) { t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
conf.Options().PortalToken = "t0k3n" conf.Options().JoinToken = "t0k3n"
ClusterNodesRegister(router) ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path // Pre-create node in registry so handler goes through existing-node path
// and rotates the secret before attempting DB ensure. // and rotates the secret before attempting DB ensure.
regy, err := reg.NewFileRegistry(conf) regy, err := reg.NewFileRegistry(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{ID: "test-id", Name: "pp-node-01", Type: "instance"} n := &reg.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
n.Secret = "oldsecret" n.Secret = "oldsecret"
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))

View File

@@ -12,7 +12,7 @@ import (
func TestClusterEndpoints(t *testing.T) { func TestClusterEndpoints(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterListNodes(router) ClusterListNodes(router)
ClusterGetNode(router) ClusterGetNode(router)
@@ -26,9 +26,9 @@ func TestClusterEndpoints(t *testing.T) {
// Seed nodes in the registry // Seed nodes in the registry
regy, err := reg.NewFileRegistry(conf) regy, err := reg.NewFileRegistry(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{ID: "n1", Name: "pp-node-01", Type: "instance"} n := &reg.Node{ID: "n1", Name: "pp-node-01", Role: "instance"}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
n2 := &reg.Node{ID: "n2", Name: "pp-node-02", Type: "service"} n2 := &reg.Node{ID: "n2", Name: "pp-node-02", Role: "service"}
assert.NoError(t, regy.Put(n2)) assert.NoError(t, regy.Put(n2))
// Get by id // Get by id
@@ -40,7 +40,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/n1", `{"internalUrl":"http://n1:2342"}`) r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/n1", `{"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
@@ -63,7 +63,7 @@ func TestClusterEndpoints(t *testing.T) {
// Test that ClusterGetNode validates the :id path parameter and rejects unsafe values. // Test that ClusterGetNode validates the :id path parameter and rejects unsafe values.
func TestClusterGetNode_IDValidation(t *testing.T) { func TestClusterGetNode_IDValidation(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
// Register route under test. // Register route under test.
ClusterGetNode(router) ClusterGetNode(router)
@@ -71,7 +71,7 @@ func TestClusterGetNode_IDValidation(t *testing.T) {
// Seed a node with a simple, valid id. // Seed a node with a simple, valid id.
regy, err := reg.NewFileRegistry(conf) regy, err := reg.NewFileRegistry(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{ID: "n1", Name: "pp-node-99", Type: "instance"} n := &reg.Node{ID: "n1", Name: "pp-node-99", Role: "instance"}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Valid ID returns 200. // Valid ID returns 200.

View File

@@ -19,7 +19,7 @@ import (
func TestClusterPermissions(t *testing.T) { func TestClusterPermissions(t *testing.T) {
t.Run("UnauthorizedWhenPublicDisabled", func(t *testing.T) { t.Run("UnauthorizedWhenPublicDisabled", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
// Disable public mode so Auth requires a session. // Disable public mode so Auth requires a session.
conf.SetAuthMode(config.AuthModePasswd) conf.SetAuthMode(config.AuthModePasswd)
@@ -33,7 +33,7 @@ func TestClusterPermissions(t *testing.T) {
t.Run("ForbiddenFromCDN", func(t *testing.T) { t.Run("ForbiddenFromCDN", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterListNodes(router) ClusterListNodes(router)
@@ -47,7 +47,7 @@ func TestClusterPermissions(t *testing.T) {
t.Run("AdminCanAccess", func(t *testing.T) { t.Run("AdminCanAccess", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterSummary(router) ClusterSummary(router)
token := AuthenticateAdmin(app, router) token := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token)
@@ -58,7 +58,7 @@ func TestClusterPermissions(t *testing.T) {
t.Run("ClientInsufficientScope", func(t *testing.T) { t.Run("ClientInsufficientScope", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
conf.SetAuthMode(config.AuthModePasswd) conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)

View File

@@ -46,10 +46,10 @@ func ClusterSummary(router *gin.RouterGroup) {
nodes, _ := regy.List() nodes, _ := regy.List()
c.JSON(http.StatusOK, cluster.SummaryResponse{ c.JSON(http.StatusOK, cluster.SummaryResponse{
PortalUUID: conf.PortalUUID(), UUID: conf.ClusterUUID(),
Nodes: len(nodes), Nodes: len(nodes),
DB: cluster.DBInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()}, Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339), Time: time.Now().UTC().Format(time.RFC3339),
}) })
}) })
} }

View File

@@ -20,7 +20,7 @@ func TestClusterGetTheme(t *testing.T) {
t.Run("FeatureDisabled", func(t *testing.T) { t.Run("FeatureDisabled", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
// Ensure portal feature flag is disabled. // Ensure portal feature flag is disabled.
conf.Options().NodeType = cluster.Instance conf.Options().NodeRole = cluster.RoleInstance
ClusterGetTheme(router) ClusterGetTheme(router)
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme") r := PerformRequest(app, http.MethodGet, "/api/v1/cluster/theme")
@@ -30,7 +30,7 @@ func TestClusterGetTheme(t *testing.T) {
t.Run("NotFound", func(t *testing.T) { t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint. // Enable portal feature flag for this endpoint.
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterGetTheme(router) ClusterGetTheme(router)
missing := filepath.Join(os.TempDir(), "photoprism-test-missing-theme") missing := filepath.Join(os.TempDir(), "photoprism-test-missing-theme")
@@ -48,7 +48,7 @@ func TestClusterGetTheme(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("Success", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint. // Enable portal feature flag for this endpoint.
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterGetTheme(router) ClusterGetTheme(router)
tempTheme, err := os.MkdirTemp("", "pp-theme-*") tempTheme, err := os.MkdirTemp("", "pp-theme-*")
@@ -104,7 +104,7 @@ func TestClusterGetTheme(t *testing.T) {
t.Run("Empty", func(t *testing.T) { t.Run("Empty", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
// Enable portal feature flag for this endpoint. // Enable portal feature flag for this endpoint.
conf.Options().NodeType = cluster.Portal conf.Options().NodeRole = cluster.RolePortal
ClusterGetTheme(router) ClusterGetTheme(router)
// Create an empty temporary theme directory (no includable files). // Create an empty temporary theme directory (no includable files).

View File

@@ -1719,7 +1719,7 @@
"operationId": "ClusterNodesRegister", "operationId": "ClusterNodesRegister",
"parameters": [ "parameters": [
{ {
"description": "registration payload (nodeName required; optional: nodeType, labels, internalUrl, rotate, rotateSecret)", "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, rotate, rotateSecret)",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -1898,7 +1898,7 @@
"required": true "required": true
}, },
{ {
"description": "properties to update (type, labels, internalUrl)", "description": "properties to update (role, labels, advertiseUrl)",
"name": "node", "name": "node",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -6195,18 +6195,18 @@
"cluster.Node": { "cluster.Node": {
"type": "object", "type": "object",
"properties": { "properties": {
"advertiseUrl": {
"type": "string"
},
"createdAt": { "createdAt": {
"type": "string" "type": "string"
}, },
"db": { "database": {
"$ref": "#/definitions/cluster.NodeDB" "$ref": "#/definitions/cluster.NodeDatabase"
}, },
"id": { "id": {
"type": "string" "type": "string"
}, },
"internalUrl": {
"type": "string"
},
"labels": { "labels": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
@@ -6216,7 +6216,7 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"type": { "role": {
"type": "string" "type": "string"
}, },
"updatedAt": { "updatedAt": {
@@ -6224,13 +6224,13 @@
} }
} }
}, },
"cluster.NodeDB": { "cluster.NodeDatabase": {
"type": "object", "type": "object",
"properties": { "properties": {
"dbLastRotatedAt": { "name": {
"type": "string" "type": "string"
}, },
"name": { "rotatedAt": {
"type": "string" "type": "string"
}, },
"user": { "user": {
@@ -6238,12 +6238,9 @@
} }
} }
}, },
"cluster.RegisterDB": { "cluster.RegisterDatabase": {
"type": "object", "type": "object",
"properties": { "properties": {
"dbLastRotatedAt": {
"type": "string"
},
"dsn": { "dsn": {
"type": "string" "type": "string"
}, },
@@ -6259,6 +6256,9 @@
"port": { "port": {
"type": "integer" "type": "integer"
}, },
"rotatedAt": {
"type": "string"
},
"user": { "user": {
"type": "string" "type": "string"
} }
@@ -6273,8 +6273,8 @@
"alreadyRegistered": { "alreadyRegistered": {
"type": "boolean" "type": "boolean"
}, },
"db": { "database": {
"$ref": "#/definitions/cluster.RegisterDB" "$ref": "#/definitions/cluster.RegisterDatabase"
}, },
"node": { "node": {
"$ref": "#/definitions/cluster.Node" "$ref": "#/definitions/cluster.Node"
@@ -6306,15 +6306,15 @@
"cluster.SummaryResponse": { "cluster.SummaryResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"UUID": {
"type": "string"
},
"db": { "db": {
"$ref": "#/definitions/cluster.DBInfo" "$ref": "#/definitions/cluster.DBInfo"
}, },
"nodes": { "nodes": {
"type": "integer" "type": "integer"
}, },
"portalUUID": {
"type": "string"
},
"time": { "time": {
"type": "string" "type": "string"
} }
@@ -6487,9 +6487,6 @@
"IndexWorkers": { "IndexWorkers": {
"type": "integer" "type": "integer"
}, },
"InternalUrl": {
"type": "string"
},
"JpegQuality": { "JpegQuality": {
"type": "integer" "type": "integer"
}, },
@@ -9463,8 +9460,12 @@
1000000000, 1000000000,
60000000000, 60000000000,
3600000000000, 3600000000000,
-9223372036854775808, 1,
9223372036854775807, 1000,
1000000,
1000000000,
60000000000,
3600000000000,
1, 1,
1000, 1000,
1000000, 1000000,
@@ -9481,8 +9482,12 @@
"Second", "Second",
"Minute", "Minute",
"Hour", "Hour",
"minDuration", "Nanosecond",
"maxDuration", "Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"Nanosecond", "Nanosecond",
"Microsecond", "Microsecond",
"Millisecond", "Millisecond",

View File

@@ -9,7 +9,7 @@ import (
func TestExitCodes_Register_ValidationAndUnauthorized(t *testing.T) { func TestExitCodes_Register_ValidationAndUnauthorized(t *testing.T) {
t.Run("MissingURL", func(t *testing.T) { t.Run("MissingURL", func(t *testing.T) {
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--type", "instance", "--portal-token", "token"}) ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
err := ClusterRegisterCommand.Action(ctx) err := ClusterRegisterCommand.Action(ctx)
assert.Error(t, err) assert.Error(t, err)
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
@@ -52,7 +52,7 @@ func TestExitCodes_Nodes_PortalOnlyMisuse(t *testing.T) {
} }
}) })
t.Run("ModNotPortal", func(t *testing.T) { t.Run("ModNotPortal", func(t *testing.T) {
ctx := NewTestContext([]string{"mod", "any", "--type", "instance", "-y"}) ctx := NewTestContext([]string{"mod", "any", "--role", "instance", "-y"})
err := ClusterNodesModCommand.Action(ctx) err := ClusterNodesModCommand.Action(ctx)
assert.Error(t, err) assert.Error(t, err)
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {

View File

@@ -69,7 +69,7 @@ func clusterNodesListAction(ctx *cli.Context) error {
page := items[offset:end] page := items[offset:end]
// Build admin view (include internal URL and DB meta). // Build admin view (include internal URL and DB meta).
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true} opts := reg.NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}
out := reg.BuildClusterNodes(page, opts) out := reg.BuildClusterNodes(page, opts)
if ctx.Bool("json") { if ctx.Bool("json") {
@@ -78,15 +78,15 @@ func clusterNodesListAction(ctx *cli.Context) error {
return nil return nil
} }
cols := []string{"ID", "Name", "Type", "Labels", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"} cols := []string{"ID", "Name", "Role", "Labels", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
rows := make([][]string, 0, len(out)) rows := make([][]string, 0, len(out))
for _, n := range out { for _, n := range out {
var dbName, dbUser, dbRot string var dbName, dbUser, dbRot string
if n.DB != nil { if n.Database != nil {
dbName, dbUser, dbRot = n.DB.Name, n.DB.User, n.DB.DBLastRotatedAt dbName, dbUser, dbRot = n.Database.Name, n.Database.User, n.Database.RotatedAt
} }
rows = append(rows, []string{ rows = append(rows, []string{
n.ID, n.Name, n.Type, formatLabels(n.Labels), n.InternalURL, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt, n.ID, n.Name, n.Role, formatLabels(n.Labels), n.AdvertiseUrl, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
}) })
} }

View File

@@ -14,8 +14,8 @@ import (
// flags for nodes mod // flags for nodes mod
var ( var (
nodesModTypeFlag = &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "node `TYPE` (portal, instance, service)"} nodesModRoleFlag = &cli.StringFlag{Name: "role", Aliases: []string{"t"}, Usage: "node `ROLE` (portal, instance, service)"}
nodesModInternal = &cli.StringFlag{Name: "internal-url", Aliases: []string{"i"}, Usage: "internal service `URL`"} nodesModInternal = &cli.StringFlag{Name: "advertise-url", Aliases: []string{"i"}, Usage: "internal service `URL`"}
nodesModLabel = &cli.StringSliceFlag{Name: "label", Aliases: []string{"l"}, Usage: "`k=v` label (repeatable)"} nodesModLabel = &cli.StringSliceFlag{Name: "label", Aliases: []string{"l"}, Usage: "`k=v` label (repeatable)"}
) )
@@ -24,7 +24,7 @@ var ClusterNodesModCommand = &cli.Command{
Name: "mod", Name: "mod",
Usage: "Updates node properties (Portal-only)", Usage: "Updates node properties (Portal-only)",
ArgsUsage: "<id|name>", ArgsUsage: "<id|name>",
Flags: []cli.Flag{nodesModTypeFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}}, Flags: []cli.Flag{nodesModRoleFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}},
Action: clusterNodesModAction, Action: clusterNodesModAction,
} }
@@ -56,11 +56,11 @@ func clusterNodesModAction(ctx *cli.Context) error {
return cli.Exit(fmt.Errorf("node not found"), 3) return cli.Exit(fmt.Errorf("node not found"), 3)
} }
if v := ctx.String("type"); v != "" { if v := ctx.String("role"); v != "" {
n.Type = clean.TypeLowerDash(v) n.Role = clean.TypeLowerDash(v)
} }
if v := ctx.String("internal-url"); v != "" { if v := ctx.String("advertise-url"); v != "" {
n.Internal = v n.AdvertiseUrl = v
} }
if labels := ctx.StringSlice("label"); len(labels) > 0 { if labels := ctx.StringSlice("label"); len(labels) > 0 {
if n.Labels == nil { if n.Labels == nil {

View File

@@ -16,10 +16,10 @@ import (
) )
var ( var (
rotateDBFlag = &cli.BoolFlag{Name: "db", Usage: "rotate DB credentials"} rotateDatabaseFlag = &cli.BoolFlag{Name: "database", Usage: "rotate DB credentials"}
rotateSecretFlag = &cli.BoolFlag{Name: "secret", Usage: "rotate node secret"} rotateSecretFlag = &cli.BoolFlag{Name: "secret", Usage: "rotate node secret"}
rotatePortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"} rotatePortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
rotatePortalTok = &cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to config)"} rotatePortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
) )
// ClusterNodesRotateCommand triggers rotation via the register endpoint. // ClusterNodesRotateCommand triggers rotation via the register endpoint.
@@ -27,7 +27,7 @@ var ClusterNodesRotateCommand = &cli.Command{
Name: "rotate", Name: "rotate",
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)", Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
ArgsUsage: "<id|name>", ArgsUsage: "<id|name>",
Flags: append([]cli.Flag{rotateDBFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...), Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...),
Action: clusterNodesRotateAction, Action: clusterNodesRotateAction,
} }
@@ -64,28 +64,28 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
if portalURL == "" { if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2) return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
} }
token := ctx.String("portal-token") token := ctx.String("join-token")
if token == "" { if token == "" {
token = conf.PortalToken() token = conf.JoinToken()
} }
if token == "" { if token == "" {
token = os.Getenv(config.EnvVar("portal-token")) token = os.Getenv(config.EnvVar("join-token"))
} }
if token == "" { if token == "" {
return cli.Exit(fmt.Errorf("portal token is required (use --portal-token or set portal-token)"), 2) return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
} }
// Default: rotate DB only if no flag given (safer default) // Default: rotate DB only if no flag given (safer default)
rotateDB := ctx.Bool("db") || (!ctx.IsSet("db") && !ctx.IsSet("secret")) rotateDatabase := ctx.Bool("database") || (!ctx.IsSet("database") && !ctx.IsSet("secret"))
rotateSecret := ctx.Bool("secret") rotateSecret := ctx.Bool("secret")
confirmed := RunNonInteractively(ctx.Bool("yes")) confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed { if !confirmed {
var what string var what string
switch { switch {
case rotateDB && rotateSecret: case rotateDatabase && rotateSecret:
what = "DB credentials and node secret" what = "DB credentials and node secret"
case rotateDB: case rotateDatabase:
what = "DB credentials" what = "DB credentials"
case rotateSecret: case rotateSecret:
what = "node secret" what = "node secret"
@@ -99,7 +99,7 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
body := map[string]interface{}{ body := map[string]interface{}{
"nodeName": name, "nodeName": name,
"rotate": rotateDB, "rotate": rotateDatabase,
"rotateSecret": rotateSecret, "rotateSecret": rotateSecret,
} }
b, _ := json.Marshal(body) b, _ := json.Marshal(body)
@@ -131,22 +131,22 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
return nil return nil
} }
cols := []string{"ID", "Name", "Type", "DB Name", "DB User", "Host", "Port"} cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Type, resp.DB.Name, resp.DB.User, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port)}} rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, resp.Database.Name, resp.Database.User, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx)) out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out) fmt.Printf("\n%s\n", out)
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.DB.Password != "" { if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:") fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" { if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.DB.Password)) fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" { } else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", "")) fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
} else if resp.DB.Password != "" { } else if resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password)) fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
} }
if resp.DB.DSN != "" { if resp.Database.DSN != "" {
fmt.Printf("DSN: %s\n", resp.DB.DSN) fmt.Printf("DSN: %s\n", resp.Database.DSN)
} }
} }
return nil return nil

View File

@@ -50,7 +50,7 @@ func clusterNodesShowAction(ctx *cli.Context) error {
return cli.Exit(fmt.Errorf("node not found"), 3) return cli.Exit(fmt.Errorf("node not found"), 3)
} }
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true} opts := reg.NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}
dto := reg.BuildClusterNode(*n, opts) dto := reg.BuildClusterNode(*n, opts)
if ctx.Bool("json") { if ctx.Bool("json") {
@@ -59,12 +59,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
return nil return nil
} }
cols := []string{"ID", "Name", "Type", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"} cols := []string{"ID", "Name", "Role", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
var dbName, dbUser, dbRot string var dbName, dbUser, dbRot string
if dto.DB != nil { if dto.Database != nil {
dbName, dbUser, dbRot = dto.DB.Name, dto.DB.User, dto.DB.DBLastRotatedAt dbName, dbUser, dbRot = dto.Database.Name, dto.Database.User, dto.Database.RotatedAt
} }
rows := [][]string{{dto.ID, dto.Name, dto.Type, dto.InternalURL, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}} rows := [][]string{{dto.ID, dto.Name, dto.Role, dto.AdvertiseUrl, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out) fmt.Printf("\n%s\n", out)
if err != nil { if err != nil {

View File

@@ -24,23 +24,23 @@ import (
// flags for register // flags for register
var ( var (
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"} regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
regTypeFlag = &cli.StringFlag{Name: "type", Usage: "node `TYPE` (instance, service)", Value: "instance"} regRoleFlag = &cli.StringFlag{Name: "role", Usage: "node `ROLE` (instance, service)", Value: "instance"}
regIntUrlFlag = &cli.StringFlag{Name: "internal-url", Usage: "internal service `URL`"} regIntUrlFlag = &cli.StringFlag{Name: "advertise-url", Usage: "internal service `URL`"}
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"} regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
regRotateDB = &cli.BoolFlag{Name: "rotate", Usage: "rotates the node's database password"} regRotateDatabase = &cli.BoolFlag{Name: "rotate", Usage: "rotates the node's database password"}
regRotateSec = &cli.BoolFlag{Name: "rotate-secret", Usage: "rotates the node's secret used for JWT"} regRotateSec = &cli.BoolFlag{Name: "rotate-secret", Usage: "rotates the node's secret used for JWT"}
regPortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"} regPortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
regPortalTok = &cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to config)"} regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"} regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"}
regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"} regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
) )
// ClusterRegisterCommand registers a node with the Portal via HTTP. // ClusterRegisterCommand registers a node with the Portal via HTTP.
var ClusterRegisterCommand = &cli.Command{ var ClusterRegisterCommand = &cli.Command{
Name: "register", Name: "register",
Usage: "Registers/rotates a node via Portal (HTTP)", Usage: "Registers/rotates a node via Portal (HTTP)",
Flags: append(append([]cli.Flag{regNameFlag, regTypeFlag, regIntUrlFlag, regLabelFlag, regRotateDB, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, JsonFlag}, report.CliFlags...)), Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, JsonFlag}, report.CliFlags...)),
Action: clusterRegisterAction, Action: clusterRegisterAction,
} }
@@ -54,11 +54,11 @@ func clusterRegisterAction(ctx *cli.Context) error {
if name == "" { if name == "" {
return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2) return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2)
} }
nodeType := clean.TypeLowerDash(ctx.String("type")) nodeRole := clean.TypeLowerDash(ctx.String("role"))
switch nodeType { switch nodeRole {
case "instance", "service": case "instance", "service":
default: default:
return cli.Exit(fmt.Errorf("invalid --type (must be instance or service)"), 2) return cli.Exit(fmt.Errorf("invalid --role (must be instance or service)"), 2)
} }
portalURL := ctx.String("portal-url") portalURL := ctx.String("portal-url")
@@ -68,19 +68,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
if portalURL == "" { if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2) return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
} }
token := ctx.String("portal-token") token := ctx.String("join-token")
if token == "" { if token == "" {
token = conf.PortalToken() token = conf.JoinToken()
} }
if token == "" { if token == "" {
return cli.Exit(fmt.Errorf("portal token is required (use --portal-token or set portal-token)"), 2) return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
} }
body := map[string]interface{}{ body := map[string]interface{}{
"nodeName": name, "nodeName": name,
"nodeType": nodeType, "nodeRole": nodeRole,
"labels": parseLabelSlice(ctx.StringSlice("label")), "labels": parseLabelSlice(ctx.StringSlice("label")),
"internalUrl": ctx.String("internal-url"), "advertiseUrl": ctx.String("advertise-url"),
"rotate": ctx.Bool("rotate"), "rotate": ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"), "rotateSecret": ctx.Bool("rotate-secret"),
} }
@@ -116,31 +116,31 @@ func clusterRegisterAction(ctx *cli.Context) error {
fmt.Println(string(jb)) fmt.Println(string(jb))
} else { } else {
// Human-readable: node row and credentials if present // Human-readable: node row and credentials if present
cols := []string{"ID", "Name", "Type", "DB Name", "DB User", "Host", "Port"} cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "Port"}
var dbName, dbUser string var dbName, dbUser string
if resp.DB.Name != "" { if resp.Database.Name != "" {
dbName = resp.DB.Name dbName = resp.Database.Name
} }
if resp.DB.User != "" { if resp.Database.User != "" {
dbUser = resp.DB.User dbUser = resp.Database.User
} }
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Type, dbName, dbUser, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port)}} rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Role, dbName, dbUser, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx)) out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out) fmt.Printf("\n%s\n", out)
// Secrets/credentials block if any // Secrets/credentials block if any
// Show secrets in up to two tables, then print DSN if present // Show secrets in up to two tables, then print DSN if present
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.DB.Password != "" { if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.Database.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:") fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" { if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.DB.Password)) fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.Database.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" { } else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", "")) fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
} else if resp.DB.Password != "" { } else if resp.Database.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password)) fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
} }
if resp.DB.DSN != "" { if resp.Database.DSN != "" {
fmt.Printf("DSN: %s\n", resp.DB.DSN) fmt.Printf("DSN: %s\n", resp.Database.DSN)
} }
} }
} }
@@ -256,13 +256,13 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse
} }
// DB settings (MySQL/MariaDB only) // DB settings (MySQL/MariaDB only)
if resp.DB.Name != "" && resp.DB.User != "" { if resp.Database.Name != "" && resp.Database.User != "" {
if err := mergeOptionsYaml(conf, map[string]any{ if err := mergeOptionsYaml(conf, map[string]any{
"DatabaseDriver": config.MySQL, "DatabaseDriver": config.MySQL,
"DatabaseName": resp.DB.Name, "DatabaseName": resp.Database.Name,
"DatabaseServer": fmt.Sprintf("%s:%d", resp.DB.Host, resp.DB.Port), "DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
"DatabaseUser": resp.DB.User, "DatabaseUser": resp.Database.User,
"DatabasePassword": resp.DB.Password, "DatabasePassword": resp.Database.Password,
}); err != nil { }); err != nil {
return err return err
} }

View File

@@ -29,8 +29,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"}, "secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": false, "alreadyRegistered": false,
"alreadyProvisioned": false, "alreadyProvisioned": false,
@@ -39,7 +39,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
defer ts.Close() defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{ out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-02", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json", "register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
}) })
assert.NoError(t, err) assert.NoError(t, err)
// Parse JSON // Parse JSON
@@ -69,8 +69,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"}, "secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
@@ -79,13 +79,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
defer ts.Close() defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive") _ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI") defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--secret", "--yes", "pp-node-03", "rotate", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03") assert.Contains(t, out, "pp-node-03")
@@ -107,8 +107,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"}, "secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
@@ -117,10 +117,10 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
defer ts.Close() defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive") _ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI") defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--secret", "--yes", "pp-node-04", "rotate", "--json", "--db", "--secret", "--yes", "pp-node-04",
@@ -160,8 +160,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n3", "name": "pp-node-05", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n3", "name": "pp-node-05", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
// secrets omitted on DB-only rotate // secrets omitted on DB-only rotate
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
@@ -170,10 +170,10 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_YES", "true") _ = os.Setenv("PHOTOPRISM_YES", "true")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
defer os.Unsetenv("PHOTOPRISM_YES") defer os.Unsetenv("PHOTOPRISM_YES")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--yes", "pp-node-05", "rotate", "--json", "--db", "--yes", "pp-node-05",
@@ -212,8 +212,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret4", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"}, "secrets": map[string]any{"nodeSecret": "secret4", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
@@ -222,9 +222,9 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--secret", "--yes", "pp-node-06", "rotate", "--json", "--secret", "--yes", "pp-node-06",
}) })
@@ -241,7 +241,7 @@ func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{ _, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-unauth", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "wrong", "--json", "register", "--name", "pp-node-unauth", "--role", "instance", "--portal-url", ts.URL, "--join-token", "wrong", "--json",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 4, ec.ExitCode()) assert.Equal(t, 4, ec.ExitCode())
@@ -257,7 +257,7 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{ _, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-conflict", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json", "register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode()) assert.Equal(t, 5, ec.ExitCode())
@@ -273,7 +273,7 @@ func TestClusterRegister_HTTPBadRequest(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{ _, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp node invalid", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json", "register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode()) assert.Equal(t, 2, ec.ExitCode())
@@ -293,8 +293,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n7", "name": "pp-node-rl", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
}) })
@@ -302,7 +302,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
defer ts.Close() defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{ out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-rl", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json", "register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--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())
@@ -315,7 +315,7 @@ func TestClusterNodesRotate_HTTPUnauthorized_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ _, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=wrong", "--db", "--yes", "pp-node-x", "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=wrong", "--db", "--yes", "pp-node-x",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 4, ec.ExitCode()) assert.Equal(t, 4, ec.ExitCode())
@@ -331,7 +331,7 @@ func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ _, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-x", "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-x",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode()) assert.Equal(t, 5, ec.ExitCode())
@@ -347,7 +347,7 @@ func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ _, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp node invalid", "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp node invalid",
}) })
if ec, ok := err.(cli.ExitCoder); ok { if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode()) assert.Equal(t, 2, ec.ExitCode())
@@ -367,8 +367,8 @@ 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n8", "name": "pp-node-rl2", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
}) })
@@ -376,13 +376,13 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{ out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-rl2", "rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--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_RotateDB_JSON(t *testing.T) { func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" { if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r) http.NotFound(w, r)
@@ -400,8 +400,8 @@ func TestClusterRegister_RotateDB_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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n5", "name": "pp-node-07", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n5", "name": "pp-node-07", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
}) })
@@ -409,7 +409,7 @@ func TestClusterRegister_RotateDB_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{ out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-07", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json", "register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--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())
@@ -441,8 +441,8 @@ func TestClusterRegister_RotateSecret_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{ _ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"}, "node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"}, "database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "pwd8secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"}, "secrets": map[string]any{"nodeSecret": "pwd8secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true, "alreadyRegistered": true,
"alreadyProvisioned": true, "alreadyProvisioned": true,
@@ -451,7 +451,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
defer ts.Close() defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{ out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-08", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate-secret", "--json", "register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--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())

View File

@@ -35,10 +35,10 @@ func clusterSummaryAction(ctx *cli.Context) error {
nodes, _ := r.List() nodes, _ := r.List()
resp := cluster.SummaryResponse{ resp := cluster.SummaryResponse{
PortalUUID: conf.PortalUUID(), UUID: conf.ClusterUUID(),
Nodes: len(nodes), Nodes: len(nodes),
DB: cluster.DBInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()}, Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339), Time: time.Now().UTC().Format(time.RFC3339),
} }
if ctx.Bool("json") { if ctx.Bool("json") {
@@ -48,7 +48,7 @@ func clusterSummaryAction(ctx *cli.Context) error {
} }
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"} cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.PortalUUID, fmt.Sprintf("%d", resp.Nodes), resp.DB.Driver, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port), resp.Time}} rows := [][]string{{resp.UUID, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out) fmt.Printf("\n%s\n", out)
return err return err

View File

@@ -34,8 +34,8 @@ func TestClusterNodesListCommand(t *testing.T) {
func TestClusterNodesShowCommand(t *testing.T) { func TestClusterNodesShowCommand(t *testing.T) {
t.Run("NotFound", func(t *testing.T) { t.Run("NotFound", func(t *testing.T) {
_ = os.Setenv("PHOTOPRISM_NODE_TYPE", "portal") _ = os.Setenv("PHOTOPRISM_NODE_ROLE", "portal")
defer os.Unsetenv("PHOTOPRISM_NODE_TYPE") defer os.Unsetenv("PHOTOPRISM_NODE_ROLE")
out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"}) out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"})
assert.Error(t, err) assert.Error(t, err)
_ = out _ = out
@@ -52,7 +52,7 @@ func TestClusterThemePullCommand(t *testing.T) {
func TestClusterRegisterCommand(t *testing.T) { func TestClusterRegisterCommand(t *testing.T) {
t.Run("ValidationMissingURL", func(t *testing.T) { t.Run("ValidationMissingURL", func(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--type", "instance", "--portal-token", "token"}) out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
assert.Error(t, err) assert.Error(t, err)
_ = out _ = out
}) })
@@ -61,7 +61,7 @@ func TestClusterRegisterCommand(t *testing.T) {
func TestClusterSuccessPaths_PortalLocal(t *testing.T) { func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Enable portal mode for local admin commands. // Enable portal mode for local admin commands.
c := get.Config() c := get.Config()
c.Options().NodeType = "portal" c.Options().NodeRole = "portal"
// Ensure registry and theme paths exist. // Ensure registry and theme paths exist.
portCfg := c.PortalConfigPath() portCfg := c.PortalConfigPath()
@@ -77,7 +77,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Create a registry node via FileRegistry. // Create a registry node via FileRegistry.
r, err := reg.NewFileRegistry(c) r, err := reg.NewFileRegistry(c)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Type: "instance", Labels: map[string]string{"env": "test"}} n := &reg.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
// nodes ls (JSON) // nodes ls (JSON)
@@ -121,11 +121,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
defer ts.Close() defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--portal-token=test-token"}) out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=test-token"})
assert.NoError(t, err) assert.NoError(t, err)
// Expect extracted file // Expect extracted file
assert.FileExists(t, filepath.Join(destDir, "test.txt")) assert.FileExists(t, filepath.Join(destDir, "test.txt"))

View File

@@ -30,7 +30,7 @@ var ClusterThemePullCommand = &cli.Command{
&cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""}, &cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""},
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"}, &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"}, &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
&cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to global config)"}, &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
JsonFlag, JsonFlag,
}, },
Action: clusterThemePullAction, Action: clusterThemePullAction,
@@ -50,15 +50,15 @@ func clusterThemePullAction(ctx *cli.Context) error {
if portalURL == "" { if portalURL == "" {
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL") return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
} }
token := ctx.String("portal-token") token := ctx.String("join-token")
if token == "" { if token == "" {
token = conf.PortalToken() token = conf.JoinToken()
} }
if token == "" { if token == "" {
token = os.Getenv(config.EnvVar("portal-token")) token = os.Getenv(config.EnvVar("join-token"))
} }
if token == "" { if token == "" {
return fmt.Errorf("portal-token not configured; set --portal-token or PHOTOPRISM_PORTAL_TOKEN") return fmt.Errorf("join-token not configured; set --join-token or PHOTOPRISM_JOIN_TOKEN")
} }
dest := ctx.Path("dest") dest := ctx.Path("dest")

View File

@@ -34,7 +34,7 @@ var VisionRunCommand = &cli.Command{
Name: "source", Name: "source",
Aliases: []string{"s"}, Aliases: []string{"s"},
Value: entity.SrcImage, Value: entity.SrcImage,
Usage: "custom data source `TYPE` e.g. default, image, meta, vision, or admin", Usage: "custom data source `ROLE` e.g. default, image, meta, vision, or admin",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "force", Name: "force",

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
@@ -12,33 +13,36 @@ import (
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
// NodeName returns the unique name of this node within the cluster (lowercase letters and numbers only). // ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 163 chars).
func (c *Config) NodeName() string { func (c *Config) ClusterDomain() string {
return clean.TypeLowerDash(c.options.NodeName) return c.options.ClusterDomain
} }
// NodeType returns the type of this node for cluster operation (portal, instance, service). // ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal.
func (c *Config) NodeType() string { // Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist.
switch c.options.NodeType { func (c *Config) ClusterUUID() string {
case cluster.Portal, cluster.Instance, cluster.Service: // Use value loaded into options only if it is persisted in the current options.yml.
return c.options.NodeType // This avoids tests (or defaults) loading a UUID from an unrelated file path.
default: if c.options.ClusterUUID != "" {
return cluster.Instance // Respect explicit CLI value if provided.
if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") {
return c.options.ClusterUUID
}
// Otherwise, only trust a persisted value from the current options.yml.
if fs.FileExists(c.OptionsYaml()) {
return c.options.ClusterUUID
}
} }
}
// NodeSecret returns the private node key for intra-cluster communication. // Generate, persist, and cache in memory if still empty.
func (c *Config) NodeSecret() string { id := rnd.UUID()
if c.options.NodeSecret != "" { c.options.ClusterUUID = id
return c.options.NodeSecret
} else if fileName := FlagFilePath("NODE_SECRET"); fileName == "" { if err := c.saveClusterUUID(id); err != nil {
return "" log.Warnf("config: failed to persist ClusterUUID to %s (%s)", c.OptionsYaml(), err)
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read node secret from %s (%s)", fileName, err)
return ""
} else {
return string(b)
} }
return id
} }
// PortalUrl returns the URL of the cluster portal server, if configured. // PortalUrl returns the URL of the cluster portal server, if configured.
@@ -46,28 +50,9 @@ func (c *Config) PortalUrl() string {
return c.options.PortalUrl return c.options.PortalUrl
} }
// PortalToken returns the token required to access the portal API endpoints.
func (c *Config) PortalToken() string {
if c.options.PortalToken != "" {
return c.options.PortalToken
} else if fileName := FlagFilePath("PORTAL_TOKEN"); fileName == "" {
return ""
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read portal token from %s (%s)", fileName, err)
return ""
} else {
return string(b)
}
}
// ClusterPortal returns true if this instance should act as a cluster portal.
func (c *Config) ClusterPortal() bool {
return c.IsPortal()
}
// IsPortal returns true if the configured node type is "portal". // IsPortal returns true if the configured node type is "portal".
func (c *Config) IsPortal() bool { func (c *Config) IsPortal() bool {
return c.NodeType() == cluster.Portal return c.NodeRole() == cluster.RolePortal
} }
// PortalConfigPath returns the path to the default configuration for cluster nodes. // PortalConfigPath returns the path to the default configuration for cluster nodes.
@@ -86,36 +71,66 @@ func (c *Config) PortalThemePath() string {
return c.ThemePath() return c.ThemePath()
} }
// PortalUUID returns a stable UUIDv4 that uniquely identifies the Portal. // JoinToken returns the token required to access the portal API endpoints.
// Precedence: env PHOTOPRISM_PORTAL_UUID -> options.yml (PortalUUID) -> auto-generate and persist. func (c *Config) JoinToken() string {
func (c *Config) PortalUUID() string { if c.options.JoinToken != "" {
// Use value loaded into options only if it is persisted in the current options.yml. return c.options.JoinToken
// This avoids tests (or defaults) loading a UUID from an unrelated file path. } else if fileName := FlagFilePath("JOIN_TOKEN"); fileName == "" {
if c.options.PortalUUID != "" { return ""
// Respect explicit CLI value if provided. } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
if c.cliCtx != nil && c.cliCtx.IsSet("portal-uuid") { log.Warnf("config: failed to read portal token from %s (%s)", fileName, err)
return c.options.PortalUUID return ""
} } else {
// Otherwise, only trust a persisted value from the current options.yml. return string(b)
if fs.FileExists(c.OptionsYaml()) {
return c.options.PortalUUID
}
} }
// Generate, persist, and cache in memory if still empty.
id := rnd.UUID()
c.options.PortalUUID = id
if err := c.savePortalUUID(id); err != nil {
log.Warnf("config: failed to persist PortalUUID to %s (%s)", c.OptionsYaml(), err)
}
return id
} }
// savePortalUUID writes or updates the PortalUUID key in options.yml without // NodeName returns the cluster node NAME (unique in cluster domain; [a-z0-9-]{1,32}).
func (c *Config) NodeName() string {
return clean.TypeLowerDash(c.options.NodeName)
}
// NodeRole returns the cluster node ROLE (portal, instance, or service).
func (c *Config) NodeRole() string {
switch c.options.NodeRole {
case cluster.RolePortal, cluster.RoleInstance, cluster.RoleService:
return c.options.NodeRole
default:
return cluster.RoleInstance
}
}
// NodeID returns the client ID registered with the portal (auto-assigned via join token).
func (c *Config) NodeID() string {
return clean.ID(c.options.NodeID)
}
// NodeSecret returns client SECRET registered with the portal (auto-assigned via join token).
func (c *Config) NodeSecret() string {
if c.options.NodeSecret != "" {
return c.options.NodeSecret
} else if fileName := FlagFilePath("NODE_SECRET"); fileName == "" {
return ""
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read node secret from %s (%s)", fileName, err)
return ""
} else {
return string(b)
}
}
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
func (c *Config) AdvertiseUrl() string {
if c.options.AdvertiseUrl == "" {
return c.SiteUrl()
}
return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/"
}
// saveClusterUUID writes or updates the ClusterUUID key in options.yml without
// touching unrelated keys. Creates the file and directories if needed. // touching unrelated keys. Creates the file and directories if needed.
func (c *Config) savePortalUUID(id string) error { func (c *Config) saveClusterUUID(id string) error {
// Always resolve against the current ConfigPath and remember it explicitly // Always resolve against the current ConfigPath and remember it explicitly
// so subsequent calls don't accidentally point to a previous default. // so subsequent calls don't accidentally point to a previous default.
cfgDir := c.ConfigPath() cfgDir := c.ConfigPath()
@@ -136,7 +151,7 @@ func (c *Config) savePortalUUID(id string) error {
m = map[string]interface{}{} m = map[string]interface{}{}
} }
m["PortalUUID"] = id m["ClusterUUID"] = id
if b, err := yaml.Marshal(m); err != nil { if b, err := yaml.Marshal(m); err != nil {
return err return err

View File

@@ -18,14 +18,12 @@ func TestConfig_Cluster(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
// Defaults // Defaults
assert.False(t, c.ClusterPortal())
assert.False(t, c.IsPortal()) assert.False(t, c.IsPortal())
// Toggle values // Toggle values
c.Options().NodeType = string(cluster.Portal) c.Options().NodeRole = string(cluster.RolePortal)
assert.True(t, c.ClusterPortal())
assert.True(t, c.IsPortal()) assert.True(t, c.IsPortal())
c.Options().NodeType = "" c.Options().NodeRole = ""
}) })
t.Run("Paths", func(t *testing.T) { t.Run("Paths", func(t *testing.T) {
@@ -36,18 +34,18 @@ func TestConfig_Cluster(t *testing.T) {
c.options.ConfigPath = tempCfg c.options.ConfigPath = tempCfg
c.options.NodeSecret = "" c.options.NodeSecret = ""
c.options.PortalUrl = "" c.options.PortalUrl = ""
c.options.PortalToken = "" c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// Clear values potentially loaded at NewConfig creation. // Clear values potentially loaded at NewConfig creation.
c.options.NodeSecret = "" c.options.NodeSecret = ""
c.options.PortalUrl = "" c.options.PortalUrl = ""
c.options.PortalToken = "" c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// Clear values that may have been loaded from repo fixtures before we // Clear values that may have been loaded from repo fixtures before we
// isolated the config path. // isolated the config path.
c.options.NodeSecret = "" c.options.NodeSecret = ""
c.options.PortalUrl = "" c.options.PortalUrl = ""
c.options.PortalToken = "" c.options.JoinToken = ""
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml") c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
// PortalConfigPath always points to a "cluster" subfolder under ConfigPath. // PortalConfigPath always points to a "cluster" subfolder under ConfigPath.
@@ -78,16 +76,16 @@ func TestConfig_Cluster(t *testing.T) {
// Defaults (no options.yml present) // Defaults (no options.yml present)
assert.Equal(t, "", c.PortalUrl()) assert.Equal(t, "", c.PortalUrl())
assert.Equal(t, "", c.PortalToken()) assert.Equal(t, "", c.JoinToken())
assert.Equal(t, "", c.NodeSecret()) assert.Equal(t, "", c.NodeSecret())
// Set and read back values // Set and read back values
c.options.PortalUrl = "https://portal.example.test" c.options.PortalUrl = "https://portal.example.test"
c.options.PortalToken = "portal-token" c.options.JoinToken = "join-token"
c.options.NodeSecret = "node-secret" c.options.NodeSecret = "node-secret"
assert.Equal(t, "https://portal.example.test", c.PortalUrl()) assert.Equal(t, "https://portal.example.test", c.PortalUrl())
assert.Equal(t, "portal-token", c.PortalToken()) assert.Equal(t, "join-token", c.JoinToken())
assert.Equal(t, "node-secret", c.NodeSecret()) assert.Equal(t, "node-secret", c.NodeSecret())
}) })
@@ -116,22 +114,22 @@ func TestConfig_Cluster(t *testing.T) {
assert.Equal(t, "", c.NodeName()) assert.Equal(t, "", c.NodeName())
}) })
t.Run("NodeTypeValues", func(t *testing.T) { t.Run("NodeRoleValues", func(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
// Default / unknown → node // Default / unknown → node
c.options.NodeType = "" c.options.NodeRole = ""
assert.Equal(t, string(cluster.Instance), c.NodeType()) assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
c.options.NodeType = "unknown" c.options.NodeRole = "unknown"
assert.Equal(t, string(cluster.Instance), c.NodeType()) assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
// Explicit values // Explicit values
c.options.NodeType = string(cluster.Instance) c.options.NodeRole = string(cluster.RoleInstance)
assert.Equal(t, string(cluster.Instance), c.NodeType()) assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
c.options.NodeType = string(cluster.Portal) c.options.NodeRole = string(cluster.RolePortal)
assert.Equal(t, string(cluster.Portal), c.NodeType()) assert.Equal(t, string(cluster.RolePortal), c.NodeRole())
c.options.NodeType = string(cluster.Service) c.options.NodeRole = string(cluster.RoleService)
assert.Equal(t, string(cluster.Service), c.NodeType()) assert.Equal(t, string(cluster.RoleService), c.NodeRole())
}) })
t.Run("SecretsFromFiles", func(t *testing.T) { t.Run("SecretsFromFiles", func(t *testing.T) {
@@ -146,23 +144,23 @@ func TestConfig_Cluster(t *testing.T) {
// Clear inline values so file-based lookup is used. // Clear inline values so file-based lookup is used.
c.options.NodeSecret = "" c.options.NodeSecret = ""
c.options.PortalToken = "" c.options.JoinToken = ""
// Point env vars at the files and verify. // Point env vars at the files and verify.
t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", nsFile) t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", nsFile)
t.Setenv("PHOTOPRISM_PORTAL_TOKEN_FILE", tkFile) t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile)
assert.Equal(t, "s3cr3t", c.NodeSecret()) assert.Equal(t, "s3cr3t", c.NodeSecret())
assert.Equal(t, "t0k3n", c.PortalToken()) assert.Equal(t, "t0k3n", c.JoinToken())
// Empty / missing should yield empty strings. // Empty / missing should yield empty strings.
t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", filepath.Join(dir, "missing")) t.Setenv("PHOTOPRISM_NODE_SECRET_FILE", filepath.Join(dir, "missing"))
t.Setenv("PHOTOPRISM_PORTAL_TOKEN_FILE", filepath.Join(dir, "missing")) t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", filepath.Join(dir, "missing"))
assert.Equal(t, "", c.NodeSecret()) assert.Equal(t, "", c.NodeSecret())
assert.Equal(t, "", c.PortalToken()) assert.Equal(t, "", c.JoinToken())
}) })
} }
func TestConfig_PortalUUID_FileOverridesEnv(t *testing.T) { func TestConfig_ClusterUUID_FileOverridesEnv(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
// Isolate config path. // Isolate config path.
@@ -170,63 +168,63 @@ func TestConfig_PortalUUID_FileOverridesEnv(t *testing.T) {
c.options.ConfigPath = tempCfg c.options.ConfigPath = tempCfg
// Prepare options.yml with a UUID; file should override env/CLI. // Prepare options.yml with a UUID; file should override env/CLI.
opts := map[string]any{"PortalUUID": "11111111-1111-4111-8111-111111111111"} opts := map[string]any{"ClusterUUID": "11111111-1111-4111-8111-111111111111"}
b, _ := yaml.Marshal(opts) b, _ := yaml.Marshal(opts)
assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644)) assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644))
// Set env; file value must win for consistency with other options. // Set env; file value must win for consistency with other options.
t.Setenv("PHOTOPRISM_PORTAL_UUID", "22222222-2222-4222-8222-222222222222") t.Setenv("PHOTOPRISM_CLUSTER_UUID", "22222222-2222-4222-8222-222222222222")
// Load options.yml into options struct (we updated ConfigPath after creation). // Load options.yml into options struct (we updated ConfigPath after creation).
assert.NoError(t, c.options.Load(c.OptionsYaml())) assert.NoError(t, c.options.Load(c.OptionsYaml()))
got := c.PortalUUID() got := c.ClusterUUID()
assert.Equal(t, "11111111-1111-4111-8111-111111111111", got) assert.Equal(t, "11111111-1111-4111-8111-111111111111", got)
} }
func TestConfig_PortalUUID_FromOptions(t *testing.T) { func TestConfig_ClusterUUID_FromOptions(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
tempCfg := t.TempDir() tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg c.options.ConfigPath = tempCfg
opts := map[string]any{"PortalUUID": "33333333-3333-4333-8333-333333333333"} opts := map[string]any{"ClusterUUID": "33333333-3333-4333-8333-333333333333"}
b, _ := yaml.Marshal(opts) b, _ := yaml.Marshal(opts)
assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644)) assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644))
// Ensure env is not set. // Ensure env is not set.
t.Setenv("PHOTOPRISM_PORTAL_UUID", "") t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
// Load options.yml into options struct (we updated ConfigPath after creation). // Load options.yml into options struct (we updated ConfigPath after creation).
assert.NoError(t, c.options.Load(c.OptionsYaml())) assert.NoError(t, c.options.Load(c.OptionsYaml()))
// Access the value via getter. // Access the value via getter.
got := c.PortalUUID() got := c.ClusterUUID()
assert.Equal(t, "33333333-3333-4333-8333-333333333333", got) assert.Equal(t, "33333333-3333-4333-8333-333333333333", got)
} }
func TestConfig_PortalUUID_FromCLIFlag(t *testing.T) { func TestConfig_ClusterUUID_FromCLIFlag(t *testing.T) {
// Create a config path so NewConfig reads/writes here and options.yml does not exist. // Create a config path so NewConfig reads/writes here and options.yml does not exist.
tempCfg := t.TempDir() tempCfg := t.TempDir()
// Start from the default CLI test context and override flags we care about. // Start from the default CLI test context and override flags we care about.
ctx := CliTestContext() ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg)) assert.NoError(t, ctx.Set("config-path", tempCfg))
assert.NoError(t, ctx.Set("portal-uuid", "44444444-4444-4444-8444-444444444444")) assert.NoError(t, ctx.Set("cluster-uuid", "44444444-4444-4444-8444-444444444444"))
c := NewConfig(ctx) c := NewConfig(ctx)
// No env and no options.yml: should take the CLI flag value directly from options. // No env and no options.yml: should take the CLI flag value directly from options.
t.Setenv("PHOTOPRISM_PORTAL_UUID", "") t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
got := c.PortalUUID() got := c.ClusterUUID()
assert.Equal(t, "44444444-4444-4444-8444-444444444444", got) assert.Equal(t, "44444444-4444-4444-8444-444444444444", got)
} }
func TestConfig_PortalUUID_GenerateAndPersist(t *testing.T) { func TestConfig_ClusterUUID_GenerateAndPersist(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
tempCfg := t.TempDir() tempCfg := t.TempDir()
c.options.ConfigPath = tempCfg c.options.ConfigPath = tempCfg
// No env, no options.yml → should generate and persist. // No env, no options.yml → should generate and persist.
t.Setenv("PHOTOPRISM_PORTAL_UUID", "") t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
got := c.PortalUUID() got := c.ClusterUUID()
if !rnd.IsUUID(got) { if !rnd.IsUUID(got) {
t.Fatalf("expected a UUIDv4, got %q", got) t.Fatalf("expected a UUIDv4, got %q", got)
} }
@@ -236,9 +234,9 @@ func TestConfig_PortalUUID_GenerateAndPersist(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
var m map[string]any var m map[string]any
assert.NoError(t, yaml.Unmarshal(b, &m)) assert.NoError(t, yaml.Unmarshal(b, &m))
assert.Equal(t, got, m["PortalUUID"]) assert.Equal(t, got, m["ClusterUUID"])
// Second call returns the same value (from options in-memory / file). // Second call returns the same value (from options in-memory / file).
got2 := c.PortalUUID() got2 := c.ClusterUUID()
assert.Equal(t, got, got2) assert.Equal(t, got, got2)
} }

View File

@@ -167,15 +167,6 @@ func (c *Config) SitePreview() string {
return fmt.Sprintf("https://i.photoprism.app/prism?cover=64&style=centered%%20dark&caption=none&title=%s", url.QueryEscape(c.AppName())) return fmt.Sprintf("https://i.photoprism.app/prism?cover=64&style=centered%%20dark&caption=none&title=%s", url.QueryEscape(c.AppName()))
} }
// InternalUrl returns the internal instance URL if configured, or the site URL if not.
func (c *Config) InternalUrl() string {
if c.options.InternalUrl == "" {
return c.SiteUrl()
}
return strings.TrimRight(c.options.InternalUrl, "/") + "/"
}
// LegalInfo returns the legal info text for the page footer. // LegalInfo returns the legal info text for the page footer.
func (c *Config) LegalInfo() string { func (c *Config) LegalInfo() string {
if s := c.CliContextString("imprint"); s != "" { if s := c.CliContextString("imprint"); s != "" {

View File

@@ -599,16 +599,10 @@ var Flags = CliFlags{
}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "site-url", Name: "site-url",
Usage: "canonical site `URL` used in generated links and to determine HTTPS/TLS; must include scheme (http/https)", Usage: "canonical site `URL` used in generated links and to determine HTTPS/TLS (scheme://host[:port])",
Value: "http://localhost:2342/", Value: "http://localhost:2342/",
EnvVars: EnvVars("SITE_URL"), EnvVars: EnvVars("SITE_URL"),
}}, { }}, {
Flag: &cli.StringFlag{
Name: "internal-url",
Usage: "service base `URL` used for intra-cluster communication and other internal requests*optional*",
Value: "",
EnvVars: EnvVars("INTERNAL_URL"),
}}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "site-author", Name: "site-author",
Usage: "site `OWNER`, copyright, or artist", Usage: "site `OWNER`, copyright, or artist",
@@ -671,40 +665,57 @@ var Flags = CliFlags{
Value: header.DefaultAccessControlAllowMethods, Value: header.DefaultAccessControlAllowMethods,
}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "node-name", Name: "cluster-domain",
Usage: "cluster node `NAME` (lowercase letters, digits, hyphens; 163 chars)", Usage: "cluster `DOMAIN` (lowercase DNS name; 163 chars)",
EnvVars: EnvVars("NODE_NAME"), EnvVars: EnvVars("CLUSTER_DOMAIN"),
}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "node-type", Name: "cluster-uuid",
Usage: "cluster node `TYPE` (portal, instance, service)", Usage: "cluster `UUID` (v4) to scope per-node credentials",
EnvVars: EnvVars("NODE_TYPE"), EnvVars: EnvVars("CLUSTER_UUID"),
Hidden: true,
}}, {
Flag: &cli.StringFlag{
Name: "node-secret",
Usage: "private `KEY` to secure intra-cluster communication*optional*",
EnvVars: EnvVars("NODE_SECRET"),
Hidden: true, Hidden: true,
}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "portal-url", Name: "portal-url",
Usage: "base `URL` of the cluster portal, e.g. https://portal.example.com", Usage: "base `URL` of the cluster portal (e.g. https://portal.example.com)",
EnvVars: EnvVars("PORTAL_URL"), EnvVars: EnvVars("PORTAL_URL"),
Hidden: true, Hidden: true,
}, Tags: []string{Pro}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "portal-token", Name: "join-token",
Usage: "access `TOKEN` for nodes to register and synchronize with the portal", Usage: "secret `TOKEN` required to join the cluster",
EnvVars: EnvVars("PORTAL_TOKEN"), EnvVars: EnvVars("JOIN_TOKEN"),
Hidden: true, Hidden: true,
}, Tags: []string{Pro}}, { }}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "portal-uuid", Name: "node-name",
Usage: "`UUID` (version 4) for the portal to scope per-node credentials*optional*", Usage: "node `NAME` (unique in cluster domain; [a-z0-9-]{1,32})",
EnvVars: EnvVars("PORTAL_UUID"), EnvVars: EnvVars("NODE_NAME"),
}}, {
Flag: &cli.StringFlag{
Name: "node-role",
Usage: "node `ROLE` (portal, instance, or service)",
EnvVars: EnvVars("NODE_ROLE"),
Hidden: true, Hidden: true,
}, Tags: []string{Pro}}, { }}, {
Flag: &cli.StringFlag{
Name: "node-id",
Usage: "client `ID` registered with the portal (auto-assigned via join token)",
EnvVars: EnvVars("NODE_ID"),
Hidden: true,
}}, {
Flag: &cli.StringFlag{
Name: "node-secret",
Usage: "client `SECRET` registered with the portal (auto-assigned via join token)",
EnvVars: EnvVars("NODE_SECRET"),
Hidden: true,
}}, {
Flag: &cli.StringFlag{
Name: "advertise-url",
Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])",
Value: "",
EnvVars: EnvVars("ADVERTISE_URL"),
}}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "https-proxy", Name: "https-proxy",
Usage: "proxy server `URL` to be used for outgoing connections*optional*", Usage: "proxy server `URL` to be used for outgoing connections*optional*",

View File

@@ -131,7 +131,6 @@ type Options struct {
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"` SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
InternalUrl string `yaml:"InternalUrl" json:"InternalUrl" flag:"internal-url"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"` SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"` SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"` SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
@@ -143,13 +142,15 @@ type Options struct {
CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"` CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"`
CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"` CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"`
CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"` CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"`
NodeName string `yaml:"NodeName" json:"-" flag:"node-name"` ClusterDomain string `yaml:"ClusterDomain" json:"-" flag:"cluster-domain"`
NodeType string `yaml:"NodeType" json:"-" flag:"node-type"` ClusterUUID string `yaml:"ClusterUUID" json:"-" flag:"cluster-uuid"`
NodeSecret string `yaml:"NodeSecret" json:"-" flag:"node-secret"`
PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"` PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"`
PortalClient string `yaml:"PortalClient" json:"-" flag:"portal-client"` JoinToken string `yaml:"JoinToken" json:"-" flag:"join-token"`
PortalToken string `yaml:"PortalToken" json:"-" flag:"portal-token"` NodeName string `yaml:"NodeName" json:"-" flag:"node-name"`
PortalUUID string `yaml:"PortalUUID" json:"-" flag:"portal-uuid"` NodeRole string `yaml:"NodeRole" json:"-" flag:"node-role"`
NodeID string `yaml:"NodeID" json:"-" flag:"node-id"`
NodeSecret string `yaml:"NodeSecret" json:"-" flag:"node-secret"`
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"` HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"` HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`
TrustedPlatform string `yaml:"TrustedPlatform" json:"-" flag:"trusted-platform"` TrustedPlatform string `yaml:"TrustedPlatform" json:"-" flag:"trusted-platform"`

View File

@@ -152,7 +152,6 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Site Infos. // Site Infos.
{"site-url", c.SiteUrl()}, {"site-url", c.SiteUrl()},
{"internal-url", c.InternalUrl()},
{"site-https", fmt.Sprintf("%t", c.SiteHttps())}, {"site-https", fmt.Sprintf("%t", c.SiteHttps())},
{"site-domain", c.SiteDomain()}, {"site-domain", c.SiteDomain()},
{"site-author", c.SiteAuthor()}, {"site-author", c.SiteAuthor()},
@@ -163,14 +162,17 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"site-preview", c.SitePreview()}, {"site-preview", c.SitePreview()},
// Cluster Configuration. // Cluster Configuration.
{"node-name", c.NodeName()}, {"cluster-domain", c.ClusterDomain()},
{"node-type", c.NodeType()}, {"cluster-uuid", c.ClusterUUID()},
{"node-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeSecret())))},
{"portal-url", c.PortalUrl()}, {"portal-url", c.PortalUrl()},
{"portal-token", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.PortalToken())))},
{"portal-uuid", c.PortalUUID()},
{"portal-config-path", c.PortalConfigPath()}, {"portal-config-path", c.PortalConfigPath()},
{"portal-theme-path", c.PortalThemePath()}, {"portal-theme-path", c.PortalThemePath()},
{"join-token", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.JoinToken())))},
{"node-name", c.NodeName()},
{"node-role", c.NodeRole()},
{"node-id", c.NodeID()},
{"node-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeSecret())))},
{"advertise-url", c.AdvertiseUrl()},
// CDN and Cross-Origin Resource Sharing (CORS). // CDN and Cross-Origin Resource Sharing (CORS).
{"cdn-url", c.CdnUrl("/")}, {"cdn-url", c.CdnUrl("/")},

View File

@@ -25,7 +25,7 @@ var OptionsReportSections = []ReportSection{
{Start: "PHOTOPRISM_READONLY", Title: "Feature Flags"}, {Start: "PHOTOPRISM_READONLY", Title: "Feature Flags"},
{Start: "PHOTOPRISM_DEFAULT_LOCALE", Title: "Customization"}, {Start: "PHOTOPRISM_DEFAULT_LOCALE", Title: "Customization"},
{Start: "PHOTOPRISM_SITE_URL", Title: "Site Information"}, {Start: "PHOTOPRISM_SITE_URL", Title: "Site Information"},
{Start: "PHOTOPRISM_NODE_NAME", Title: "Cluster Configuration"}, {Start: "PHOTOPRISM_CLUSTER_DOMAIN", Title: "Cluster Configuration"},
{Start: "PHOTOPRISM_HTTPS_PROXY", Title: "Proxy Server"}, {Start: "PHOTOPRISM_HTTPS_PROXY", Title: "Proxy Server"},
{Start: "PHOTOPRISM_DISABLE_TLS", Title: "Web Server"}, {Start: "PHOTOPRISM_DISABLE_TLS", Title: "Web Server"},
{Start: "PHOTOPRISM_DATABASE_DRIVER", Title: "Database Connection"}, {Start: "PHOTOPRISM_DATABASE_DRIVER", Title: "Database Connection"},
@@ -52,7 +52,7 @@ var YamlReportSections = []ReportSection{
{Start: "ReadOnly", Title: "Feature Flags"}, {Start: "ReadOnly", Title: "Feature Flags"},
{Start: "DefaultLocale", Title: "Customization"}, {Start: "DefaultLocale", Title: "Customization"},
{Start: "SiteUrl", Title: "Site Information"}, {Start: "SiteUrl", Title: "Site Information"},
{Start: "NodeName", Title: "Cluster Configuration"}, {Start: "ClusterDomain", Title: "Cluster Configuration"},
{Start: "HttpsProxy", Title: "Proxy Server"}, {Start: "HttpsProxy", Title: "Proxy Server"},
{Start: "DisableTLS", Title: "Web Server"}, {Start: "DisableTLS", Title: "Web Server"},
{Start: "DatabaseDriver", Title: "Database Connection"}, {Start: "DatabaseDriver", Title: "Database Connection"},

View File

@@ -248,7 +248,7 @@ func CliTestContext() *cli.Context {
globalSet.String("import-path", config.OriginalsPath, "doc") globalSet.String("import-path", config.OriginalsPath, "doc")
globalSet.String("cache-path", config.OriginalsPath, "doc") globalSet.String("cache-path", config.OriginalsPath, "doc")
globalSet.String("temp-path", config.OriginalsPath, "doc") globalSet.String("temp-path", config.OriginalsPath, "doc")
globalSet.String("portal-uuid", config.PortalUUID, "doc") globalSet.String("cluster-uuid", config.ClusterUUID, "doc")
globalSet.String("backup-path", config.StoragePath, "doc") globalSet.String("backup-path", config.StoragePath, "doc")
globalSet.Int("backup-retain", config.BackupRetain, "doc") globalSet.Int("backup-retain", config.BackupRetain, "doc")
globalSet.String("backup-schedule", config.BackupSchedule, "doc") globalSet.String("backup-schedule", config.BackupSchedule, "doc")

View File

@@ -1,6 +1,7 @@
package entity package entity
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@@ -29,24 +30,28 @@ type Clients []Client
// Client represents a client application. // Client represents a client application.
type Client struct { type Client struct {
ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"` ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"` UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"` UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-" yaml:"-"` user *User `gorm:"-" yaml:"-"`
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"` ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,omitempty"` ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,omitempty"`
ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"` ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"`
ClientURL string `gorm:"type:VARBINARY(255);default:'';column:client_url;" json:"ClientURL" yaml:"ClientURL,omitempty"` ClientURL string `gorm:"type:VARBINARY(255);default:'';column:client_url;" json:"ClientURL" yaml:"ClientURL,omitempty"`
CallbackURL string `gorm:"type:VARBINARY(255);default:'';column:callback_url;" json:"CallbackURL" yaml:"CallbackURL,omitempty"` CallbackURL string `gorm:"type:VARBINARY(255);default:'';column:callback_url;" json:"CallbackURL" yaml:"CallbackURL,omitempty"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"` AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"` AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"` AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"`
AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"` AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"`
AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"` AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"`
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"` RefreshToken string `gorm:"type:VARBINARY(2048);column:refresh_token;default:'';" json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"` IdToken string `gorm:"type:VARBINARY(2048);column:id_token;default:'';" json:"IdToken,omitempty" yaml:"IdToken,omitempty"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"` DataJSON json.RawMessage `gorm:"type:VARBINARY(4096);" json:"-" yaml:"Data,omitempty"`
data *ClientData `gorm:"-" yaml:"-"`
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
} }
// TableName returns the entity table name. // TableName returns the entity table name.

View File

@@ -0,0 +1,52 @@
package entity
import (
"encoding/json"
)
// ClientData represents Client data.
type ClientData struct {
// TODO: Define what types of data can have.
}
// NewClientData creates a new client data struct and returns a pointer to it.
func NewClientData() *ClientData {
return &ClientData{}
}
// GetData returns the data that belong to this session.
func (m *Client) GetData() (data *ClientData) {
if m.data != nil {
data = m.data
}
data = NewClientData()
if len(m.DataJSON) == 0 {
return data
} else if err := json.Unmarshal(m.DataJSON, data); err != nil {
log.Errorf("auth: failed to read client data (%s)", err)
} else {
m.data = data
}
return data
}
// SetData updates the data that belong to this session.
func (m *Client) SetData(data *ClientData) *Client {
if data == nil {
log.Debugf("auth: nil cannot be set as client data (%s)", m.ClientUID)
return m
}
if j, err := json.Marshal(data); err != nil {
log.Debugf("auth: failed to set client data (%s)", err)
} else {
m.DataJSON = j
}
m.data = data
return m
}

View File

@@ -622,7 +622,7 @@ func (m *Session) GetData() (data *SessionData) {
if len(m.DataJSON) == 0 { if len(m.DataJSON) == 0 {
return data return data
} else if err := json.Unmarshal(m.DataJSON, data); err != nil { } else if err := json.Unmarshal(m.DataJSON, data); err != nil {
log.Errorf("failed parsing session json: %s", err) log.Errorf("auth: failed to read session data (%s)", err)
} else { } else {
data.RefreshShares() data.RefreshShares()
m.data = data m.data = data
@@ -634,7 +634,7 @@ func (m *Session) GetData() (data *SessionData) {
// SetData updates the data that belong to this session. // SetData updates the data that belong to this session.
func (m *Session) SetData(data *SessionData) *Session { func (m *Session) SetData(data *SessionData) *Session {
if data == nil { if data == nil {
log.Debugf("auth: empty data passed to session %s", m.RefID) log.Debugf("auth: nil cannot be set as session data (%s)", m.RefID)
return m return m
} }
@@ -642,7 +642,7 @@ func (m *Session) SetData(data *SessionData) *Session {
data.RefreshShares() data.RefreshShares()
if j, err := json.Marshal(data); err != nil { if j, err := json.Marshal(data); err != nil {
log.Debugf("auth: %s", err) log.Debugf("auth: failed to set session data (%s)", err)
} else { } else {
m.DataJSON = j m.DataJSON = j
} }

View File

@@ -1,9 +0,0 @@
package cluster
type NodeType = string
const (
Portal NodeType = "portal" // A Portal server for orchestrating a cluster.
Instance NodeType = "instance" // An Instance can register with a Portal to join a cluster.
Service NodeType = "service" // Additional Service with computing, sharing, or storage capabilities.
)

View File

@@ -13,7 +13,7 @@ import (
"strings" "strings"
"time" "time"
yaml "gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
@@ -37,13 +37,13 @@ func InitConfig(c *config.Config) error {
} }
// Skip on portal nodes and unknown node types. // Skip on portal nodes and unknown node types.
if c.IsPortal() || c.NodeType() != cluster.Instance { if c.IsPortal() || c.NodeRole() != cluster.RoleInstance {
return nil return nil
} }
portalURL := strings.TrimSpace(c.PortalUrl()) portalURL := strings.TrimSpace(c.PortalUrl())
portalToken := strings.TrimSpace(c.PortalToken()) joinToken := strings.TrimSpace(c.JoinToken())
if portalURL == "" || portalToken == "" { if portalURL == "" || joinToken == "" {
return nil return nil
} }
@@ -61,7 +61,7 @@ func InitConfig(c *config.Config) error {
// Register with retry policy. // Register with retry policy.
if cluster.BootstrapAutoJoinEnabled { if cluster.BootstrapAutoJoinEnabled {
if err := registerWithPortal(c, u, portalToken); err != nil { if err := registerWithPortal(c, u, joinToken); err != nil {
// Registration errors are expected when the Portal is temporarily unavailable // Registration errors are expected when the Portal is temporarily unavailable
// or not configured with cluster endpoints (404). Keep as warn to signal // or not configured with cluster endpoints (404). Keep as warn to signal
// exhaustion/terminal errors; per-attempt details are logged at debug level. // exhaustion/terminal errors; per-attempt details are logged at debug level.
@@ -71,7 +71,7 @@ func InitConfig(c *config.Config) error {
// Pull theme if missing. // Pull theme if missing.
if cluster.BootstrapAutoThemeEnabled { if cluster.BootstrapAutoThemeEnabled {
if err := installThemeIfMissing(c, u, portalToken); err != nil { if err := installThemeIfMissing(c, u, joinToken); err != nil {
// Theme install failures are non-critical; log at debug to avoid noise. // Theme install failures are non-critical; log at debug to avoid noise.
log.Debugf("cluster: theme install skipped/failed (%s)", clean.Error(err)) log.Debugf("cluster: theme install skipped/failed (%s)", clean.Error(err))
} }
@@ -110,16 +110,16 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
// and no DSN/fields are set (raw options) and no password is provided via file. // and no DSN/fields are set (raw options) and no password is provided via file.
opts := c.Options() opts := c.Options()
driver := c.DatabaseDriver() driver := c.DatabaseDriver()
wantRotateDB := (driver == config.MySQL || driver == config.MariaDB) && wantRotateDatabase := (driver == config.MySQL || driver == config.MariaDB) &&
opts.DatabaseDsn == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" && opts.DatabaseDsn == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" &&
c.DatabasePassword() == "" c.DatabasePassword() == ""
payload := map[string]interface{}{ payload := map[string]interface{}{
"nodeName": c.NodeName(), "nodeName": c.NodeName(),
"nodeType": string(cluster.Instance), // JSON wire format is string "nodeRole": cluster.RoleInstance, // JSON wire format is string
"internalUrl": c.InternalUrl(), "advertiseUrl": c.AdvertiseUrl(),
} }
if wantRotateDB { if wantRotateDatabase {
payload["rotate"] = true payload["rotate"] = true
} }
@@ -151,7 +151,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
if err := dec.Decode(&r); err != nil { if err := dec.Decode(&r); err != nil {
return err return err
} }
if err := persistRegistration(c, &r, wantRotateDB); err != nil { if err := persistRegistration(c, &r, wantRotateDatabase); err != nil {
return err return err
} }
if resp.StatusCode == http.StatusCreated { if resp.StatusCode == http.StatusCreated {
@@ -191,7 +191,7 @@ func isTemporary(err error) bool {
return errors.As(err, &nerr) && nerr.Timeout() return errors.As(err, &nerr) && nerr.Timeout()
} }
func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDB bool) error { func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error {
updates := map[string]interface{}{} updates := map[string]interface{}{}
// Persist node secret only if missing locally and provided by server. // Persist node secret only if missing locally and provided by server.
@@ -201,20 +201,20 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
// 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 wantRotateDB { if wantRotateDatabase {
if r.DB.DSN != "" { if r.Database.DSN != "" {
updates["DatabaseDriver"] = config.MySQL updates["DatabaseDriver"] = config.MySQL
updates["DatabaseDsn"] = r.DB.DSN updates["DatabaseDsn"] = r.Database.DSN
} else if r.DB.Name != "" && r.DB.User != "" && r.DB.Password != "" { } else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" {
server := r.DB.Host server := r.Database.Host
if r.DB.Port > 0 { if r.Database.Port > 0 {
server = net.JoinHostPort(r.DB.Host, strconv.Itoa(r.DB.Port)) server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port))
} }
updates["DatabaseDriver"] = config.MySQL updates["DatabaseDriver"] = config.MySQL
updates["DatabaseServer"] = server updates["DatabaseServer"] = server
updates["DatabaseName"] = r.DB.Name updates["DatabaseName"] = r.Database.Name
updates["DatabaseUser"] = r.DB.User updates["DatabaseUser"] = r.Database.User
updates["DatabasePassword"] = r.DB.Password updates["DatabasePassword"] = r.Database.Password
} }
} }

View File

@@ -19,8 +19,8 @@ import (
func TestInitConfig_NoPortal_NoOp(t *testing.T) { func TestInitConfig_NoPortal_NoOp(t *testing.T) {
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir()) t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
c := config.NewTestConfig("bootstrap-np") c := config.NewTestConfig("bootstrap-np")
// Default NodeType() resolves to instance; no Portal configured. // Default NodeRole() resolves to instance; no Portal configured.
assert.Equal(t, cluster.Instance, c.NodeType()) assert.Equal(t, cluster.RoleInstance, c.NodeRole())
assert.NoError(t, InitConfig(c)) assert.NoError(t, InitConfig(c))
} }
@@ -34,9 +34,9 @@ func TestRegister_PersistSecretAndDB(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)
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"}, Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"}, Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"},
DB: cluster.RegisterDB{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"}, Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
} }
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
case "/api/v1/cluster/theme": case "/api/v1/cluster/theme":
@@ -51,7 +51,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
c := config.NewTestConfig("bootstrap-reg") c := config.NewTestConfig("bootstrap-reg")
// Configure Portal. // Configure Portal.
c.Options().PortalUrl = srv.URL c.Options().PortalUrl = srv.URL
c.Options().PortalToken = "t0k3n" c.Options().JoinToken = "t0k3n"
// Gate rotate=true: driver mysql and no DSN/fields. // Gate rotate=true: driver mysql and no DSN/fields.
c.Options().DatabaseDriver = config.MySQL c.Options().DatabaseDriver = config.MySQL
c.Options().DatabaseDsn = "" c.Options().DatabaseDsn = ""
@@ -97,7 +97,7 @@ func TestThemeInstall_Missing(t *testing.T) {
c := config.NewTestConfig("bootstrap-theme") c := config.NewTestConfig("bootstrap-theme")
// Point Portal. // Point Portal.
c.Options().PortalUrl = srv.URL c.Options().PortalUrl = srv.URL
c.Options().PortalToken = "t0k3n" c.Options().JoinToken = "t0k3n"
// Ensure theme dir is empty and unique. // Ensure theme dir is empty and unique.
tempTheme, err := os.MkdirTemp("", "pp-theme-*") tempTheme, err := os.MkdirTemp("", "pp-theme-*")
@@ -124,9 +124,9 @@ func TestRegister_SQLite_NoDBPersist(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)
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"}, Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"}, Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"},
DB: cluster.RegisterDB{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"}, Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
} }
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
default: default:
@@ -138,7 +138,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
c := config.NewTestConfig("bootstrap-sqlite") c := config.NewTestConfig("bootstrap-sqlite")
// SQLite driver by default; set Portal. // SQLite driver by default; set Portal.
c.Options().PortalUrl = srv.URL c.Options().PortalUrl = srv.URL
c.Options().PortalToken = "t0k3n" c.Options().JoinToken = "t0k3n"
// Remember original DSN so we can ensure it is not changed. // Remember original DSN so we can ensure it is not changed.
origDSN := c.Options().DatabaseDsn origDSN := c.Options().DatabaseDsn
t.Cleanup(func() { _ = os.Remove(origDSN) }) t.Cleanup(func() { _ = os.Remove(origDSN) })
@@ -167,7 +167,7 @@ func TestRegister_404_NoRetry(t *testing.T) {
c := config.NewTestConfig("bootstrap-404") c := config.NewTestConfig("bootstrap-404")
c.Options().PortalUrl = srv.URL c.Options().PortalUrl = srv.URL
c.Options().PortalToken = "t0k3n" c.Options().JoinToken = "t0k3n"
// Run bootstrap; registration should attempt once and stop on 404. // Run bootstrap; registration should attempt once and stop on 404.
_ = InitConfig(c) _ = InitConfig(c)
@@ -195,7 +195,7 @@ func TestThemeInstall_SkipWhenAppJsExists(t *testing.T) {
c := config.NewTestConfig("bootstrap-theme-skip") c := config.NewTestConfig("bootstrap-theme-skip")
c.Options().PortalUrl = srv.URL c.Options().PortalUrl = srv.URL
c.Options().PortalToken = "t0k3n" c.Options().JoinToken = "t0k3n"
// Prepare theme dir with app.js // Prepare theme dir with app.js
tempTheme, err := os.MkdirTemp("", "pp-theme-*") tempTheme, err := os.MkdirTemp("", "pp-theme-*")

View File

@@ -4,7 +4,7 @@ import "time"
// BootstrapAutoJoinEnabled indicates whether cluster bootstrap logic is enabled // BootstrapAutoJoinEnabled indicates whether cluster bootstrap logic is enabled
// for nodes by default. Portal nodes ignore this value; gating is decided by // for nodes by default. Portal nodes ignore this value; gating is decided by
// runtime checks (e.g., conf.IsPortal() and conf.NodeType()). // runtime checks (e.g., conf.IsPortal() and conf.NodeRole()).
var BootstrapAutoJoinEnabled = true var BootstrapAutoJoinEnabled = true
// BootstrapAutoThemeEnabled indicates whether bootstrap should attempt to // BootstrapAutoThemeEnabled indicates whether bootstrap should attempt to

View File

@@ -28,11 +28,11 @@ var identRe = regexp.MustCompile(`^[a-z0-9\-_.]+$`)
func quoteIdent(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" } func quoteIdent(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
// EnsureNodeDB ensures a per-node database and user exist with minimal grants. // EnsureNodeDatabase ensures a per-node database and user exist with minimal grants.
// - Requires MySQL/MariaDB driver on the portal. // - Requires MySQL/MariaDB driver on the portal.
// - Returns created=true if the database schema did not exist before. // - Returns created=true if the database schema did not exist before.
// - If rotate is true or created, rotates the user password and includes it (and DSN) in the result. // - If rotate is true or created, rotates the user password and includes it (and DSN) in the result.
func EnsureNodeDB(ctx context.Context, conf *config.Config, nodeName string, rotate bool) (Creds, bool, error) { func EnsureNodeDatabase(ctx context.Context, conf *config.Config, nodeName string, rotate bool) (Creds, bool, error) {
out := Creds{} out := Creds{}
switch conf.DatabaseDriver() { switch conf.DatabaseDriver() {

View File

@@ -22,15 +22,15 @@ const (
) )
// GenerateCreds computes deterministic database name and user for a node under the given portal // GenerateCreds computes deterministic database name and user for a node under the given portal
// plus a random password. Naming is stable for a given (portalUUID, nodeName) pair and changes // plus a random password. Naming is stable for a given (clusterUUID, nodeName) pair and changes
// if the portal UUID changes. The returned password is random and independent. // if the cluster UUID changes. The returned password is random and independent.
func GenerateCreds(conf *config.Config, nodeName string) (dbName, dbUser, dbPass string) { func GenerateCreds(conf *config.Config, nodeName string) (dbName, dbUser, dbPass string) {
portalUUID := conf.PortalUUID() clusterUUID := conf.ClusterUUID()
slug := clean.TypeLowerDash(nodeName) slug := clean.TypeLowerDash(nodeName)
// Compute base32 (no padding) HMAC suffixes scoped by portal UUID. // Compute base32 (no padding) HMAC suffixes scoped by cluster UUID.
sName := hmacBase32("db-name:"+portalUUID, slug) sName := hmacBase32("db-name:"+clusterUUID, slug)
sUser := hmacBase32("db-user:"+portalUUID, slug) sUser := hmacBase32("db-user:"+clusterUUID, slug)
// Budgets: user ≤32, db ≤64 // Budgets: user ≤32, db ≤64
// Patterns: pp_<slug>_<suffix> // Patterns: pp_<slug>_<suffix>

View File

@@ -10,8 +10,8 @@ import (
func TestGenerateCreds_StabilityAndBudgets(t *testing.T) { func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
// Fix the portal UUID via options to ensure determinism. // Fix the cluster UUID via options to ensure determinism.
c.Options().PortalUUID = "11111111-1111-4111-8111-111111111111" c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
db1, user1, pass1 := GenerateCreds(c, "pp-node-01") db1, user1, pass1 := GenerateCreds(c, "pp-node-01")
db2, user2, pass2 := GenerateCreds(c, "pp-node-01") db2, user2, pass2 := GenerateCreds(c, "pp-node-01")
@@ -31,8 +31,8 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
func TestGenerateCreds_DifferentPortal(t *testing.T) { func TestGenerateCreds_DifferentPortal(t *testing.T) {
c1 := config.NewConfig(config.CliTestContext()) c1 := config.NewConfig(config.CliTestContext())
c2 := config.NewConfig(config.CliTestContext()) c2 := config.NewConfig(config.CliTestContext())
c1.Options().PortalUUID = "11111111-1111-4111-8111-111111111111" c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
c2.Options().PortalUUID = "22222222-2222-4222-8222-222222222222" c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222"
db1, user1, _ := GenerateCreds(c1, "pp-node-01") db1, user1, _ := GenerateCreds(c1, "pp-node-01")
db2, user2, _ := GenerateCreds(c2, "pp-node-01") db2, user2, _ := GenerateCreds(c2, "pp-node-01")
@@ -43,7 +43,7 @@ func TestGenerateCreds_DifferentPortal(t *testing.T) {
func TestGenerateCreds_Truncation(t *testing.T) { func TestGenerateCreds_Truncation(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
c.Options().PortalUUID = "11111111-1111-4111-8111-111111111111" c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets" longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets"
db, user, _ := GenerateCreds(c, longName) db, user, _ := GenerateCreds(c, longName)
@@ -58,12 +58,12 @@ func TestBuildDSN(t *testing.T) {
assert.Contains(t, dsn, "parseTime=true") assert.Contains(t, dsn, "parseTime=true")
} }
func TestEnsureNodeDB_SqliteRejected(t *testing.T) { func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
// Ensure we're on SQLite in tests. // Ensure we're on SQLite in tests.
if c.DatabaseDriver() != config.SQLite3 { if c.DatabaseDriver() != config.SQLite3 {
t.Skip("test requires SQLite driver in test config") t.Skip("test requires SQLite driver in test config")
} }
_, _, err := EnsureNodeDB(nil, c, "pp-node-01", false) _, _, err := EnsureNodeDatabase(nil, c, "pp-node-01", false)
assert.Error(t, err) assert.Error(t, err)
} }

View File

@@ -16,19 +16,19 @@ import (
// Node represents a registered cluster node persisted to YAML. // Node represents a registered cluster node persisted to YAML.
type Node struct { type Node struct {
ID string `yaml:"id" json:"id"` ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Type string `yaml:"type" json:"type"` Role string `yaml:"role" json:"role"`
Labels map[string]string `yaml:"labels" json:"labels"` Labels map[string]string `yaml:"labels" json:"labels"`
Internal string `yaml:"internalUrl" json:"internalUrl"` AdvertiseUrl string `yaml:"advertiseUrl" json:"advertiseUrl"`
CreatedAt string `yaml:"createdAt" json:"createdAt"` CreatedAt string `yaml:"createdAt" json:"createdAt"`
UpdatedAt string `yaml:"updatedAt" json:"updatedAt"` UpdatedAt string `yaml:"updatedAt" json:"updatedAt"`
Secret string `yaml:"secret" json:"-"` // never JSON-encoded by default Secret string `yaml:"secret" json:"-"` // never JSON-encoded by default
SecretRot string `yaml:"nodeSecretLastRotatedAt" json:"nodeSecretLastRotatedAt"` SecretRot string `yaml:"nodeSecretLastRotatedAt" json:"nodeSecretLastRotatedAt"`
DB struct { DB struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
User string `yaml:"user" json:"user"` User string `yaml:"user" json:"user"`
RotAt string `yaml:"lastRotatedAt" json:"dbLastRotatedAt"` RotAt string `yaml:"lastRotatedAt" json:"databaseLastRotatedAt"`
} `yaml:"db" json:"db"` } `yaml:"db" json:"db"`
} }

View File

@@ -27,14 +27,14 @@ func TestFindByNameDeterministic(t *testing.T) {
old := Node{ old := Node{
ID: "id-old", ID: "id-old",
Name: "pp-node-01", Name: "pp-node-01",
Type: "instance", Role: "instance",
CreatedAt: "2024-01-01T00:00:00Z", CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-01T00:00:00Z", UpdatedAt: "2024-01-01T00:00:00Z",
} }
newer := Node{ newer := Node{
ID: "id-new", ID: "id-new",
Name: "pp-node-01", Name: "pp-node-01",
Type: "instance", Role: "instance",
CreatedAt: "2024-02-01T00:00:00Z", CreatedAt: "2024-02-01T00:00:00Z",
UpdatedAt: "2024-02-01T00:00:00Z", UpdatedAt: "2024-02-01T00:00:00Z",
} }

View File

@@ -7,15 +7,15 @@ import (
// NodeOpts controls which optional fields get included in responses. // NodeOpts controls which optional fields get included in responses.
type NodeOpts struct { type NodeOpts struct {
IncludeInternalURL bool IncludeAdvertiseUrl bool
IncludeDBMeta bool IncludeDatabase bool
} }
// NodeOptsForSession returns the default exposure policy for a session. // NodeOptsForSession returns the default exposure policy for a session.
// Admin users see internalUrl 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{IncludeInternalURL: true, IncludeDBMeta: true} return NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}
} }
return NodeOpts{} return NodeOpts{}
@@ -26,21 +26,21 @@ func BuildClusterNode(n Node, opts NodeOpts) cluster.Node {
out := cluster.Node{ out := cluster.Node{
ID: n.ID, ID: n.ID,
Name: n.Name, Name: n.Name,
Type: n.Type, Role: n.Role,
Labels: n.Labels, Labels: n.Labels,
CreatedAt: n.CreatedAt, CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt, UpdatedAt: n.UpdatedAt,
} }
if opts.IncludeInternalURL && n.Internal != "" { if opts.IncludeAdvertiseUrl && n.AdvertiseUrl != "" {
out.InternalURL = n.Internal out.AdvertiseUrl = n.AdvertiseUrl
} }
if opts.IncludeDBMeta { if opts.IncludeDatabase {
out.DB = &cluster.NodeDB{ out.Database = &cluster.NodeDatabase{
Name: n.DB.Name, Name: n.DB.Name,
User: n.DB.User, User: n.DB.User,
DBLastRotatedAt: n.DB.RotAt, RotatedAt: n.DB.RotAt,
} }
} }

View File

@@ -1,29 +1,29 @@
package cluster package cluster
// NodeDB represents database metadata returned for a node. // NodeDatabase represents database metadata returned for a node.
// swagger:model NodeDB // swagger:model NodeDatabase
type NodeDB struct { type NodeDatabase struct {
Name string `json:"name"` Name string `json:"name"`
User string `json:"user"` User string `json:"user"`
DBLastRotatedAt string `json:"dbLastRotatedAt"` 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 {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Role string `json:"role"`
InternalURL string `json:"internalUrl,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"`
DB *NodeDB `json:"db,omitempty"` Database *NodeDatabase `json:"database,omitempty"`
} }
// DBInfo provides basic database connection metadata for summary endpoints. // DatabaseInfo provides basic database connection metadata for summary endpoints.
// swagger:model DBInfo // swagger:model DatabaseInfo
type DBInfo 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"`
@@ -32,10 +32,10 @@ type DBInfo struct {
// 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 {
PortalUUID string `json:"portalUUID"` UUID string `json:"UUID"`
Nodes int `json:"nodes"` Nodes int `json:"nodes"`
DB DBInfo `json:"db"` Database DatabaseInfo `json:"database"`
Time string `json:"time"` Time string `json:"time"`
} }
// RegisterSecrets contains newly issued or rotated node secrets. // RegisterSecrets contains newly issued or rotated node secrets.
@@ -45,23 +45,23 @@ type RegisterSecrets struct {
NodeSecretLastRotatedAt string `json:"nodeSecretLastRotatedAt,omitempty"` NodeSecretLastRotatedAt string `json:"nodeSecretLastRotatedAt,omitempty"`
} }
// RegisterDB describes database credentials returned during registration/rotation. // RegisterDatabase describes database credentials returned during registration/rotation.
// swagger:model RegisterDB // swagger:model RegisterDatabase
type RegisterDB struct { type RegisterDatabase struct {
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"`
DBLastRotatedAt string `json:"dbLastRotatedAt,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 {
Node Node `json:"node"` Node Node `json:"node"`
DB RegisterDB `json:"db"` Database RegisterDatabase `json:"database"`
Secrets *RegisterSecrets `json:"secrets,omitempty"` Secrets *RegisterSecrets `json:"secrets,omitempty"`
AlreadyRegistered bool `json:"alreadyRegistered"` AlreadyRegistered bool `json:"alreadyRegistered"`
AlreadyProvisioned bool `json:"alreadyProvisioned"` AlreadyProvisioned bool `json:"alreadyProvisioned"`

View File

@@ -0,0 +1,13 @@
package cluster
import (
"github.com/photoprism/photoprism/internal/auth/acl"
)
type NodeRole = string
const (
RolePortal = NodeRole(acl.RolePortal) // A management portal for orchestrating a cluster
RoleInstance = NodeRole(acl.RoleInstance) // A regular PhotoPrism instance that can join a cluster
RoleService = NodeRole(acl.RoleService) // Other service used within a cluster, e.g. Ollama or Vision API
)