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

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

View File

@@ -172,7 +172,7 @@ func ClusterGetNode(router *gin.RouterGroup) {
})
}
// ClusterUpdateNode updates mutable fields: type, labels, internalUrl.
// ClusterUpdateNode updates mutable fields: role, labels, advertiseUrl.
//
// @Summary update node fields
// @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)

View File

@@ -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 := &reg.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,
}

View File

@@ -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 := &reg.Node{ID: "test-id", Name: "pp-node-01", Type: "instance"}
n := &reg.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"}
n.Secret = "oldsecret"
assert.NoError(t, regy.Put(n))

View File

@@ -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 := &reg.Node{ID: "n1", Name: "pp-node-01", Type: "instance"}
n := &reg.Node{ID: "n1", Name: "pp-node-01", Role: "instance"}
assert.NoError(t, regy.Put(n))
n2 := &reg.Node{ID: "n2", Name: "pp-node-02", Type: "service"}
n2 := &reg.Node{ID: "n2", Name: "pp-node-02", Role: "service"}
assert.NoError(t, regy.Put(n2))
// 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 := &reg.Node{ID: "n1", Name: "pp-node-99", Type: "instance"}
n := &reg.Node{ID: "n1", Name: "pp-node-99", Role: "instance"}
assert.NoError(t, regy.Put(n))
// Valid ID returns 200.

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 := &reg.Node{Name: "pp-node-01", Type: "instance", Labels: map[string]string{"env": "test"}}
n := &reg.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
assert.NoError(t, r.Put(n))
// 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"))

View File

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

View File

@@ -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",

View File

@@ -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; 163 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

View File

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

View File

@@ -167,15 +167,6 @@ func (c *Config) SitePreview() string {
return fmt.Sprintf("https://i.photoprism.app/prism?cover=64&style=centered%%20dark&caption=none&title=%s", url.QueryEscape(c.AppName()))
}
// 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 != "" {

View File

@@ -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; 163 chars)",
EnvVars: EnvVars("NODE_NAME"),
Name: "cluster-domain",
Usage: "cluster `DOMAIN` (lowercase DNS name; 163 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*",

View File

@@ -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"`

View File

@@ -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("/")},

View File

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

View File

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

View File

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

View File

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

View File

@@ -622,7 +622,7 @@ func (m *Session) GetData() (data *SessionData) {
if len(m.DataJSON) == 0 {
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
}

View File

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

View File

@@ -13,7 +13,7 @@ import (
"strings"
"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
}
}

View File

@@ -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-*")

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

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