From 13e1c751d41dd0c81d8540f6063d66a42bb508ac Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 19 Sep 2025 01:13:32 +0200 Subject: [PATCH] API: Update entity.Client and cluster config options #98 Signed-off-by: Michael Mayer --- internal/api/cluster_nodes.go | 18 +-- internal/api/cluster_nodes_register.go | 48 +++--- internal/api/cluster_nodes_register_test.go | 18 +-- internal/api/cluster_nodes_test.go | 12 +- internal/api/cluster_permissions_test.go | 8 +- internal/api/cluster_summary.go | 8 +- internal/api/cluster_theme_test.go | 8 +- internal/api/swagger.json | 59 +++---- internal/commands/cluster_exit_codes_test.go | 4 +- internal/commands/cluster_nodes_list.go | 10 +- internal/commands/cluster_nodes_mod.go | 14 +- internal/commands/cluster_nodes_rotate.go | 44 ++--- internal/commands/cluster_nodes_show.go | 10 +- internal/commands/cluster_register.go | 74 ++++----- .../commands/cluster_register_http_test.go | 78 ++++----- internal/commands/cluster_summary.go | 10 +- internal/commands/cluster_test.go | 16 +- internal/commands/cluster_theme_pull.go | 10 +- internal/commands/vision_run.go | 2 +- internal/config/config_cluster.go | 153 ++++++++++-------- internal/config/config_cluster_test.go | 84 +++++----- internal/config/config_site.go | 9 -- internal/config/flags.go | 69 ++++---- internal/config/options.go | 15 +- internal/config/report.go | 14 +- internal/config/report_sections.go | 4 +- internal/config/test.go | 2 +- internal/entity/auth_client.go | 41 ++--- internal/entity/auth_client_data.go | 52 ++++++ internal/entity/auth_session.go | 6 +- internal/service/cluster/const.go | 9 -- .../service/cluster/instance/bootstrap.go | 46 +++--- .../cluster/instance/bootstrap_test.go | 26 +-- internal/service/cluster/policy.go | 2 +- internal/service/cluster/provisioner/db.go | 4 +- .../service/cluster/provisioner/naming.go | 12 +- .../cluster/provisioner/naming_test.go | 14 +- internal/service/cluster/registry/file.go | 22 +-- .../service/cluster/registry/file_test.go | 4 +- internal/service/cluster/registry/response.go | 24 +-- internal/service/cluster/response.go | 64 ++++---- internal/service/cluster/roles.go | 13 ++ 42 files changed, 612 insertions(+), 528 deletions(-) create mode 100644 internal/entity/auth_client_data.go delete mode 100644 internal/service/cluster/const.go create mode 100644 internal/service/cluster/roles.go diff --git a/internal/api/cluster_nodes.go b/internal/api/cluster_nodes.go index 231f34d19..64ee1ccb7 100644 --- a/internal/api/cluster_nodes.go +++ b/internal/api/cluster_nodes.go @@ -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) diff --git a/internal/api/cluster_nodes_register.go b/internal/api/cluster_nodes_register.go index d78414882..f915e820d 100644 --- a/internal/api/cluster_nodes_register.go +++ b/internal/api/cluster_nodes_register.go @@ -25,7 +25,7 @@ import ( // @Tags Cluster // @Accept json // @Produce json -// @Param request body object true "registration payload (nodeName required; optional: nodeType, labels, internalUrl, rotate, rotateSecret)" +// @Param request body object true "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, rotate, rotateSecret)" // @Success 200,201 {object} cluster.RegisterResponse // @Failure 400,401,403,409,429 {object} i18n.Response // @Router /api/v1/cluster/nodes/register [post] @@ -50,7 +50,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) { } // Token check (Bearer). - expected := conf.PortalToken() + expected := conf.JoinToken() token := header.BearerToken(c) if expected == "" || token == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 { @@ -62,12 +62,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) { // Parse request. var req struct { - NodeName string `json:"nodeName"` - NodeType string `json:"nodeType"` - Labels map[string]string `json:"labels"` - InternalUrl string `json:"internalUrl"` - RotateDB bool `json:"rotate"` - RotateSecret bool `json:"rotateSecret"` + NodeName string `json:"nodeName"` + NodeRole string `json:"nodeRole"` + Labels map[string]string `json:"labels"` + AdvertiseUrl string `json:"advertiseUrl"` + RotateDatabase bool `json:"rotateDatabase"` + RotateSecret bool `json:"rotateSecret"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -115,15 +115,15 @@ func ClusterNodesRegister(router *gin.RouterGroup) { } // Ensure that a database for this node exists (rotation optional). - creds, _, credsErr := provisioner.EnsureNodeDB(c, conf, name, req.RotateDB) + creds, _, credsErr := provisioner.EnsureNodeDatabase(c, conf, name, req.RotateDatabase) if credsErr != nil { - event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure db", event.Failed, "%s"}, clean.Error(credsErr)) + event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(credsErr)) c.JSON(http.StatusConflict, gin.H{"error": credsErr.Error()}) return } - if req.RotateDB { + if req.RotateDatabase { n.DB.RotAt = creds.LastRotatedAt if putErr := regy.Put(n); putErr != nil { event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "persist node", event.Failed, "%s"}, clean.Error(putErr)) @@ -137,17 +137,17 @@ func ClusterNodesRegister(router *gin.RouterGroup) { opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine resp := cluster.RegisterResponse{ Node: reg.BuildClusterNode(*n, opts), - DB: cluster.RegisterDB{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User}, + Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.DB.Name, User: n.DB.User}, Secrets: respSecret, AlreadyRegistered: true, AlreadyProvisioned: true, } // Include password/dsn only if rotated now. - if req.RotateDB { - resp.DB.Password = creds.Password - resp.DB.DSN = creds.DSN - resp.DB.DBLastRotatedAt = creds.LastRotatedAt + if req.RotateDatabase { + resp.Database.Password = creds.Password + resp.Database.DSN = creds.DSN + resp.Database.RotatedAt = creds.LastRotatedAt } c.Header(header.CacheControl, header.CacheControlNoStore) @@ -157,11 +157,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) { // New node. n := ®.Node{ - ID: rnd.UUID(), - Name: name, - Type: clean.TypeLowerDash(req.NodeType), - Labels: req.Labels, - Internal: req.InternalUrl, + ID: rnd.UUID(), + Name: name, + Role: clean.TypeLowerDash(req.NodeRole), + Labels: req.Labels, + AdvertiseUrl: req.AdvertiseUrl, } // Generate node secret. @@ -169,9 +169,9 @@ func ClusterNodesRegister(router *gin.RouterGroup) { n.SecretRot = nowRFC3339() // Ensure DB (force rotation at create path to return password). - creds, _, err := provisioner.EnsureNodeDB(c, conf, name, true) + creds, _, err := provisioner.EnsureNodeDatabase(c, conf, name, true) if err != nil { - event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure db", event.Failed, "%s"}, clean.Error(err)) + event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "ensure database", event.Failed, "%s"}, clean.Error(err)) c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } @@ -186,7 +186,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) { resp := cluster.RegisterResponse{ Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)), Secrets: &cluster.RegisterSecrets{NodeSecret: n.Secret, NodeSecretLastRotatedAt: n.SecretRot}, - DB: cluster.RegisterDB{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, DBLastRotatedAt: creds.LastRotatedAt}, + Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.LastRotatedAt}, AlreadyRegistered: false, AlreadyProvisioned: false, } diff --git a/internal/api/cluster_nodes_register_test.go b/internal/api/cluster_nodes_register_test.go index 6d6c01a82..46f13b597 100644 --- a/internal/api/cluster_nodes_register_test.go +++ b/internal/api/cluster_nodes_register_test.go @@ -13,7 +13,7 @@ import ( func TestClusterNodesRegister(t *testing.T) { t.Run("FeatureDisabled", func(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Instance + conf.Options().NodeRole = cluster.RoleInstance ClusterNodesRegister(router) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) @@ -22,7 +22,7 @@ func TestClusterNodesRegister(t *testing.T) { t.Run("MissingToken", func(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal + conf.Options().NodeRole = cluster.RolePortal ClusterNodesRegister(router) r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`) @@ -31,8 +31,8 @@ func TestClusterNodesRegister(t *testing.T) { t.Run("DriverConflict", func(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal - conf.Options().PortalToken = "t0k3n" + conf.Options().NodeRole = cluster.RolePortal + conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // With SQLite driver in tests, provisioning should fail with conflict. @@ -43,8 +43,8 @@ func TestClusterNodesRegister(t *testing.T) { t.Run("BadName", func(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal - conf.Options().PortalToken = "t0k3n" + conf.Options().NodeRole = cluster.RolePortal + conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Empty nodeName → 400 @@ -54,15 +54,15 @@ func TestClusterNodesRegister(t *testing.T) { t.Run("RotateSecretPersistsDespiteDBConflict", func(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal - conf.Options().PortalToken = "t0k3n" + conf.Options().NodeRole = cluster.RolePortal + conf.Options().JoinToken = "t0k3n" ClusterNodesRegister(router) // Pre-create node in registry so handler goes through existing-node path // and rotates the secret before attempting DB ensure. regy, err := reg.NewFileRegistry(conf) assert.NoError(t, err) - n := ®.Node{ID: "test-id", Name: "pp-node-01", Type: "instance"} + n := ®.Node{ID: "test-id", Name: "pp-node-01", Role: "instance"} n.Secret = "oldsecret" assert.NoError(t, regy.Put(n)) diff --git a/internal/api/cluster_nodes_test.go b/internal/api/cluster_nodes_test.go index 2d8cacd8c..5c57d513f 100644 --- a/internal/api/cluster_nodes_test.go +++ b/internal/api/cluster_nodes_test.go @@ -12,7 +12,7 @@ import ( func TestClusterEndpoints(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal + conf.Options().NodeRole = cluster.RolePortal ClusterListNodes(router) ClusterGetNode(router) @@ -26,9 +26,9 @@ func TestClusterEndpoints(t *testing.T) { // Seed nodes in the registry regy, err := reg.NewFileRegistry(conf) assert.NoError(t, err) - n := ®.Node{ID: "n1", Name: "pp-node-01", Type: "instance"} + n := ®.Node{ID: "n1", Name: "pp-node-01", Role: "instance"} assert.NoError(t, regy.Put(n)) - n2 := ®.Node{ID: "n2", Name: "pp-node-02", Type: "service"} + n2 := ®.Node{ID: "n2", Name: "pp-node-02", Role: "service"} assert.NoError(t, regy.Put(n2)) // Get by id @@ -40,7 +40,7 @@ func TestClusterEndpoints(t *testing.T) { assert.Equal(t, http.StatusNotFound, r.Code) // Patch (manage requires Auth; our Auth() in tests allows admin; skip strict role checks here) - r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/n1", `{"internalUrl":"http://n1:2342"}`) + r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/n1", `{"advertiseUrl":"http://n1:2342"}`) assert.Equal(t, http.StatusOK, r.Code) // Pagination: count=1 returns exactly one @@ -63,7 +63,7 @@ func TestClusterEndpoints(t *testing.T) { // Test that ClusterGetNode validates the :id path parameter and rejects unsafe values. func TestClusterGetNode_IDValidation(t *testing.T) { app, router, conf := NewApiTest() - conf.Options().NodeType = cluster.Portal + conf.Options().NodeRole = cluster.RolePortal // Register route under test. ClusterGetNode(router) @@ -71,7 +71,7 @@ func TestClusterGetNode_IDValidation(t *testing.T) { // Seed a node with a simple, valid id. regy, err := reg.NewFileRegistry(conf) assert.NoError(t, err) - n := ®.Node{ID: "n1", Name: "pp-node-99", Type: "instance"} + n := ®.Node{ID: "n1", Name: "pp-node-99", Role: "instance"} assert.NoError(t, regy.Put(n)) // Valid ID returns 200. diff --git a/internal/api/cluster_permissions_test.go b/internal/api/cluster_permissions_test.go index 99c75c20f..8abec2159 100644 --- a/internal/api/cluster_permissions_test.go +++ b/internal/api/cluster_permissions_test.go @@ -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) diff --git a/internal/api/cluster_summary.go b/internal/api/cluster_summary.go index 0c597c997..9888774ed 100644 --- a/internal/api/cluster_summary.go +++ b/internal/api/cluster_summary.go @@ -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), }) }) } diff --git a/internal/api/cluster_theme_test.go b/internal/api/cluster_theme_test.go index 42660ec49..a88a17949 100644 --- a/internal/api/cluster_theme_test.go +++ b/internal/api/cluster_theme_test.go @@ -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). diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 1dd1ebba0..b315d07ee 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -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", diff --git a/internal/commands/cluster_exit_codes_test.go b/internal/commands/cluster_exit_codes_test.go index 85a6b79e6..b43aa0d24 100644 --- a/internal/commands/cluster_exit_codes_test.go +++ b/internal/commands/cluster_exit_codes_test.go @@ -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 { diff --git a/internal/commands/cluster_nodes_list.go b/internal/commands/cluster_nodes_list.go index 9dbc299a0..b0edd5a95 100644 --- a/internal/commands/cluster_nodes_list.go +++ b/internal/commands/cluster_nodes_list.go @@ -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, }) } diff --git a/internal/commands/cluster_nodes_mod.go b/internal/commands/cluster_nodes_mod.go index a8be1e3ec..ed082675b 100644 --- a/internal/commands/cluster_nodes_mod.go +++ b/internal/commands/cluster_nodes_mod.go @@ -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: "", - 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 { diff --git a/internal/commands/cluster_nodes_rotate.go b/internal/commands/cluster_nodes_rotate.go index 9d1f6e184..bc2a17456 100644 --- a/internal/commands/cluster_nodes_rotate.go +++ b/internal/commands/cluster_nodes_rotate.go @@ -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: "", - 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 diff --git a/internal/commands/cluster_nodes_show.go b/internal/commands/cluster_nodes_show.go index 25dba205a..91198e4a1 100644 --- a/internal/commands/cluster_nodes_show.go +++ b/internal/commands/cluster_nodes_show.go @@ -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 { diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go index 39eee9b25..4284fd8f7 100644 --- a/internal/commands/cluster_register.go +++ b/internal/commands/cluster_register.go @@ -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 } diff --git a/internal/commands/cluster_register_http_test.go b/internal/commands/cluster_register_http_test.go index ba05886a7..36ac3da7a 100644 --- a/internal/commands/cluster_register_http_test.go +++ b/internal/commands/cluster_register_http_test.go @@ -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()) diff --git a/internal/commands/cluster_summary.go b/internal/commands/cluster_summary.go index 18dfe9372..612fe71de 100644 --- a/internal/commands/cluster_summary.go +++ b/internal/commands/cluster_summary.go @@ -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 diff --git a/internal/commands/cluster_test.go b/internal/commands/cluster_test.go index f93db7b13..a04dfbaab 100644 --- a/internal/commands/cluster_test.go +++ b/internal/commands/cluster_test.go @@ -34,8 +34,8 @@ func TestClusterNodesListCommand(t *testing.T) { func TestClusterNodesShowCommand(t *testing.T) { t.Run("NotFound", func(t *testing.T) { - _ = os.Setenv("PHOTOPRISM_NODE_TYPE", "portal") - defer os.Unsetenv("PHOTOPRISM_NODE_TYPE") + _ = os.Setenv("PHOTOPRISM_NODE_ROLE", "portal") + defer os.Unsetenv("PHOTOPRISM_NODE_ROLE") out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"}) assert.Error(t, err) _ = out @@ -52,7 +52,7 @@ func TestClusterThemePullCommand(t *testing.T) { func TestClusterRegisterCommand(t *testing.T) { t.Run("ValidationMissingURL", func(t *testing.T) { - out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--type", "instance", "--portal-token", "token"}) + out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"}) assert.Error(t, err) _ = out }) @@ -61,7 +61,7 @@ func TestClusterRegisterCommand(t *testing.T) { func TestClusterSuccessPaths_PortalLocal(t *testing.T) { // Enable portal mode for local admin commands. c := get.Config() - c.Options().NodeType = "portal" + c.Options().NodeRole = "portal" // Ensure registry and theme paths exist. portCfg := c.PortalConfigPath() @@ -77,7 +77,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) { // Create a registry node via FileRegistry. r, err := reg.NewFileRegistry(c) assert.NoError(t, err) - n := ®.Node{Name: "pp-node-01", Type: "instance", Labels: map[string]string{"env": "test"}} + n := ®.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}} assert.NoError(t, r.Put(n)) // nodes ls (JSON) @@ -121,11 +121,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) { defer ts.Close() _ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL) - _ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token") + _ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token") defer os.Unsetenv("PHOTOPRISM_PORTAL_URL") - defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN") + defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN") - out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--portal-token=test-token"}) + out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=test-token"}) assert.NoError(t, err) // Expect extracted file assert.FileExists(t, filepath.Join(destDir, "test.txt")) diff --git a/internal/commands/cluster_theme_pull.go b/internal/commands/cluster_theme_pull.go index 9926442ee..aa77b2dd1 100644 --- a/internal/commands/cluster_theme_pull.go +++ b/internal/commands/cluster_theme_pull.go @@ -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") diff --git a/internal/commands/vision_run.go b/internal/commands/vision_run.go index eeb71c88e..c8cff6aca 100644 --- a/internal/commands/vision_run.go +++ b/internal/commands/vision_run.go @@ -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", diff --git a/internal/config/config_cluster.go b/internal/config/config_cluster.go index 7710342f2..2aecb054d 100644 --- a/internal/config/config_cluster.go +++ b/internal/config/config_cluster.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "gopkg.in/yaml.v2" @@ -12,33 +13,36 @@ import ( "github.com/photoprism/photoprism/pkg/rnd" ) -// NodeName returns the unique name of this node within the cluster (lowercase letters and numbers only). -func (c *Config) NodeName() string { - return clean.TypeLowerDash(c.options.NodeName) +// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 1–63 chars). +func (c *Config) ClusterDomain() string { + return c.options.ClusterDomain } -// NodeType returns the type of this node for cluster operation (portal, instance, service). -func (c *Config) NodeType() string { - switch c.options.NodeType { - case cluster.Portal, cluster.Instance, cluster.Service: - return c.options.NodeType - default: - return cluster.Instance +// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal. +// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist. +func (c *Config) ClusterUUID() string { + // Use value loaded into options only if it is persisted in the current options.yml. + // This avoids tests (or defaults) loading a UUID from an unrelated file path. + if c.options.ClusterUUID != "" { + // Respect explicit CLI value if provided. + if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") { + return c.options.ClusterUUID + } + // Otherwise, only trust a persisted value from the current options.yml. + if fs.FileExists(c.OptionsYaml()) { + return c.options.ClusterUUID + } } -} -// NodeSecret returns the private node key for intra-cluster communication. -func (c *Config) NodeSecret() string { - if c.options.NodeSecret != "" { - return c.options.NodeSecret - } else if fileName := FlagFilePath("NODE_SECRET"); fileName == "" { - return "" - } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { - log.Warnf("config: failed to read node secret from %s (%s)", fileName, err) - return "" - } else { - return string(b) + // Generate, persist, and cache in memory if still empty. + id := rnd.UUID() + c.options.ClusterUUID = id + + if err := c.saveClusterUUID(id); err != nil { + log.Warnf("config: failed to persist ClusterUUID to %s (%s)", c.OptionsYaml(), err) } + + return id } // PortalUrl returns the URL of the cluster portal server, if configured. @@ -46,28 +50,9 @@ func (c *Config) PortalUrl() string { return c.options.PortalUrl } -// PortalToken returns the token required to access the portal API endpoints. -func (c *Config) PortalToken() string { - if c.options.PortalToken != "" { - return c.options.PortalToken - } else if fileName := FlagFilePath("PORTAL_TOKEN"); fileName == "" { - return "" - } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { - log.Warnf("config: failed to read portal token from %s (%s)", fileName, err) - return "" - } else { - return string(b) - } -} - -// ClusterPortal returns true if this instance should act as a cluster portal. -func (c *Config) ClusterPortal() bool { - return c.IsPortal() -} - // IsPortal returns true if the configured node type is "portal". func (c *Config) IsPortal() bool { - return c.NodeType() == cluster.Portal + return c.NodeRole() == cluster.RolePortal } // PortalConfigPath returns the path to the default configuration for cluster nodes. @@ -86,36 +71,66 @@ func (c *Config) PortalThemePath() string { return c.ThemePath() } -// PortalUUID returns a stable UUIDv4 that uniquely identifies the Portal. -// Precedence: env PHOTOPRISM_PORTAL_UUID -> options.yml (PortalUUID) -> auto-generate and persist. -func (c *Config) PortalUUID() string { - // Use value loaded into options only if it is persisted in the current options.yml. - // This avoids tests (or defaults) loading a UUID from an unrelated file path. - if c.options.PortalUUID != "" { - // Respect explicit CLI value if provided. - if c.cliCtx != nil && c.cliCtx.IsSet("portal-uuid") { - return c.options.PortalUUID - } - // Otherwise, only trust a persisted value from the current options.yml. - if fs.FileExists(c.OptionsYaml()) { - return c.options.PortalUUID - } +// JoinToken returns the token required to access the portal API endpoints. +func (c *Config) JoinToken() string { + if c.options.JoinToken != "" { + return c.options.JoinToken + } else if fileName := FlagFilePath("JOIN_TOKEN"); fileName == "" { + return "" + } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { + log.Warnf("config: failed to read portal token from %s (%s)", fileName, err) + return "" + } else { + return string(b) } - - // Generate, persist, and cache in memory if still empty. - id := rnd.UUID() - c.options.PortalUUID = id - - if err := c.savePortalUUID(id); err != nil { - log.Warnf("config: failed to persist PortalUUID to %s (%s)", c.OptionsYaml(), err) - } - - return id } -// savePortalUUID writes or updates the PortalUUID key in options.yml without +// NodeName returns the cluster node NAME (unique in cluster domain; [a-z0-9-]{1,32}). +func (c *Config) NodeName() string { + return clean.TypeLowerDash(c.options.NodeName) +} + +// NodeRole returns the cluster node ROLE (portal, instance, or service). +func (c *Config) NodeRole() string { + switch c.options.NodeRole { + case cluster.RolePortal, cluster.RoleInstance, cluster.RoleService: + return c.options.NodeRole + default: + return cluster.RoleInstance + } +} + +// NodeID returns the client ID registered with the portal (auto-assigned via join token). +func (c *Config) NodeID() string { + return clean.ID(c.options.NodeID) +} + +// NodeSecret returns client SECRET registered with the portal (auto-assigned via join token). +func (c *Config) NodeSecret() string { + if c.options.NodeSecret != "" { + return c.options.NodeSecret + } else if fileName := FlagFilePath("NODE_SECRET"); fileName == "" { + return "" + } else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { + log.Warnf("config: failed to read node secret from %s (%s)", fileName, err) + return "" + } else { + return string(b) + } +} + +// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]). +func (c *Config) AdvertiseUrl() string { + if c.options.AdvertiseUrl == "" { + return c.SiteUrl() + } + + return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/" +} + +// saveClusterUUID writes or updates the ClusterUUID key in options.yml without // touching unrelated keys. Creates the file and directories if needed. -func (c *Config) savePortalUUID(id string) error { +func (c *Config) saveClusterUUID(id string) error { // Always resolve against the current ConfigPath and remember it explicitly // so subsequent calls don't accidentally point to a previous default. cfgDir := c.ConfigPath() @@ -136,7 +151,7 @@ func (c *Config) savePortalUUID(id string) error { m = map[string]interface{}{} } - m["PortalUUID"] = id + m["ClusterUUID"] = id if b, err := yaml.Marshal(m); err != nil { return err diff --git a/internal/config/config_cluster_test.go b/internal/config/config_cluster_test.go index 803980ded..2707f63aa 100644 --- a/internal/config/config_cluster_test.go +++ b/internal/config/config_cluster_test.go @@ -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) } diff --git a/internal/config/config_site.go b/internal/config/config_site.go index 876c04f93..20fe9d21a 100644 --- a/internal/config/config_site.go +++ b/internal/config/config_site.go @@ -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 != "" { diff --git a/internal/config/flags.go b/internal/config/flags.go index 7c3a1905d..074264d57 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -599,16 +599,10 @@ var Flags = CliFlags{ }}, { Flag: &cli.StringFlag{ Name: "site-url", - Usage: "canonical site `URL` used in generated links and to determine HTTPS/TLS; must include scheme (http/https)", + Usage: "canonical site `URL` used in generated links and to determine HTTPS/TLS (scheme://host[:port])", Value: "http://localhost:2342/", EnvVars: EnvVars("SITE_URL"), }}, { - Flag: &cli.StringFlag{ - Name: "internal-url", - Usage: "service base `URL` used for intra-cluster communication and other internal requests *optional*", - Value: "", - EnvVars: EnvVars("INTERNAL_URL"), - }}, { Flag: &cli.StringFlag{ Name: "site-author", Usage: "site `OWNER`, copyright, or artist", @@ -671,40 +665,57 @@ var Flags = CliFlags{ Value: header.DefaultAccessControlAllowMethods, }}, { Flag: &cli.StringFlag{ - Name: "node-name", - Usage: "cluster node `NAME` (lowercase letters, digits, hyphens; 1–63 chars)", - EnvVars: EnvVars("NODE_NAME"), + Name: "cluster-domain", + Usage: "cluster `DOMAIN` (lowercase DNS name; 1–63 chars)", + EnvVars: EnvVars("CLUSTER_DOMAIN"), }}, { Flag: &cli.StringFlag{ - Name: "node-type", - Usage: "cluster node `TYPE` (portal, instance, service)", - EnvVars: EnvVars("NODE_TYPE"), - Hidden: true, - }}, { - Flag: &cli.StringFlag{ - Name: "node-secret", - Usage: "private `KEY` to secure intra-cluster communication *optional*", - EnvVars: EnvVars("NODE_SECRET"), + Name: "cluster-uuid", + Usage: "cluster `UUID` (v4) to scope per-node credentials", + EnvVars: EnvVars("CLUSTER_UUID"), Hidden: true, }}, { Flag: &cli.StringFlag{ Name: "portal-url", - Usage: "base `URL` of the cluster portal, e.g. https://portal.example.com", + Usage: "base `URL` of the cluster portal (e.g. https://portal.example.com)", EnvVars: EnvVars("PORTAL_URL"), Hidden: true, - }, Tags: []string{Pro}}, { + }}, { Flag: &cli.StringFlag{ - Name: "portal-token", - Usage: "access `TOKEN` for nodes to register and synchronize with the portal", - EnvVars: EnvVars("PORTAL_TOKEN"), + Name: "join-token", + Usage: "secret `TOKEN` required to join the cluster", + EnvVars: EnvVars("JOIN_TOKEN"), Hidden: true, - }, Tags: []string{Pro}}, { + }}, { Flag: &cli.StringFlag{ - Name: "portal-uuid", - Usage: "`UUID` (version 4) for the portal to scope per-node credentials *optional*", - EnvVars: EnvVars("PORTAL_UUID"), + Name: "node-name", + Usage: "node `NAME` (unique in cluster domain; [a-z0-9-]{1,32})", + EnvVars: EnvVars("NODE_NAME"), + }}, { + Flag: &cli.StringFlag{ + Name: "node-role", + Usage: "node `ROLE` (portal, instance, or service)", + EnvVars: EnvVars("NODE_ROLE"), Hidden: true, - }, Tags: []string{Pro}}, { + }}, { + Flag: &cli.StringFlag{ + Name: "node-id", + Usage: "client `ID` registered with the portal (auto-assigned via join token)", + EnvVars: EnvVars("NODE_ID"), + Hidden: true, + }}, { + Flag: &cli.StringFlag{ + Name: "node-secret", + Usage: "client `SECRET` registered with the portal (auto-assigned via join token)", + EnvVars: EnvVars("NODE_SECRET"), + Hidden: true, + }}, { + Flag: &cli.StringFlag{ + Name: "advertise-url", + Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])", + Value: "", + EnvVars: EnvVars("ADVERTISE_URL"), + }}, { Flag: &cli.StringFlag{ Name: "https-proxy", Usage: "proxy server `URL` to be used for outgoing connections *optional*", diff --git a/internal/config/options.go b/internal/config/options.go index 341eb6eb2..70e8bb4c3 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -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"` diff --git a/internal/config/report.go b/internal/config/report.go index 89937c6fd..6912fe4e6 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -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("/")}, diff --git a/internal/config/report_sections.go b/internal/config/report_sections.go index 1fd2b3caf..6b199123c 100644 --- a/internal/config/report_sections.go +++ b/internal/config/report_sections.go @@ -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"}, diff --git a/internal/config/test.go b/internal/config/test.go index e73a37926..531327c01 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -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") diff --git a/internal/entity/auth_client.go b/internal/entity/auth_client.go index 21e75ca7d..d9b3800a1 100644 --- a/internal/entity/auth_client.go +++ b/internal/entity/auth_client.go @@ -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. diff --git a/internal/entity/auth_client_data.go b/internal/entity/auth_client_data.go new file mode 100644 index 000000000..57518ae8f --- /dev/null +++ b/internal/entity/auth_client_data.go @@ -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 +} diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 989a1b421..7c427639e 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -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 } diff --git a/internal/service/cluster/const.go b/internal/service/cluster/const.go deleted file mode 100644 index 1ac2b0ade..000000000 --- a/internal/service/cluster/const.go +++ /dev/null @@ -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. -) diff --git a/internal/service/cluster/instance/bootstrap.go b/internal/service/cluster/instance/bootstrap.go index 84abd9fe1..664f5c5e8 100644 --- a/internal/service/cluster/instance/bootstrap.go +++ b/internal/service/cluster/instance/bootstrap.go @@ -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 } } diff --git a/internal/service/cluster/instance/bootstrap_test.go b/internal/service/cluster/instance/bootstrap_test.go index 923f99d42..e8471ba74 100644 --- a/internal/service/cluster/instance/bootstrap_test.go +++ b/internal/service/cluster/instance/bootstrap_test.go @@ -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-*") diff --git a/internal/service/cluster/policy.go b/internal/service/cluster/policy.go index 892cbd2a0..05755088c 100644 --- a/internal/service/cluster/policy.go +++ b/internal/service/cluster/policy.go @@ -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 diff --git a/internal/service/cluster/provisioner/db.go b/internal/service/cluster/provisioner/db.go index 827a9c5a9..5d1e54e53 100644 --- a/internal/service/cluster/provisioner/db.go +++ b/internal/service/cluster/provisioner/db.go @@ -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() { diff --git a/internal/service/cluster/provisioner/naming.go b/internal/service/cluster/provisioner/naming.go index f82e217d3..17819f4cb 100644 --- a/internal/service/cluster/provisioner/naming.go +++ b/internal/service/cluster/provisioner/naming.go @@ -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__ diff --git a/internal/service/cluster/provisioner/naming_test.go b/internal/service/cluster/provisioner/naming_test.go index 65d0d2a2c..e52a97e1f 100644 --- a/internal/service/cluster/provisioner/naming_test.go +++ b/internal/service/cluster/provisioner/naming_test.go @@ -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) } diff --git a/internal/service/cluster/registry/file.go b/internal/service/cluster/registry/file.go index e783fe81e..2cf8ab5e1 100644 --- a/internal/service/cluster/registry/file.go +++ b/internal/service/cluster/registry/file.go @@ -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"` } diff --git a/internal/service/cluster/registry/file_test.go b/internal/service/cluster/registry/file_test.go index f3acef27f..3accf44f2 100644 --- a/internal/service/cluster/registry/file_test.go +++ b/internal/service/cluster/registry/file_test.go @@ -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", } diff --git a/internal/service/cluster/registry/response.go b/internal/service/cluster/registry/response.go index 23918a441..085fe09ca 100644 --- a/internal/service/cluster/registry/response.go +++ b/internal/service/cluster/registry/response.go @@ -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, } } diff --git a/internal/service/cluster/response.go b/internal/service/cluster/response.go index 2c9501e74..e6cca0dc6 100644 --- a/internal/service/cluster/response.go +++ b/internal/service/cluster/response.go @@ -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"` diff --git a/internal/service/cluster/roles.go b/internal/service/cluster/roles.go new file mode 100644 index 000000000..332f037bb --- /dev/null +++ b/internal/service/cluster/roles.go @@ -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 +)