mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
API: Update entity.Client and cluster config options #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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
|
||||
// @Id ClusterUpdateNode
|
||||
@@ -180,7 +180,7 @@ func ClusterGetNode(router *gin.RouterGroup) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @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
|
||||
// @Failure 400,401,403,404,429 {object} i18n.Response
|
||||
// @Router /api/v1/cluster/nodes/{id} [patch]
|
||||
@@ -202,9 +202,9 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
|
||||
id := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
InternalUrl string `json:"internalUrl"`
|
||||
Role string `json:"role"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
AdvertiseUrl string `json:"advertiseUrl"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -226,16 +226,16 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type != "" {
|
||||
n.Type = clean.TypeLowerDash(req.Type)
|
||||
if req.Role != "" {
|
||||
n.Role = clean.TypeLowerDash(req.Role)
|
||||
}
|
||||
|
||||
if req.Labels != nil {
|
||||
n.Labels = req.Labels
|
||||
}
|
||||
|
||||
if req.InternalUrl != "" {
|
||||
n.Internal = req.InternalUrl
|
||||
if req.AdvertiseUrl != "" {
|
||||
n.AdvertiseUrl = req.AdvertiseUrl
|
||||
}
|
||||
|
||||
n.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
@@ -25,7 +25,7 @@ import (
|
||||
// @Tags Cluster
|
||||
// @Accept 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
|
||||
// @Failure 400,401,403,409,429 {object} i18n.Response
|
||||
// @Router /api/v1/cluster/nodes/register [post]
|
||||
@@ -50,7 +50,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Token check (Bearer).
|
||||
expected := conf.PortalToken()
|
||||
expected := conf.JoinToken()
|
||||
token := header.BearerToken(c)
|
||||
|
||||
if expected == "" || token == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 {
|
||||
@@ -62,12 +62,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
// Parse request.
|
||||
var req struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeType string `json:"nodeType"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
InternalUrl string `json:"internalUrl"`
|
||||
RotateDB bool `json:"rotate"`
|
||||
RotateSecret bool `json:"rotateSecret"`
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeRole string `json:"nodeRole"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
AdvertiseUrl string `json:"advertiseUrl"`
|
||||
RotateDatabase bool `json:"rotateDatabase"`
|
||||
RotateSecret bool `json:"rotateSecret"`
|
||||
}
|
||||
|
||||
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).
|
||||
creds, _, credsErr := provisioner.EnsureNodeDB(c, conf, name, req.RotateDB)
|
||||
creds, _, credsErr := provisioner.EnsureNodeDatabase(c, conf, name, req.RotateDatabase)
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.RotateDB {
|
||||
if req.RotateDatabase {
|
||||
n.DB.RotAt = creds.LastRotatedAt
|
||||
if putErr := regy.Put(n); putErr != nil {
|
||||
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
|
||||
resp := cluster.RegisterResponse{
|
||||
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,
|
||||
AlreadyRegistered: true,
|
||||
AlreadyProvisioned: true,
|
||||
}
|
||||
|
||||
// Include password/dsn only if rotated now.
|
||||
if req.RotateDB {
|
||||
resp.DB.Password = creds.Password
|
||||
resp.DB.DSN = creds.DSN
|
||||
resp.DB.DBLastRotatedAt = creds.LastRotatedAt
|
||||
if req.RotateDatabase {
|
||||
resp.Database.Password = creds.Password
|
||||
resp.Database.DSN = creds.DSN
|
||||
resp.Database.RotatedAt = creds.LastRotatedAt
|
||||
}
|
||||
|
||||
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||
@@ -157,11 +157,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
// New node.
|
||||
n := ®.Node{
|
||||
ID: rnd.UUID(),
|
||||
Name: name,
|
||||
Type: clean.TypeLowerDash(req.NodeType),
|
||||
Labels: req.Labels,
|
||||
Internal: req.InternalUrl,
|
||||
ID: rnd.UUID(),
|
||||
Name: name,
|
||||
Role: clean.TypeLowerDash(req.NodeRole),
|
||||
Labels: req.Labels,
|
||||
AdvertiseUrl: req.AdvertiseUrl,
|
||||
}
|
||||
|
||||
// Generate node secret.
|
||||
@@ -169,9 +169,9 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
n.SecretRot = nowRFC3339()
|
||||
|
||||
// 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 {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
||||
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,
|
||||
AlreadyProvisioned: false,
|
||||
}
|
||||
|
@@ -13,7 +13,7 @@ import (
|
||||
func TestClusterNodesRegister(t *testing.T) {
|
||||
t.Run("FeatureDisabled", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Instance
|
||||
conf.Options().NodeRole = cluster.RoleInstance
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
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) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
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) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().PortalToken = "t0k3n"
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// 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) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().PortalToken = "t0k3n"
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Empty nodeName → 400
|
||||
@@ -54,15 +54,15 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
|
||||
t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().PortalToken = "t0k3n"
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.Options().JoinToken = "t0k3n"
|
||||
ClusterNodesRegister(router)
|
||||
|
||||
// Pre-create node in registry so handler goes through existing-node path
|
||||
// and rotates the secret before attempting DB ensure.
|
||||
regy, err := reg.NewFileRegistry(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{ID: "test-id", Name: "pp-node-01", Type: "instance"}
|
||||
n := ®.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
|
||||
n.Secret = "oldsecret"
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
|
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestClusterEndpoints(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
||||
ClusterListNodes(router)
|
||||
ClusterGetNode(router)
|
||||
@@ -26,9 +26,9 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
// Seed nodes in the registry
|
||||
regy, err := reg.NewFileRegistry(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{ID: "n1", Name: "pp-node-01", Type: "instance"}
|
||||
n := ®.Node{ID: "n1", Name: "pp-node-01", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n2 := ®.Node{ID: "n2", Name: "pp-node-02", Type: "service"}
|
||||
n2 := ®.Node{ID: "n2", Name: "pp-node-02", Role: "service"}
|
||||
assert.NoError(t, regy.Put(n2))
|
||||
|
||||
// Get by id
|
||||
@@ -40,7 +40,7 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
|
||||
// Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here)
|
||||
r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/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)
|
||||
|
||||
// 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.
|
||||
func TestClusterGetNode_IDValidation(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
||||
// Register route under test.
|
||||
ClusterGetNode(router)
|
||||
@@ -71,7 +71,7 @@ func TestClusterGetNode_IDValidation(t *testing.T) {
|
||||
// Seed a node with a simple, valid id.
|
||||
regy, err := reg.NewFileRegistry(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{ID: "n1", Name: "pp-node-99", Type: "instance"}
|
||||
n := ®.Node{ID: "n1", Name: "pp-node-99", Role: "instance"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Valid ID returns 200.
|
||||
|
@@ -19,7 +19,7 @@ import (
|
||||
func TestClusterPermissions(t *testing.T) {
|
||||
t.Run("UnauthorizedWhenPublicDisabled", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
||||
// Disable public mode so Auth requires a session.
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
@@ -33,7 +33,7 @@ func TestClusterPermissions(t *testing.T) {
|
||||
|
||||
t.Run("ForbiddenFromCDN", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
|
||||
ClusterListNodes(router)
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestClusterPermissions(t *testing.T) {
|
||||
|
||||
t.Run("AdminCanAccess", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
ClusterSummary(router)
|
||||
token := AuthenticateAdmin(app, router)
|
||||
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) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
|
@@ -46,10 +46,10 @@ func ClusterSummary(router *gin.RouterGroup) {
|
||||
nodes, _ := regy.List()
|
||||
|
||||
c.JSON(http.StatusOK, cluster.SummaryResponse{
|
||||
PortalUUID: conf.PortalUUID(),
|
||||
Nodes: len(nodes),
|
||||
DB: cluster.DBInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
UUID: conf.ClusterUUID(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@@ -20,7 +20,7 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
t.Run("FeatureDisabled", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Ensure portal feature flag is disabled.
|
||||
conf.Options().NodeType = cluster.Instance
|
||||
conf.Options().NodeRole = cluster.RoleInstance
|
||||
ClusterGetTheme(router)
|
||||
|
||||
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) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
ClusterGetTheme(router)
|
||||
|
||||
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) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
ClusterGetTheme(router)
|
||||
|
||||
tempTheme, err := os.MkdirTemp("", "pp-theme-*")
|
||||
@@ -104,7 +104,7 @@ func TestClusterGetTheme(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
// Enable portal feature flag for this endpoint.
|
||||
conf.Options().NodeType = cluster.Portal
|
||||
conf.Options().NodeRole = cluster.RolePortal
|
||||
ClusterGetTheme(router)
|
||||
|
||||
// Create an empty temporary theme directory (no includable files).
|
||||
|
@@ -1719,7 +1719,7 @@
|
||||
"operationId": "ClusterNodesRegister",
|
||||
"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",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -1898,7 +1898,7 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "properties to update (type, labels, internalUrl)",
|
||||
"description": "properties to update (role, labels, advertiseUrl)",
|
||||
"name": "node",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -6195,18 +6195,18 @@
|
||||
"cluster.Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"advertiseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"db": {
|
||||
"$ref": "#/definitions/cluster.NodeDB"
|
||||
"database": {
|
||||
"$ref": "#/definitions/cluster.NodeDatabase"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"internalUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -6216,7 +6216,7 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
@@ -6224,13 +6224,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster.NodeDB": {
|
||||
"cluster.NodeDatabase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dbLastRotatedAt": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"rotatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
@@ -6238,12 +6238,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster.RegisterDB": {
|
||||
"cluster.RegisterDatabase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dbLastRotatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"dsn": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6259,6 +6256,9 @@
|
||||
"port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"rotatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6273,8 +6273,8 @@
|
||||
"alreadyRegistered": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"db": {
|
||||
"$ref": "#/definitions/cluster.RegisterDB"
|
||||
"database": {
|
||||
"$ref": "#/definitions/cluster.RegisterDatabase"
|
||||
},
|
||||
"node": {
|
||||
"$ref": "#/definitions/cluster.Node"
|
||||
@@ -6306,15 +6306,15 @@
|
||||
"cluster.SummaryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"UUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"db": {
|
||||
"$ref": "#/definitions/cluster.DBInfo"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"portalUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6487,9 +6487,6 @@
|
||||
"IndexWorkers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"InternalUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"JpegQuality": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -9463,8 +9460,12 @@
|
||||
1000000000,
|
||||
60000000000,
|
||||
3600000000000,
|
||||
-9223372036854775808,
|
||||
9223372036854775807,
|
||||
1,
|
||||
1000,
|
||||
1000000,
|
||||
1000000000,
|
||||
60000000000,
|
||||
3600000000000,
|
||||
1,
|
||||
1000,
|
||||
1000000,
|
||||
@@ -9481,8 +9482,12 @@
|
||||
"Second",
|
||||
"Minute",
|
||||
"Hour",
|
||||
"minDuration",
|
||||
"maxDuration",
|
||||
"Nanosecond",
|
||||
"Microsecond",
|
||||
"Millisecond",
|
||||
"Second",
|
||||
"Minute",
|
||||
"Hour",
|
||||
"Nanosecond",
|
||||
"Microsecond",
|
||||
"Millisecond",
|
||||
|
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestExitCodes_Register_ValidationAndUnauthorized(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)
|
||||
assert.Error(t, err)
|
||||
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) {
|
||||
ctx := NewTestContext([]string{"mod", "any", "--type", "instance", "-y"})
|
||||
ctx := NewTestContext([]string{"mod", "any", "--role", "instance", "-y"})
|
||||
err := ClusterNodesModCommand.Action(ctx)
|
||||
assert.Error(t, err)
|
||||
if ec, ok := err.(cli.ExitCoder); ok {
|
||||
|
@@ -69,7 +69,7 @@ func clusterNodesListAction(ctx *cli.Context) error {
|
||||
page := items[offset:end]
|
||||
|
||||
// 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)
|
||||
|
||||
if ctx.Bool("json") {
|
||||
@@ -78,15 +78,15 @@ func clusterNodesListAction(ctx *cli.Context) error {
|
||||
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))
|
||||
for _, n := range out {
|
||||
var dbName, dbUser, dbRot string
|
||||
if n.DB != nil {
|
||||
dbName, dbUser, dbRot = n.DB.Name, n.DB.User, n.DB.DBLastRotatedAt
|
||||
if n.Database != nil {
|
||||
dbName, dbUser, dbRot = n.Database.Name, n.Database.User, n.Database.RotatedAt
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
// flags for nodes mod
|
||||
var (
|
||||
nodesModTypeFlag = &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "node `TYPE` (portal, instance, service)"}
|
||||
nodesModInternal = &cli.StringFlag{Name: "internal-url", Aliases: []string{"i"}, Usage: "internal service `URL`"}
|
||||
nodesModRoleFlag = &cli.StringFlag{Name: "role", Aliases: []string{"t"}, Usage: "node `ROLE` (portal, instance, service)"}
|
||||
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)"}
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ var ClusterNodesModCommand = &cli.Command{
|
||||
Name: "mod",
|
||||
Usage: "Updates node properties (Portal-only)",
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ func clusterNodesModAction(ctx *cli.Context) error {
|
||||
return cli.Exit(fmt.Errorf("node not found"), 3)
|
||||
}
|
||||
|
||||
if v := ctx.String("type"); v != "" {
|
||||
n.Type = clean.TypeLowerDash(v)
|
||||
if v := ctx.String("role"); v != "" {
|
||||
n.Role = clean.TypeLowerDash(v)
|
||||
}
|
||||
if v := ctx.String("internal-url"); v != "" {
|
||||
n.Internal = v
|
||||
if v := ctx.String("advertise-url"); v != "" {
|
||||
n.AdvertiseUrl = v
|
||||
}
|
||||
if labels := ctx.StringSlice("label"); len(labels) > 0 {
|
||||
if n.Labels == nil {
|
||||
|
@@ -16,10 +16,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rotateDBFlag = &cli.BoolFlag{Name: "db", Usage: "rotate DB credentials"}
|
||||
rotateSecretFlag = &cli.BoolFlag{Name: "secret", Usage: "rotate node secret"}
|
||||
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)"}
|
||||
rotateDatabaseFlag = &cli.BoolFlag{Name: "database", Usage: "rotate DB credentials"}
|
||||
rotateSecretFlag = &cli.BoolFlag{Name: "secret", Usage: "rotate node secret"}
|
||||
rotatePortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
|
||||
rotatePortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
|
||||
)
|
||||
|
||||
// ClusterNodesRotateCommand triggers rotation via the register endpoint.
|
||||
@@ -27,7 +27,7 @@ var ClusterNodesRotateCommand = &cli.Command{
|
||||
Name: "rotate",
|
||||
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -64,28 +64,28 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
if portalURL == "" {
|
||||
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 == "" {
|
||||
token = conf.PortalToken()
|
||||
token = conf.JoinToken()
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv(config.EnvVar("portal-token"))
|
||||
token = os.Getenv(config.EnvVar("join-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)
|
||||
rotateDB := ctx.Bool("db") || (!ctx.IsSet("db") && !ctx.IsSet("secret"))
|
||||
rotateDatabase := ctx.Bool("database") || (!ctx.IsSet("database") && !ctx.IsSet("secret"))
|
||||
rotateSecret := ctx.Bool("secret")
|
||||
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
if !confirmed {
|
||||
var what string
|
||||
switch {
|
||||
case rotateDB && rotateSecret:
|
||||
case rotateDatabase && rotateSecret:
|
||||
what = "DB credentials and node secret"
|
||||
case rotateDB:
|
||||
case rotateDatabase:
|
||||
what = "DB credentials"
|
||||
case rotateSecret:
|
||||
what = "node secret"
|
||||
@@ -99,7 +99,7 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"rotate": rotateDB,
|
||||
"rotate": rotateDatabase,
|
||||
"rotateSecret": rotateSecret,
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
@@ -131,22 +131,22 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Type", "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)}}
|
||||
cols := []string{"ID", "Name", "Role", "DB Name", "DB User", "Host", "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))
|
||||
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:")
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", 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.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
|
||||
} else if resp.DB.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password))
|
||||
} else if resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
|
||||
}
|
||||
if resp.DB.DSN != "" {
|
||||
fmt.Printf("DSN: %s\n", resp.DB.DSN)
|
||||
if resp.Database.DSN != "" {
|
||||
fmt.Printf("DSN: %s\n", resp.Database.DSN)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@@ -50,7 +50,7 @@ func clusterNodesShowAction(ctx *cli.Context) error {
|
||||
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)
|
||||
|
||||
if ctx.Bool("json") {
|
||||
@@ -59,12 +59,12 @@ func clusterNodesShowAction(ctx *cli.Context) error {
|
||||
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
|
||||
if dto.DB != nil {
|
||||
dbName, dbUser, dbRot = dto.DB.Name, dto.DB.User, dto.DB.DBLastRotatedAt
|
||||
if dto.Database != nil {
|
||||
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))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
if err != nil {
|
||||
|
@@ -24,23 +24,23 @@ import (
|
||||
|
||||
// flags for register
|
||||
var (
|
||||
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
|
||||
regTypeFlag = &cli.StringFlag{Name: "type", Usage: "node `TYPE` (instance, service)", Value: "instance"}
|
||||
regIntUrlFlag = &cli.StringFlag{Name: "internal-url", Usage: "internal service `URL`"}
|
||||
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
|
||||
regRotateDB = &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"}
|
||||
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)"}
|
||||
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)"}
|
||||
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
|
||||
regRoleFlag = &cli.StringFlag{Name: "role", Usage: "node `ROLE` (instance, service)", Value: "instance"}
|
||||
regIntUrlFlag = &cli.StringFlag{Name: "advertise-url", Usage: "internal service `URL`"}
|
||||
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
|
||||
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"}
|
||||
regPortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (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"}
|
||||
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.
|
||||
var ClusterRegisterCommand = &cli.Command{
|
||||
Name: "register",
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
if name == "" {
|
||||
return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2)
|
||||
}
|
||||
nodeType := clean.TypeLowerDash(ctx.String("type"))
|
||||
switch nodeType {
|
||||
nodeRole := clean.TypeLowerDash(ctx.String("role"))
|
||||
switch nodeRole {
|
||||
case "instance", "service":
|
||||
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")
|
||||
@@ -68,19 +68,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
if portalURL == "" {
|
||||
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 == "" {
|
||||
token = conf.PortalToken()
|
||||
token = conf.JoinToken()
|
||||
}
|
||||
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{}{
|
||||
"nodeName": name,
|
||||
"nodeType": nodeType,
|
||||
"nodeRole": nodeRole,
|
||||
"labels": parseLabelSlice(ctx.StringSlice("label")),
|
||||
"internalUrl": ctx.String("internal-url"),
|
||||
"advertiseUrl": ctx.String("advertise-url"),
|
||||
"rotate": ctx.Bool("rotate"),
|
||||
"rotateSecret": ctx.Bool("rotate-secret"),
|
||||
}
|
||||
@@ -116,31 +116,31 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
fmt.Println(string(jb))
|
||||
} else {
|
||||
// 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
|
||||
if resp.DB.Name != "" {
|
||||
dbName = resp.DB.Name
|
||||
if resp.Database.Name != "" {
|
||||
dbName = resp.Database.Name
|
||||
}
|
||||
if resp.DB.User != "" {
|
||||
dbUser = resp.DB.User
|
||||
if resp.Database.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))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
|
||||
// Secrets/credentials block if any
|
||||
// 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:")
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", 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.Database.Password))
|
||||
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
|
||||
} else if resp.DB.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password))
|
||||
} else if resp.Database.Password != "" {
|
||||
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.Database.User, "DB Password", resp.Database.Password))
|
||||
}
|
||||
if resp.DB.DSN != "" {
|
||||
fmt.Printf("DSN: %s\n", resp.DB.DSN)
|
||||
if resp.Database.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)
|
||||
if resp.DB.Name != "" && resp.DB.User != "" {
|
||||
if resp.Database.Name != "" && resp.Database.User != "" {
|
||||
if err := mergeOptionsYaml(conf, map[string]any{
|
||||
"DatabaseDriver": config.MySQL,
|
||||
"DatabaseName": resp.DB.Name,
|
||||
"DatabaseServer": fmt.Sprintf("%s:%d", resp.DB.Host, resp.DB.Port),
|
||||
"DatabaseUser": resp.DB.User,
|
||||
"DatabasePassword": resp.DB.Password,
|
||||
"DatabaseName": resp.Database.Name,
|
||||
"DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
|
||||
"DatabaseUser": resp.Database.User,
|
||||
"DatabasePassword": resp.Database.Password,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -29,8 +29,8 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-02", "type": "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"},
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": false,
|
||||
"alreadyProvisioned": false,
|
||||
@@ -39,7 +39,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
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)
|
||||
// Parse JSON
|
||||
@@ -69,8 +69,8 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-03", "type": "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"},
|
||||
"node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -79,13 +79,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = 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")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_CLI")
|
||||
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.Contains(t, out, "pp-node-03")
|
||||
@@ -107,8 +107,8 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n2", "name": "pp-node-04", "type": "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"},
|
||||
"node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -117,10 +117,10 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = 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")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_CLI")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"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.WriteHeader(http.StatusOK)
|
||||
_ = 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"},
|
||||
"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"},
|
||||
"node": map[string]any{"id": "n3", "name": "pp-node-05", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
// secrets omitted on DB-only rotate
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -170,10 +170,10 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = 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")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_YES")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"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.WriteHeader(http.StatusOK)
|
||||
_ = 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"},
|
||||
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "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"},
|
||||
"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"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -222,9 +222,9 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = 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_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--secret", "--yes", "pp-node-06",
|
||||
})
|
||||
@@ -241,7 +241,7 @@ func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, 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 {
|
||||
assert.Equal(t, 4, ec.ExitCode())
|
||||
@@ -257,7 +257,7 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, 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 {
|
||||
assert.Equal(t, 5, ec.ExitCode())
|
||||
@@ -273,7 +273,7 @@ func TestClusterRegister_HTTPBadRequest(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, 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 {
|
||||
assert.Equal(t, 2, ec.ExitCode())
|
||||
@@ -293,8 +293,8 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "type": "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"},
|
||||
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -302,7 +302,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
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.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()
|
||||
|
||||
_, 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 {
|
||||
assert.Equal(t, 4, ec.ExitCode())
|
||||
@@ -331,7 +331,7 @@ func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, 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 {
|
||||
assert.Equal(t, 5, ec.ExitCode())
|
||||
@@ -347,7 +347,7 @@ func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, 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 {
|
||||
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.WriteHeader(http.StatusOK)
|
||||
_ = 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"},
|
||||
"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"},
|
||||
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -376,13 +376,13 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
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.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) {
|
||||
if r.URL.Path != "/api/v1/cluster/nodes/register" {
|
||||
http.NotFound(w, r)
|
||||
@@ -400,8 +400,8 @@ func TestClusterRegister_RotateDB_JSON(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"node": map[string]any{"id": "n5", "name": "pp-node-07", "type": "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"},
|
||||
"node": map[string]any{"id": "n5", "name": "pp-node-07", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
|
||||
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "databaseLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
@@ -409,7 +409,7 @@ func TestClusterRegister_RotateDB_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
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.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.WriteHeader(http.StatusOK)
|
||||
_ = 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"},
|
||||
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "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"},
|
||||
"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"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
@@ -451,7 +451,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
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.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
|
||||
|
@@ -35,10 +35,10 @@ func clusterSummaryAction(ctx *cli.Context) error {
|
||||
nodes, _ := r.List()
|
||||
|
||||
resp := cluster.SummaryResponse{
|
||||
PortalUUID: conf.PortalUUID(),
|
||||
Nodes: len(nodes),
|
||||
DB: cluster.DBInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
UUID: conf.ClusterUUID(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
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"}
|
||||
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))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
return err
|
||||
|
@@ -34,8 +34,8 @@ func TestClusterNodesListCommand(t *testing.T) {
|
||||
|
||||
func TestClusterNodesShowCommand(t *testing.T) {
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
_ = os.Setenv("PHOTOPRISM_NODE_TYPE", "portal")
|
||||
defer os.Unsetenv("PHOTOPRISM_NODE_TYPE")
|
||||
_ = os.Setenv("PHOTOPRISM_NODE_ROLE", "portal")
|
||||
defer os.Unsetenv("PHOTOPRISM_NODE_ROLE")
|
||||
out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
@@ -52,7 +52,7 @@ func TestClusterThemePullCommand(t *testing.T) {
|
||||
|
||||
func TestClusterRegisterCommand(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)
|
||||
_ = out
|
||||
})
|
||||
@@ -61,7 +61,7 @@ func TestClusterRegisterCommand(t *testing.T) {
|
||||
func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
// Enable portal mode for local admin commands.
|
||||
c := get.Config()
|
||||
c.Options().NodeType = "portal"
|
||||
c.Options().NodeRole = "portal"
|
||||
|
||||
// Ensure registry and theme paths exist.
|
||||
portCfg := c.PortalConfigPath()
|
||||
@@ -77,7 +77,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
// Create a registry node via FileRegistry.
|
||||
r, err := reg.NewFileRegistry(c)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Type: "instance", Labels: map[string]string{"env": "test"}}
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
// nodes ls (JSON)
|
||||
@@ -121,11 +121,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_ = 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_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)
|
||||
// Expect extracted file
|
||||
assert.FileExists(t, filepath.Join(destDir, "test.txt"))
|
||||
|
@@ -30,7 +30,7 @@ var ClusterThemePullCommand = &cli.Command{
|
||||
&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.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,
|
||||
},
|
||||
Action: clusterThemePullAction,
|
||||
@@ -50,15 +50,15 @@ func clusterThemePullAction(ctx *cli.Context) error {
|
||||
if portalURL == "" {
|
||||
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 == "" {
|
||||
token = conf.PortalToken()
|
||||
token = conf.JoinToken()
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv(config.EnvVar("portal-token"))
|
||||
token = os.Getenv(config.EnvVar("join-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")
|
||||
|
@@ -34,7 +34,7 @@ var VisionRunCommand = &cli.Command{
|
||||
Name: "source",
|
||||
Aliases: []string{"s"},
|
||||
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{
|
||||
Name: "force",
|
||||
|
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
@@ -12,33 +13,36 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// NodeName returns the unique name of this node within the cluster (lowercase letters and numbers only).
|
||||
func (c *Config) NodeName() string {
|
||||
return clean.TypeLowerDash(c.options.NodeName)
|
||||
// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 1–63 chars).
|
||||
func (c *Config) ClusterDomain() string {
|
||||
return c.options.ClusterDomain
|
||||
}
|
||||
|
||||
// NodeType returns the type of this node for cluster operation (portal, instance, service).
|
||||
func (c *Config) NodeType() string {
|
||||
switch c.options.NodeType {
|
||||
case cluster.Portal, cluster.Instance, cluster.Service:
|
||||
return c.options.NodeType
|
||||
default:
|
||||
return cluster.Instance
|
||||
// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal.
|
||||
// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist.
|
||||
func (c *Config) ClusterUUID() string {
|
||||
// Use value loaded into options only if it is persisted in the current options.yml.
|
||||
// This avoids tests (or defaults) loading a UUID from an unrelated file path.
|
||||
if c.options.ClusterUUID != "" {
|
||||
// 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.
|
||||
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)
|
||||
// Generate, persist, and cache in memory if still empty.
|
||||
id := rnd.UUID()
|
||||
c.options.ClusterUUID = id
|
||||
|
||||
if err := c.saveClusterUUID(id); err != nil {
|
||||
log.Warnf("config: failed to persist ClusterUUID to %s (%s)", c.OptionsYaml(), err)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// PortalUrl returns the URL of the cluster portal server, if configured.
|
||||
@@ -46,28 +50,9 @@ func (c *Config) PortalUrl() string {
|
||||
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".
|
||||
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.
|
||||
@@ -86,36 +71,66 @@ func (c *Config) PortalThemePath() string {
|
||||
return c.ThemePath()
|
||||
}
|
||||
|
||||
// PortalUUID returns a stable UUIDv4 that uniquely identifies the Portal.
|
||||
// Precedence: env PHOTOPRISM_PORTAL_UUID -> options.yml (PortalUUID) -> auto-generate and persist.
|
||||
func (c *Config) PortalUUID() string {
|
||||
// Use value loaded into options only if it is persisted in the current options.yml.
|
||||
// This avoids tests (or defaults) loading a UUID from an unrelated file path.
|
||||
if c.options.PortalUUID != "" {
|
||||
// Respect explicit CLI value if provided.
|
||||
if c.cliCtx != nil && c.cliCtx.IsSet("portal-uuid") {
|
||||
return c.options.PortalUUID
|
||||
}
|
||||
// Otherwise, only trust a persisted value from the current options.yml.
|
||||
if fs.FileExists(c.OptionsYaml()) {
|
||||
return c.options.PortalUUID
|
||||
}
|
||||
// JoinToken returns the token required to access the portal API endpoints.
|
||||
func (c *Config) JoinToken() string {
|
||||
if c.options.JoinToken != "" {
|
||||
return c.options.JoinToken
|
||||
} else if fileName := FlagFilePath("JOIN_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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) savePortalUUID(id string) error {
|
||||
func (c *Config) saveClusterUUID(id string) error {
|
||||
// Always resolve against the current ConfigPath and remember it explicitly
|
||||
// so subsequent calls don't accidentally point to a previous default.
|
||||
cfgDir := c.ConfigPath()
|
||||
@@ -136,7 +151,7 @@ func (c *Config) savePortalUUID(id string) error {
|
||||
m = map[string]interface{}{}
|
||||
}
|
||||
|
||||
m["PortalUUID"] = id
|
||||
m["ClusterUUID"] = id
|
||||
|
||||
if b, err := yaml.Marshal(m); err != nil {
|
||||
return err
|
||||
|
@@ -18,14 +18,12 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
// Defaults
|
||||
assert.False(t, c.ClusterPortal())
|
||||
assert.False(t, c.IsPortal())
|
||||
|
||||
// Toggle values
|
||||
c.Options().NodeType = string(cluster.Portal)
|
||||
assert.True(t, c.ClusterPortal())
|
||||
c.Options().NodeRole = string(cluster.RolePortal)
|
||||
assert.True(t, c.IsPortal())
|
||||
c.Options().NodeType = ""
|
||||
c.Options().NodeRole = ""
|
||||
})
|
||||
|
||||
t.Run("Paths", func(t *testing.T) {
|
||||
@@ -36,18 +34,18 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
c.options.ConfigPath = tempCfg
|
||||
c.options.NodeSecret = ""
|
||||
c.options.PortalUrl = ""
|
||||
c.options.PortalToken = ""
|
||||
c.options.JoinToken = ""
|
||||
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
|
||||
// Clear values potentially loaded at NewConfig creation.
|
||||
c.options.NodeSecret = ""
|
||||
c.options.PortalUrl = ""
|
||||
c.options.PortalToken = ""
|
||||
c.options.JoinToken = ""
|
||||
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
|
||||
// Clear values that may have been loaded from repo fixtures before we
|
||||
// isolated the config path.
|
||||
c.options.NodeSecret = ""
|
||||
c.options.PortalUrl = ""
|
||||
c.options.PortalToken = ""
|
||||
c.options.JoinToken = ""
|
||||
c.options.OptionsYaml = filepath.Join(tempCfg, "options.yml")
|
||||
|
||||
// PortalConfigPath always points to a "cluster" subfolder under ConfigPath.
|
||||
@@ -78,16 +76,16 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
|
||||
// Defaults (no options.yml present)
|
||||
assert.Equal(t, "", c.PortalUrl())
|
||||
assert.Equal(t, "", c.PortalToken())
|
||||
assert.Equal(t, "", c.JoinToken())
|
||||
assert.Equal(t, "", c.NodeSecret())
|
||||
|
||||
// Set and read back values
|
||||
c.options.PortalUrl = "https://portal.example.test"
|
||||
c.options.PortalToken = "portal-token"
|
||||
c.options.JoinToken = "join-token"
|
||||
c.options.NodeSecret = "node-secret"
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
@@ -116,22 +114,22 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
assert.Equal(t, "", c.NodeName())
|
||||
})
|
||||
|
||||
t.Run("NodeTypeValues", func(t *testing.T) {
|
||||
t.Run("NodeRoleValues", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
// Default / unknown → node
|
||||
c.options.NodeType = ""
|
||||
assert.Equal(t, string(cluster.Instance), c.NodeType())
|
||||
c.options.NodeType = "unknown"
|
||||
assert.Equal(t, string(cluster.Instance), c.NodeType())
|
||||
c.options.NodeRole = ""
|
||||
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
|
||||
c.options.NodeRole = "unknown"
|
||||
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
|
||||
|
||||
// Explicit values
|
||||
c.options.NodeType = string(cluster.Instance)
|
||||
assert.Equal(t, string(cluster.Instance), c.NodeType())
|
||||
c.options.NodeType = string(cluster.Portal)
|
||||
assert.Equal(t, string(cluster.Portal), c.NodeType())
|
||||
c.options.NodeType = string(cluster.Service)
|
||||
assert.Equal(t, string(cluster.Service), c.NodeType())
|
||||
c.options.NodeRole = string(cluster.RoleInstance)
|
||||
assert.Equal(t, string(cluster.RoleInstance), c.NodeRole())
|
||||
c.options.NodeRole = string(cluster.RolePortal)
|
||||
assert.Equal(t, string(cluster.RolePortal), c.NodeRole())
|
||||
c.options.NodeRole = string(cluster.RoleService)
|
||||
assert.Equal(t, string(cluster.RoleService), c.NodeRole())
|
||||
})
|
||||
|
||||
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.
|
||||
c.options.NodeSecret = ""
|
||||
c.options.PortalToken = ""
|
||||
c.options.JoinToken = ""
|
||||
|
||||
// Point env vars at the files and verify.
|
||||
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, "t0k3n", c.PortalToken())
|
||||
assert.Equal(t, "t0k3n", c.JoinToken())
|
||||
|
||||
// Empty / missing should yield empty strings.
|
||||
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.PortalToken())
|
||||
assert.Equal(t, "", c.JoinToken())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_PortalUUID_FileOverridesEnv(t *testing.T) {
|
||||
func TestConfig_ClusterUUID_FileOverridesEnv(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
// Isolate config path.
|
||||
@@ -170,63 +168,63 @@ func TestConfig_PortalUUID_FileOverridesEnv(t *testing.T) {
|
||||
c.options.ConfigPath = tempCfg
|
||||
|
||||
// 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)
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644))
|
||||
|
||||
// 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).
|
||||
assert.NoError(t, c.options.Load(c.OptionsYaml()))
|
||||
got := c.PortalUUID()
|
||||
got := c.ClusterUUID()
|
||||
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())
|
||||
tempCfg := t.TempDir()
|
||||
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)
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(tempCfg, "options.yml"), b, 0o644))
|
||||
|
||||
// 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).
|
||||
assert.NoError(t, c.options.Load(c.OptionsYaml()))
|
||||
// Access the value via getter.
|
||||
got := c.PortalUUID()
|
||||
got := c.ClusterUUID()
|
||||
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.
|
||||
tempCfg := t.TempDir()
|
||||
|
||||
// Start from the default CLI test context and override flags we care about.
|
||||
ctx := CliTestContext()
|
||||
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)
|
||||
|
||||
// No env and no options.yml: should take the CLI flag value directly from options.
|
||||
t.Setenv("PHOTOPRISM_PORTAL_UUID", "")
|
||||
got := c.PortalUUID()
|
||||
t.Setenv("PHOTOPRISM_CLUSTER_UUID", "")
|
||||
got := c.ClusterUUID()
|
||||
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())
|
||||
tempCfg := t.TempDir()
|
||||
c.options.ConfigPath = tempCfg
|
||||
|
||||
// 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) {
|
||||
t.Fatalf("expected a UUIDv4, got %q", got)
|
||||
}
|
||||
@@ -236,9 +234,9 @@ func TestConfig_PortalUUID_GenerateAndPersist(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
var m map[string]any
|
||||
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).
|
||||
got2 := c.PortalUUID()
|
||||
got2 := c.ClusterUUID()
|
||||
assert.Equal(t, got, got2)
|
||||
}
|
||||
|
@@ -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()))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) LegalInfo() string {
|
||||
if s := c.CliContextString("imprint"); s != "" {
|
||||
|
@@ -599,16 +599,10 @@ var Flags = CliFlags{
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
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/",
|
||||
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{
|
||||
Name: "site-author",
|
||||
Usage: "site `OWNER`, copyright, or artist",
|
||||
@@ -671,40 +665,57 @@ var Flags = CliFlags{
|
||||
Value: header.DefaultAccessControlAllowMethods,
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "node-name",
|
||||
Usage: "cluster node `NAME` (lowercase letters, digits, hyphens; 1–63 chars)",
|
||||
EnvVars: EnvVars("NODE_NAME"),
|
||||
Name: "cluster-domain",
|
||||
Usage: "cluster `DOMAIN` (lowercase DNS name; 1–63 chars)",
|
||||
EnvVars: EnvVars("CLUSTER_DOMAIN"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "node-type",
|
||||
Usage: "cluster node `TYPE` (portal, instance, service)",
|
||||
EnvVars: EnvVars("NODE_TYPE"),
|
||||
Hidden: true,
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "node-secret",
|
||||
Usage: "private `KEY` to secure intra-cluster communication *optional*",
|
||||
EnvVars: EnvVars("NODE_SECRET"),
|
||||
Name: "cluster-uuid",
|
||||
Usage: "cluster `UUID` (v4) to scope per-node credentials",
|
||||
EnvVars: EnvVars("CLUSTER_UUID"),
|
||||
Hidden: true,
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
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"),
|
||||
Hidden: true,
|
||||
}, Tags: []string{Pro}}, {
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "portal-token",
|
||||
Usage: "access `TOKEN` for nodes to register and synchronize with the portal",
|
||||
EnvVars: EnvVars("PORTAL_TOKEN"),
|
||||
Name: "join-token",
|
||||
Usage: "secret `TOKEN` required to join the cluster",
|
||||
EnvVars: EnvVars("JOIN_TOKEN"),
|
||||
Hidden: true,
|
||||
}, Tags: []string{Pro}}, {
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "portal-uuid",
|
||||
Usage: "`UUID` (version 4) for the portal to scope per-node credentials *optional*",
|
||||
EnvVars: EnvVars("PORTAL_UUID"),
|
||||
Name: "node-name",
|
||||
Usage: "node `NAME` (unique in cluster domain; [a-z0-9-]{1,32})",
|
||||
EnvVars: EnvVars("NODE_NAME"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "node-role",
|
||||
Usage: "node `ROLE` (portal, instance, or service)",
|
||||
EnvVars: EnvVars("NODE_ROLE"),
|
||||
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{
|
||||
Name: "https-proxy",
|
||||
Usage: "proxy server `URL` to be used for outgoing connections *optional*",
|
||||
|
@@ -131,7 +131,6 @@ type Options struct {
|
||||
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
|
||||
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
|
||||
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"`
|
||||
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
|
||||
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
|
||||
@@ -143,13 +142,15 @@ type Options struct {
|
||||
CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"`
|
||||
CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"`
|
||||
CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"`
|
||||
NodeName string `yaml:"NodeName" json:"-" flag:"node-name"`
|
||||
NodeType string `yaml:"NodeType" json:"-" flag:"node-type"`
|
||||
NodeSecret string `yaml:"NodeSecret" json:"-" flag:"node-secret"`
|
||||
ClusterDomain string `yaml:"ClusterDomain" json:"-" flag:"cluster-domain"`
|
||||
ClusterUUID string `yaml:"ClusterUUID" json:"-" flag:"cluster-uuid"`
|
||||
PortalUrl string `yaml:"PortalUrl" json:"-" flag:"portal-url"`
|
||||
PortalClient string `yaml:"PortalClient" json:"-" flag:"portal-client"`
|
||||
PortalToken string `yaml:"PortalToken" json:"-" flag:"portal-token"`
|
||||
PortalUUID string `yaml:"PortalUUID" json:"-" flag:"portal-uuid"`
|
||||
JoinToken string `yaml:"JoinToken" json:"-" flag:"join-token"`
|
||||
NodeName string `yaml:"NodeName" json:"-" flag:"node-name"`
|
||||
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"`
|
||||
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`
|
||||
TrustedPlatform string `yaml:"TrustedPlatform" json:"-" flag:"trusted-platform"`
|
||||
|
@@ -152,7 +152,6 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
|
||||
// Site Infos.
|
||||
{"site-url", c.SiteUrl()},
|
||||
{"internal-url", c.InternalUrl()},
|
||||
{"site-https", fmt.Sprintf("%t", c.SiteHttps())},
|
||||
{"site-domain", c.SiteDomain()},
|
||||
{"site-author", c.SiteAuthor()},
|
||||
@@ -163,14 +162,17 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
{"site-preview", c.SitePreview()},
|
||||
|
||||
// Cluster Configuration.
|
||||
{"node-name", c.NodeName()},
|
||||
{"node-type", c.NodeType()},
|
||||
{"node-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeSecret())))},
|
||||
{"cluster-domain", c.ClusterDomain()},
|
||||
{"cluster-uuid", c.ClusterUUID()},
|
||||
{"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-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-url", c.CdnUrl("/")},
|
||||
|
@@ -25,7 +25,7 @@ var OptionsReportSections = []ReportSection{
|
||||
{Start: "PHOTOPRISM_READONLY", Title: "Feature Flags"},
|
||||
{Start: "PHOTOPRISM_DEFAULT_LOCALE", Title: "Customization"},
|
||||
{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_DISABLE_TLS", Title: "Web Server"},
|
||||
{Start: "PHOTOPRISM_DATABASE_DRIVER", Title: "Database Connection"},
|
||||
@@ -52,7 +52,7 @@ var YamlReportSections = []ReportSection{
|
||||
{Start: "ReadOnly", Title: "Feature Flags"},
|
||||
{Start: "DefaultLocale", Title: "Customization"},
|
||||
{Start: "SiteUrl", Title: "Site Information"},
|
||||
{Start: "NodeName", Title: "Cluster Configuration"},
|
||||
{Start: "ClusterDomain", Title: "Cluster Configuration"},
|
||||
{Start: "HttpsProxy", Title: "Proxy Server"},
|
||||
{Start: "DisableTLS", Title: "Web Server"},
|
||||
{Start: "DatabaseDriver", Title: "Database Connection"},
|
||||
|
@@ -248,7 +248,7 @@ func CliTestContext() *cli.Context {
|
||||
globalSet.String("import-path", config.OriginalsPath, "doc")
|
||||
globalSet.String("cache-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.Int("backup-retain", config.BackupRetain, "doc")
|
||||
globalSet.String("backup-schedule", config.BackupSchedule, "doc")
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -29,24 +30,28 @@ type Clients []Client
|
||||
|
||||
// Client represents a client application.
|
||||
type Client struct {
|
||||
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"`
|
||||
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
|
||||
user *User `gorm:"-" yaml:"-"`
|
||||
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
|
||||
ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,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"`
|
||||
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"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
|
||||
AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"`
|
||||
AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"`
|
||||
AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"`
|
||||
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
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"`
|
||||
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
|
||||
user *User `gorm:"-" yaml:"-"`
|
||||
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
|
||||
ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,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"`
|
||||
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"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
|
||||
AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"`
|
||||
AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"`
|
||||
AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"`
|
||||
RefreshToken string `gorm:"type:VARBINARY(2048);column:refresh_token;default:'';" json:"-" yaml:"-"`
|
||||
IdToken string `gorm:"type:VARBINARY(2048);column:id_token;default:'';" json:"IdToken,omitempty" yaml:"IdToken,omitempty"`
|
||||
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.
|
||||
|
52
internal/entity/auth_client_data.go
Normal file
52
internal/entity/auth_client_data.go
Normal 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
|
||||
}
|
@@ -622,7 +622,7 @@ func (m *Session) GetData() (data *SessionData) {
|
||||
if len(m.DataJSON) == 0 {
|
||||
return data
|
||||
} 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 {
|
||||
data.RefreshShares()
|
||||
m.data = data
|
||||
@@ -634,7 +634,7 @@ func (m *Session) GetData() (data *SessionData) {
|
||||
// SetData updates the data that belong to this session.
|
||||
func (m *Session) SetData(data *SessionData) *Session {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -642,7 +642,7 @@ func (m *Session) SetData(data *SessionData) *Session {
|
||||
data.RefreshShares()
|
||||
|
||||
if j, err := json.Marshal(data); err != nil {
|
||||
log.Debugf("auth: %s", err)
|
||||
log.Debugf("auth: failed to set session data (%s)", err)
|
||||
} else {
|
||||
m.DataJSON = j
|
||||
}
|
||||
|
@@ -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.
|
||||
)
|
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
@@ -37,13 +37,13 @@ func InitConfig(c *config.Config) error {
|
||||
}
|
||||
|
||||
// Skip on portal nodes and unknown node types.
|
||||
if c.IsPortal() || c.NodeType() != cluster.Instance {
|
||||
if c.IsPortal() || c.NodeRole() != cluster.RoleInstance {
|
||||
return nil
|
||||
}
|
||||
|
||||
portalURL := strings.TrimSpace(c.PortalUrl())
|
||||
portalToken := strings.TrimSpace(c.PortalToken())
|
||||
if portalURL == "" || portalToken == "" {
|
||||
joinToken := strings.TrimSpace(c.JoinToken())
|
||||
if portalURL == "" || joinToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func InitConfig(c *config.Config) error {
|
||||
|
||||
// Register with retry policy.
|
||||
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
|
||||
// or not configured with cluster endpoints (404). Keep as warn to signal
|
||||
// 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.
|
||||
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.
|
||||
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.
|
||||
opts := c.Options()
|
||||
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 == "" &&
|
||||
c.DatabasePassword() == ""
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"nodeName": c.NodeName(),
|
||||
"nodeType": string(cluster.Instance), // JSON wire format is string
|
||||
"internalUrl": c.InternalUrl(),
|
||||
"nodeName": c.NodeName(),
|
||||
"nodeRole": cluster.RoleInstance, // JSON wire format is string
|
||||
"advertiseUrl": c.AdvertiseUrl(),
|
||||
}
|
||||
if wantRotateDB {
|
||||
if wantRotateDatabase {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := persistRegistration(c, &r, wantRotateDB); err != nil {
|
||||
if err := persistRegistration(c, &r, wantRotateDatabase); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
@@ -191,7 +191,7 @@ func isTemporary(err error) bool {
|
||||
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{}{}
|
||||
|
||||
// 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
|
||||
// and local DB not configured (as checked before calling).
|
||||
if wantRotateDB {
|
||||
if r.DB.DSN != "" {
|
||||
if wantRotateDatabase {
|
||||
if r.Database.DSN != "" {
|
||||
updates["DatabaseDriver"] = config.MySQL
|
||||
updates["DatabaseDsn"] = r.DB.DSN
|
||||
} else if r.DB.Name != "" && r.DB.User != "" && r.DB.Password != "" {
|
||||
server := r.DB.Host
|
||||
if r.DB.Port > 0 {
|
||||
server = net.JoinHostPort(r.DB.Host, strconv.Itoa(r.DB.Port))
|
||||
updates["DatabaseDsn"] = r.Database.DSN
|
||||
} else if r.Database.Name != "" && r.Database.User != "" && r.Database.Password != "" {
|
||||
server := r.Database.Host
|
||||
if r.Database.Port > 0 {
|
||||
server = net.JoinHostPort(r.Database.Host, strconv.Itoa(r.Database.Port))
|
||||
}
|
||||
updates["DatabaseDriver"] = config.MySQL
|
||||
updates["DatabaseServer"] = server
|
||||
updates["DatabaseName"] = r.DB.Name
|
||||
updates["DatabaseUser"] = r.DB.User
|
||||
updates["DatabasePassword"] = r.DB.Password
|
||||
updates["DatabaseName"] = r.Database.Name
|
||||
updates["DatabaseUser"] = r.Database.User
|
||||
updates["DatabasePassword"] = r.Database.Password
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -19,8 +19,8 @@ import (
|
||||
func TestInitConfig_NoPortal_NoOp(t *testing.T) {
|
||||
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
|
||||
c := config.NewTestConfig("bootstrap-np")
|
||||
// Default NodeType() resolves to instance; no Portal configured.
|
||||
assert.Equal(t, cluster.Instance, c.NodeType())
|
||||
// Default NodeRole() resolves to instance; no Portal configured.
|
||||
assert.Equal(t, cluster.RoleInstance, c.NodeRole())
|
||||
assert.NoError(t, InitConfig(c))
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
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"},
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"},
|
||||
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)
|
||||
case "/api/v1/cluster/theme":
|
||||
@@ -51,7 +51,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
c := config.NewTestConfig("bootstrap-reg")
|
||||
// Configure Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().PortalToken = "t0k3n"
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
// Gate rotate=true: driver mysql and no DSN/fields.
|
||||
c.Options().DatabaseDriver = config.MySQL
|
||||
c.Options().DatabaseDsn = ""
|
||||
@@ -97,7 +97,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
c := config.NewTestConfig("bootstrap-theme")
|
||||
// Point Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().PortalToken = "t0k3n"
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
|
||||
// Ensure theme dir is empty and unique.
|
||||
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.WriteHeader(http.StatusCreated)
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
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"},
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{NodeSecret: "SECRET"},
|
||||
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)
|
||||
default:
|
||||
@@ -138,7 +138,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
c := config.NewTestConfig("bootstrap-sqlite")
|
||||
// SQLite driver by default; set Portal.
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().PortalToken = "t0k3n"
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
// Remember original DSN so we can ensure it is not changed.
|
||||
origDSN := c.Options().DatabaseDsn
|
||||
t.Cleanup(func() { _ = os.Remove(origDSN) })
|
||||
@@ -167,7 +167,7 @@ func TestRegister_404_NoRetry(t *testing.T) {
|
||||
|
||||
c := config.NewTestConfig("bootstrap-404")
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().PortalToken = "t0k3n"
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
|
||||
// Run bootstrap; registration should attempt once and stop on 404.
|
||||
_ = InitConfig(c)
|
||||
@@ -195,7 +195,7 @@ func TestThemeInstall_SkipWhenAppJsExists(t *testing.T) {
|
||||
|
||||
c := config.NewTestConfig("bootstrap-theme-skip")
|
||||
c.Options().PortalUrl = srv.URL
|
||||
c.Options().PortalToken = "t0k3n"
|
||||
c.Options().JoinToken = "t0k3n"
|
||||
|
||||
// Prepare theme dir with app.js
|
||||
tempTheme, err := os.MkdirTemp("", "pp-theme-*")
|
||||
|
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// BootstrapAutoJoinEnabled indicates whether cluster bootstrap logic is enabled
|
||||
// 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
|
||||
|
||||
// BootstrapAutoThemeEnabled indicates whether bootstrap should attempt to
|
||||
|
@@ -28,11 +28,11 @@ var identRe = regexp.MustCompile(`^[a-z0-9\-_.]+$`)
|
||||
|
||||
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.
|
||||
// - 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.
|
||||
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{}
|
||||
|
||||
switch conf.DatabaseDriver() {
|
||||
|
@@ -22,15 +22,15 @@ const (
|
||||
)
|
||||
|
||||
// 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
|
||||
// if the portal UUID changes. The returned password is random and independent.
|
||||
// plus a random password. Naming is stable for a given (clusterUUID, nodeName) pair and changes
|
||||
// if the cluster UUID changes. The returned password is random and independent.
|
||||
func GenerateCreds(conf *config.Config, nodeName string) (dbName, dbUser, dbPass string) {
|
||||
portalUUID := conf.PortalUUID()
|
||||
clusterUUID := conf.ClusterUUID()
|
||||
slug := clean.TypeLowerDash(nodeName)
|
||||
|
||||
// Compute base32 (no padding) HMAC suffixes scoped by portal UUID.
|
||||
sName := hmacBase32("db-name:"+portalUUID, slug)
|
||||
sUser := hmacBase32("db-user:"+portalUUID, slug)
|
||||
// Compute base32 (no padding) HMAC suffixes scoped by cluster UUID.
|
||||
sName := hmacBase32("db-name:"+clusterUUID, slug)
|
||||
sUser := hmacBase32("db-user:"+clusterUUID, slug)
|
||||
|
||||
// Budgets: user ≤32, db ≤64
|
||||
// Patterns: pp_<slug>_<suffix>
|
||||
|
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
// Fix the portal UUID via options to ensure determinism.
|
||||
c.Options().PortalUUID = "11111111-1111-4111-8111-111111111111"
|
||||
// Fix the cluster UUID via options to ensure determinism.
|
||||
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
db1, user1, pass1 := 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) {
|
||||
c1 := config.NewConfig(config.CliTestContext())
|
||||
c2 := config.NewConfig(config.CliTestContext())
|
||||
c1.Options().PortalUUID = "11111111-1111-4111-8111-111111111111"
|
||||
c2.Options().PortalUUID = "22222222-2222-4222-8222-222222222222"
|
||||
c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
||||
c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222"
|
||||
|
||||
db1, user1, _ := GenerateCreds(c1, "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) {
|
||||
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"
|
||||
db, user, _ := GenerateCreds(c, longName)
|
||||
|
||||
@@ -58,12 +58,12 @@ func TestBuildDSN(t *testing.T) {
|
||||
assert.Contains(t, dsn, "parseTime=true")
|
||||
}
|
||||
|
||||
func TestEnsureNodeDB_SqliteRejected(t *testing.T) {
|
||||
func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
// Ensure we're on SQLite in tests.
|
||||
if c.DatabaseDriver() != config.SQLite3 {
|
||||
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)
|
||||
}
|
||||
|
@@ -16,19 +16,19 @@ import (
|
||||
|
||||
// Node represents a registered cluster node persisted to YAML.
|
||||
type Node struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Labels map[string]string `yaml:"labels" json:"labels"`
|
||||
Internal string `yaml:"internalUrl" json:"internalUrl"`
|
||||
CreatedAt string `yaml:"createdAt" json:"createdAt"`
|
||||
UpdatedAt string `yaml:"updatedAt" json:"updatedAt"`
|
||||
Secret string `yaml:"secret" json:"-"` // never JSON-encoded by default
|
||||
SecretRot string `yaml:"nodeSecretLastRotatedAt" json:"nodeSecretLastRotatedAt"`
|
||||
DB struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Labels map[string]string `yaml:"labels" json:"labels"`
|
||||
AdvertiseUrl string `yaml:"advertiseUrl" json:"advertiseUrl"`
|
||||
CreatedAt string `yaml:"createdAt" json:"createdAt"`
|
||||
UpdatedAt string `yaml:"updatedAt" json:"updatedAt"`
|
||||
Secret string `yaml:"secret" json:"-"` // never JSON-encoded by default
|
||||
SecretRot string `yaml:"nodeSecretLastRotatedAt" json:"nodeSecretLastRotatedAt"`
|
||||
DB struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
User string `yaml:"user" json:"user"`
|
||||
RotAt string `yaml:"lastRotatedAt" json:"dbLastRotatedAt"`
|
||||
RotAt string `yaml:"lastRotatedAt" json:"databaseLastRotatedAt"`
|
||||
} `yaml:"db" json:"db"`
|
||||
}
|
||||
|
||||
|
@@ -27,14 +27,14 @@ func TestFindByNameDeterministic(t *testing.T) {
|
||||
old := Node{
|
||||
ID: "id-old",
|
||||
Name: "pp-node-01",
|
||||
Type: "instance",
|
||||
Role: "instance",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
newer := Node{
|
||||
ID: "id-new",
|
||||
Name: "pp-node-01",
|
||||
Type: "instance",
|
||||
Role: "instance",
|
||||
CreatedAt: "2024-02-01T00:00:00Z",
|
||||
UpdatedAt: "2024-02-01T00:00:00Z",
|
||||
}
|
||||
|
@@ -7,15 +7,15 @@ import (
|
||||
|
||||
// NodeOpts controls which optional fields get included in responses.
|
||||
type NodeOpts struct {
|
||||
IncludeInternalURL bool
|
||||
IncludeDBMeta bool
|
||||
IncludeAdvertiseUrl bool
|
||||
IncludeDatabase bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if s != nil && s.GetUser() != nil && s.GetUser().IsAdmin() {
|
||||
return NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true}
|
||||
return NodeOpts{IncludeAdvertiseUrl: true, IncludeDatabase: true}
|
||||
}
|
||||
|
||||
return NodeOpts{}
|
||||
@@ -26,21 +26,21 @@ func BuildClusterNode(n Node, opts NodeOpts) cluster.Node {
|
||||
out := cluster.Node{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
Type: n.Type,
|
||||
Role: n.Role,
|
||||
Labels: n.Labels,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
}
|
||||
|
||||
if opts.IncludeInternalURL && n.Internal != "" {
|
||||
out.InternalURL = n.Internal
|
||||
if opts.IncludeAdvertiseUrl && n.AdvertiseUrl != "" {
|
||||
out.AdvertiseUrl = n.AdvertiseUrl
|
||||
}
|
||||
|
||||
if opts.IncludeDBMeta {
|
||||
out.DB = &cluster.NodeDB{
|
||||
Name: n.DB.Name,
|
||||
User: n.DB.User,
|
||||
DBLastRotatedAt: n.DB.RotAt,
|
||||
if opts.IncludeDatabase {
|
||||
out.Database = &cluster.NodeDatabase{
|
||||
Name: n.DB.Name,
|
||||
User: n.DB.User,
|
||||
RotatedAt: n.DB.RotAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,29 +1,29 @@
|
||||
package cluster
|
||||
|
||||
// NodeDB represents database metadata returned for a node.
|
||||
// swagger:model NodeDB
|
||||
type NodeDB struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
DBLastRotatedAt string `json:"dbLastRotatedAt"`
|
||||
// NodeDatabase represents database metadata returned for a node.
|
||||
// swagger:model NodeDatabase
|
||||
type NodeDatabase struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
RotatedAt string `json:"rotatedAt"`
|
||||
}
|
||||
|
||||
// Node is the API response DTO for a cluster node.
|
||||
// swagger:model Node
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
InternalURL string `json:"internalUrl,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
DB *NodeDB `json:"db,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Database *NodeDatabase `json:"database,omitempty"`
|
||||
}
|
||||
|
||||
// DBInfo provides basic database connection metadata for summary endpoints.
|
||||
// swagger:model DBInfo
|
||||
type DBInfo struct {
|
||||
// DatabaseInfo provides basic database connection metadata for summary endpoints.
|
||||
// swagger:model DatabaseInfo
|
||||
type DatabaseInfo struct {
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -32,10 +32,10 @@ type DBInfo struct {
|
||||
// SummaryResponse is the response type for GET /api/v1/cluster.
|
||||
// swagger:model SummaryResponse
|
||||
type SummaryResponse struct {
|
||||
PortalUUID string `json:"portalUUID"`
|
||||
Nodes int `json:"nodes"`
|
||||
DB DBInfo `json:"db"`
|
||||
Time string `json:"time"`
|
||||
UUID string `json:"UUID"`
|
||||
Nodes int `json:"nodes"`
|
||||
Database DatabaseInfo `json:"database"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// RegisterSecrets contains newly issued or rotated node secrets.
|
||||
@@ -45,23 +45,23 @@ type RegisterSecrets struct {
|
||||
NodeSecretLastRotatedAt string `json:"nodeSecretLastRotatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterDB describes database credentials returned during registration/rotation.
|
||||
// swagger:model RegisterDB
|
||||
type RegisterDB struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
DSN string `json:"dsn,omitempty"`
|
||||
DBLastRotatedAt string `json:"dbLastRotatedAt,omitempty"`
|
||||
// RegisterDatabase describes database credentials returned during registration/rotation.
|
||||
// swagger:model RegisterDatabase
|
||||
type RegisterDatabase struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
DSN string `json:"dsn,omitempty"`
|
||||
RotatedAt string `json:"rotatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterResponse is the response body for POST /api/v1/cluster/nodes/register.
|
||||
// swagger:model RegisterResponse
|
||||
type RegisterResponse struct {
|
||||
Node Node `json:"node"`
|
||||
DB RegisterDB `json:"db"`
|
||||
Database RegisterDatabase `json:"database"`
|
||||
Secrets *RegisterSecrets `json:"secrets,omitempty"`
|
||||
AlreadyRegistered bool `json:"alreadyRegistered"`
|
||||
AlreadyProvisioned bool `json:"alreadyProvisioned"`
|
||||
|
13
internal/service/cluster/roles.go
Normal file
13
internal/service/cluster/roles.go
Normal 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
|
||||
)
|
Reference in New Issue
Block a user