mirror of
https://github.com/photoprism/photoprism.git
synced 2025-10-04 16:33:19 +08:00
CLI: Add cluster operations and management commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -50,7 +50,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
clientIp := ClientIP(c)
|
||||
themePath := conf.PortalThemePath()
|
||||
themePath := conf.ThemePath()
|
||||
|
||||
// Resolve symbolic links.
|
||||
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
|
||||
|
@@ -30,11 +30,17 @@ func authRemoveAction(ctx *cli.Context) error {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Remove session %s?", clean.LogQuote(id)),
|
||||
IsConfirm: true,
|
||||
if cliMode == NONINTERACTIVE {
|
||||
// proceed without prompt
|
||||
if m, err := query.Session(id); err != nil {
|
||||
return errors.New("session not found")
|
||||
} else if err := m.Delete(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
log.Infof("session %s has been removed", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
} else {
|
||||
actionPrompt := promptui.Prompt{Label: fmt.Sprintf("Remove session %s?", clean.LogQuote(id)), IsConfirm: true}
|
||||
if _, err := actionPrompt.Run(); err == nil {
|
||||
if m, err := query.Session(id); err != nil {
|
||||
return errors.New("session not found")
|
||||
@@ -46,6 +52,7 @@ func authRemoveAction(ctx *cli.Context) error {
|
||||
} else {
|
||||
log.Infof("session %s was not removed", clean.LogQuote(id))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
@@ -35,7 +35,7 @@ var AuthResetCommand = &cli.Command{
|
||||
// authResetAction removes all sessions and resets the related database table to a clean state.
|
||||
func authResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
@@ -50,7 +50,7 @@ func clientsRemoveAction(ctx *cli.Context) error {
|
||||
return fmt.Errorf("client %s has already been deleted", clean.Log(id))
|
||||
}
|
||||
|
||||
if !ctx.Bool("force") {
|
||||
if !ctx.Bool("force") && !RunNonInteractively(false) {
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Delete client %s?", m.GetUID()),
|
||||
IsConfirm: true,
|
||||
|
@@ -31,7 +31,7 @@ var ClientsResetCommand = &cli.Command{
|
||||
// clientsResetAction removes all registered client applications.
|
||||
func clientsResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
31
internal/commands/cluster.go
Normal file
31
internal/commands/cluster.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// JsonFlag enables machine-readable JSON output for cluster commands.
|
||||
var JsonFlag = &cli.BoolFlag{
|
||||
Name: "json",
|
||||
Usage: "print machine-readable JSON",
|
||||
}
|
||||
|
||||
// OffsetFlag for pagination offset (>= 0).
|
||||
var OffsetFlag = &cli.IntFlag{
|
||||
Name: "offset",
|
||||
Usage: "result `OFFSET` (>= 0)",
|
||||
Value: 0,
|
||||
}
|
||||
|
||||
// ClusterCommands configures the cluster command group and subcommands.
|
||||
var ClusterCommands = &cli.Command{
|
||||
Name: "cluster",
|
||||
Usage: "Cluster operations and management (portal, nodes)",
|
||||
Subcommands: []*cli.Command{
|
||||
ClusterSummaryCommand,
|
||||
ClusterHealthCommand,
|
||||
ClusterNodesCommands,
|
||||
ClusterRegisterCommand,
|
||||
ClusterThemePullCommand,
|
||||
},
|
||||
}
|
47
internal/commands/cluster_health.go
Normal file
47
internal/commands/cluster_health.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// ClusterHealthCommand prints a minimal health response (Portal-only).
|
||||
var ClusterHealthCommand = &cli.Command{
|
||||
Name: "health",
|
||||
Usage: "Shows cluster health (Portal-only)",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Action: clusterHealthAction,
|
||||
}
|
||||
|
||||
func clusterHealthAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("cluster health is only available on a Portal node")
|
||||
}
|
||||
|
||||
resp := healthResponse{Status: "ok", Time: time.Now().UTC().Format(time.RFC3339)}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
b, _ := json.Marshal(resp)
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"Status", "Time"}
|
||||
rows := [][]string{{resp.Status, resp.Time}}
|
||||
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
return err
|
||||
})
|
||||
}
|
113
internal/commands/cluster_nodes_list.go
Normal file
113
internal/commands/cluster_nodes_list.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// ClusterNodesCommands groups node subcommands.
|
||||
var ClusterNodesCommands = &cli.Command{
|
||||
Name: "nodes",
|
||||
Usage: "Node registry subcommands",
|
||||
Subcommands: []*cli.Command{
|
||||
ClusterNodesListCommand,
|
||||
ClusterNodesShowCommand,
|
||||
ClusterNodesModCommand,
|
||||
ClusterNodesRemoveCommand,
|
||||
ClusterNodesRotateCommand,
|
||||
},
|
||||
}
|
||||
|
||||
// ClusterNodesListCommand lists registered nodes.
|
||||
var ClusterNodesListCommand = &cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "Lists registered cluster nodes (Portal-only)",
|
||||
Flags: append(append(report.CliFlags, JsonFlag), CountFlag, OffsetFlag),
|
||||
ArgsUsage: "",
|
||||
Action: clusterNodesListAction,
|
||||
}
|
||||
|
||||
func clusterNodesListAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("node listing is only available on a Portal node")
|
||||
}
|
||||
|
||||
r, err := reg.NewFileRegistry(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, err := r.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pagination identical to API defaults.
|
||||
count := int(ctx.Uint("count"))
|
||||
if count <= 0 || count > 1000 {
|
||||
count = 100
|
||||
}
|
||||
offset := ctx.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > len(items) {
|
||||
offset = len(items)
|
||||
}
|
||||
end := offset + count
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
page := items[offset:end]
|
||||
|
||||
// Build admin view (include internal URL and DB meta).
|
||||
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true}
|
||||
out := reg.BuildClusterNodes(page, opts)
|
||||
|
||||
if ctx.Bool("json") {
|
||||
b, _ := json.Marshal(out)
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Type", "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
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
n.ID, n.Name, n.Type, formatLabels(n.Labels), n.InternalURL, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
log.Warnf("no nodes registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", result)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func formatLabels(m map[string]string) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
103
internal/commands/cluster_nodes_mod.go
Normal file
103
internal/commands/cluster_nodes_mod.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// 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`"}
|
||||
nodesModLabel = &cli.StringSliceFlag{Name: "label", Aliases: []string{"l"}, Usage: "`k=v` label (repeatable)"}
|
||||
)
|
||||
|
||||
// ClusterNodesModCommand updates node fields.
|
||||
var ClusterNodesModCommand = &cli.Command{
|
||||
Name: "mod",
|
||||
Usage: "Updates node properties (Portal-only)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: []cli.Flag{nodesModTypeFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}},
|
||||
Action: clusterNodesModAction,
|
||||
}
|
||||
|
||||
func clusterNodesModAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("node update is only available on a Portal node")
|
||||
}
|
||||
|
||||
key := ctx.Args().First()
|
||||
if key == "" {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
r, err := reg.NewFileRegistry(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, getErr := r.Get(key)
|
||||
if getErr != nil {
|
||||
name := clean.TypeLowerDash(key)
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid node identifier")
|
||||
}
|
||||
n, getErr = r.FindByName(name)
|
||||
}
|
||||
if getErr != nil || n == nil {
|
||||
return fmt.Errorf("node not found")
|
||||
}
|
||||
|
||||
if v := ctx.String("type"); v != "" {
|
||||
n.Type = clean.TypeLowerDash(v)
|
||||
}
|
||||
if v := ctx.String("internal-url"); v != "" {
|
||||
n.Internal = v
|
||||
}
|
||||
if labels := ctx.StringSlice("label"); len(labels) > 0 {
|
||||
if n.Labels == nil {
|
||||
n.Labels = map[string]string{}
|
||||
}
|
||||
for _, kv := range labels {
|
||||
if k, v, ok := splitKV(kv); ok {
|
||||
n.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
if !confirmed {
|
||||
prompt := promptui.Prompt{Label: fmt.Sprintf("Update node %s?", clean.LogQuote(n.Name)), IsConfirm: true}
|
||||
if _, err := prompt.Run(); err != nil {
|
||||
log.Infof("update cancelled for %s", clean.LogQuote(n.Name))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.Put(n); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("node %s has been updated", clean.LogQuote(n.Name))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func splitKV(s string) (string, string, bool) {
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i <= 0 || i >= len(s)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
67
internal/commands/cluster_nodes_remove.go
Normal file
67
internal/commands/cluster_nodes_remove.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// ClusterNodesRemoveCommand deletes a node from the registry.
|
||||
var ClusterNodesRemoveCommand = &cli.Command{
|
||||
Name: "rm",
|
||||
Usage: "Deletes a node from the registry (Portal-only)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"},
|
||||
},
|
||||
Action: clusterNodesRemoveAction,
|
||||
}
|
||||
|
||||
func clusterNodesRemoveAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("node delete is only available on a Portal node")
|
||||
}
|
||||
|
||||
key := ctx.Args().First()
|
||||
if key == "" {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
r, err := reg.NewFileRegistry(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve to id for deletion, but also support name.
|
||||
id := key
|
||||
if _, getErr := r.Get(id); getErr != nil {
|
||||
if n, err2 := r.FindByName(clean.TypeLowerDash(key)); err2 == nil && n != nil {
|
||||
id = n.ID
|
||||
} else {
|
||||
return fmt.Errorf("node not found")
|
||||
}
|
||||
}
|
||||
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
if !confirmed {
|
||||
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(id)), IsConfirm: true}
|
||||
if _, err := prompt.Run(); err != nil {
|
||||
log.Infof("node %s was not deleted", clean.Log(id))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("node %s has been deleted", clean.Log(id))
|
||||
return nil
|
||||
})
|
||||
}
|
139
internal/commands/cluster_nodes_rotate.go
Normal file
139
internal/commands/cluster_nodes_rotate.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
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)"}
|
||||
)
|
||||
|
||||
// ClusterNodesRotateCommand triggers rotation via the register endpoint.
|
||||
var ClusterNodesRotateCommand = &cli.Command{
|
||||
Name: "rotate",
|
||||
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: append([]cli.Flag{rotateDBFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...),
|
||||
Action: clusterNodesRotateAction,
|
||||
}
|
||||
|
||||
func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
key := ctx.Args().First()
|
||||
if key == "" {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
// Determine node name. On portal, resolve id->name via registry; otherwise treat key as name.
|
||||
name := clean.TypeLowerDash(key)
|
||||
if conf.IsPortal() {
|
||||
if r, err := reg.NewFileRegistry(conf); err == nil {
|
||||
if n, err := r.Get(key); err == nil && n != nil {
|
||||
name = n.Name
|
||||
} else if n, err := r.FindByName(clean.TypeLowerDash(key)); err == nil && n != nil {
|
||||
name = n.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid node identifier")
|
||||
}
|
||||
|
||||
// Portal URL and token
|
||||
portalURL := ctx.String("portal-url")
|
||||
if portalURL == "" {
|
||||
portalURL = conf.PortalUrl()
|
||||
}
|
||||
if portalURL == "" {
|
||||
portalURL = os.Getenv(config.EnvVar("portal-url"))
|
||||
}
|
||||
if portalURL == "" {
|
||||
return fmt.Errorf("portal URL is required (use --portal-url or set portal-url)")
|
||||
}
|
||||
token := ctx.String("portal-token")
|
||||
if token == "" {
|
||||
token = conf.PortalToken()
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv(config.EnvVar("portal-token"))
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("portal token is required (use --portal-token or set portal-token)")
|
||||
}
|
||||
|
||||
// Default: rotate DB only if no flag given (safer default)
|
||||
rotateDB := ctx.Bool("db") || (!ctx.IsSet("db") && !ctx.IsSet("secret"))
|
||||
rotateSecret := ctx.Bool("secret")
|
||||
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
if !confirmed {
|
||||
var what string
|
||||
switch {
|
||||
case rotateDB && rotateSecret:
|
||||
what = "DB credentials and node secret"
|
||||
case rotateDB:
|
||||
what = "DB credentials"
|
||||
case rotateSecret:
|
||||
what = "node secret"
|
||||
}
|
||||
prompt := promptui.Prompt{Label: fmt.Sprintf("Rotate %s for %s?", what, clean.LogQuote(name)), IsConfirm: true}
|
||||
if _, err := prompt.Run(); err != nil {
|
||||
log.Infof("rotation cancelled for %s", clean.LogQuote(name))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"rotate": rotateDB,
|
||||
"rotateSecret": rotateSecret,
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
|
||||
var resp cluster.RegisterResponse
|
||||
if err := postWithBackoff(url, token, b, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
jb, _ := json.Marshal(resp)
|
||||
fmt.Println(string(jb))
|
||||
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)}}
|
||||
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
|
||||
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.DB.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))
|
||||
} 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))
|
||||
}
|
||||
if resp.DB.DSN != "" {
|
||||
fmt.Printf("DSN: %s\n", resp.DB.DSN)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
72
internal/commands/cluster_nodes_show.go
Normal file
72
internal/commands/cluster_nodes_show.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// ClusterNodesShowCommand shows node details.
|
||||
var ClusterNodesShowCommand = &cli.Command{
|
||||
Name: "show",
|
||||
Usage: "Shows node details (Portal-only)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Action: clusterNodesShowAction,
|
||||
}
|
||||
|
||||
func clusterNodesShowAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("node show is only available on a Portal node")
|
||||
}
|
||||
|
||||
key := ctx.Args().First()
|
||||
if key == "" {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
r, err := reg.NewFileRegistry(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve by id first, then by normalized name.
|
||||
n, getErr := r.Get(key)
|
||||
if getErr != nil {
|
||||
name := clean.TypeLowerDash(key)
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid node identifier")
|
||||
}
|
||||
n, getErr = r.FindByName(name)
|
||||
}
|
||||
if getErr != nil || n == nil {
|
||||
return fmt.Errorf("node not found")
|
||||
}
|
||||
|
||||
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true}
|
||||
dto := reg.BuildClusterNode(*n, opts)
|
||||
|
||||
if ctx.Bool("json") {
|
||||
b, _ := json.Marshal(dto)
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"ID", "Name", "Type", "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
|
||||
}
|
||||
rows := [][]string{{dto.ID, dto.Name, dto.Type, dto.InternalURL, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
|
||||
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
return err
|
||||
})
|
||||
}
|
295
internal/commands/cluster_register.go
Normal file
295
internal/commands/cluster_register.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// 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)"}
|
||||
)
|
||||
|
||||
// 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...)),
|
||||
Action: clusterRegisterAction,
|
||||
}
|
||||
|
||||
func clusterRegisterAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
// Resolve inputs
|
||||
name := clean.TypeLowerDash(ctx.String("name"))
|
||||
if name == "" { // default from config if set
|
||||
name = clean.TypeLowerDash(conf.NodeName())
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("node name is required (use --name or set node-name)")
|
||||
}
|
||||
nodeType := clean.TypeLowerDash(ctx.String("type"))
|
||||
switch nodeType {
|
||||
case "instance", "service":
|
||||
default:
|
||||
return fmt.Errorf("invalid --type (must be instance or service)")
|
||||
}
|
||||
|
||||
portalURL := ctx.String("portal-url")
|
||||
if portalURL == "" {
|
||||
portalURL = conf.PortalUrl()
|
||||
}
|
||||
if portalURL == "" {
|
||||
return fmt.Errorf("portal URL is required (use --portal-url or set portal-url)")
|
||||
}
|
||||
token := ctx.String("portal-token")
|
||||
if token == "" {
|
||||
token = conf.PortalToken()
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("portal token is required (use --portal-token or set portal-token)")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"nodeType": nodeType,
|
||||
"labels": parseLabelSlice(ctx.StringSlice("label")),
|
||||
"internalUrl": ctx.String("internal-url"),
|
||||
"rotate": ctx.Bool("rotate"),
|
||||
"rotateSecret": ctx.Bool("rotate-secret"),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
// POST with bounded backoff on 429
|
||||
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
|
||||
var resp cluster.RegisterResponse
|
||||
if err := postWithBackoff(url, token, b, &resp); err != nil {
|
||||
var httpErr *httpError
|
||||
if errors.As(err, &httpErr) && httpErr.Status == http.StatusTooManyRequests {
|
||||
return fmt.Errorf("portal rate-limited registration attempts")
|
||||
}
|
||||
// Map common errors
|
||||
if errors.As(err, &httpErr) {
|
||||
switch httpErr.Status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("%s", httpErr.Error())
|
||||
case http.StatusConflict:
|
||||
return fmt.Errorf("%s", httpErr.Error())
|
||||
case http.StatusBadRequest:
|
||||
return fmt.Errorf("%s", httpErr.Error())
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("%s", httpErr.Error())
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Output
|
||||
if ctx.Bool("json") {
|
||||
jb, _ := json.Marshal(resp)
|
||||
fmt.Println(string(jb))
|
||||
} else {
|
||||
// Human-readable: node row and credentials if present
|
||||
cols := []string{"ID", "Name", "Type", "DB Name", "DB User", "Host", "Port"}
|
||||
var dbName, dbUser string
|
||||
if resp.DB.Name != "" {
|
||||
dbName = resp.DB.Name
|
||||
}
|
||||
if resp.DB.User != "" {
|
||||
dbUser = resp.DB.User
|
||||
}
|
||||
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Type, dbName, dbUser, resp.DB.Host, fmt.Sprintf("%d", resp.DB.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 != "" {
|
||||
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))
|
||||
} 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))
|
||||
}
|
||||
if resp.DB.DSN != "" {
|
||||
fmt.Printf("DSN: %s\n", resp.DB.DSN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional persistence
|
||||
if ctx.Bool("write-config") {
|
||||
if err := persistRegisterResponse(conf, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP helpers and backoff
|
||||
|
||||
type httpError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
|
||||
|
||||
func postWithBackoff(url, token string, payload []byte, out any) error {
|
||||
// backoff: 500ms -> max ~8s, 6 attempts with jitter
|
||||
delay := 500 * time.Millisecond
|
||||
for attempt := 0; attempt < 6; attempt++ {
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
// backoff and retry
|
||||
time.Sleep(jitter(delay, 0.25))
|
||||
if delay < 8*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return &httpError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
return dec.Decode(out)
|
||||
}
|
||||
return &httpError{Status: http.StatusTooManyRequests, Body: "rate limited"}
|
||||
}
|
||||
|
||||
func jitter(d time.Duration, frac float64) time.Duration {
|
||||
// simple +/- jitter
|
||||
n := time.Duration(float64(d) * (1 + (randFloat()*2-1)*frac))
|
||||
if n <= 0 {
|
||||
return d
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// tiny rand without pulling math/rand global state unpredictably
|
||||
func randFloat() float64 { return float64(time.Now().UnixNano()%1000) / 1000.0 }
|
||||
|
||||
func stringsTrimRightSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Persistence helpers for --write-config
|
||||
func parseLabelSlice(labels []string) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, kv := range labels {
|
||||
if i := bytes.IndexByte([]byte(kv), '='); i > 0 && i < len(kv)-1 {
|
||||
k := kv[:i]
|
||||
v := kv[i+1:]
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Persistence helpers for --write-config
|
||||
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
|
||||
// Node secret file
|
||||
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
|
||||
// Prefer PHOTOPRISM_NODE_SECRET_FILE; otherwise config cluster path
|
||||
fileName := os.Getenv(config.FlagFileVar("NODE_SECRET"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Join(conf.PortalConfigPath(), "node-secret")
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fileName, []byte(resp.Secrets.NodeSecret), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("wrote node secret to %s", clean.Log(fileName))
|
||||
}
|
||||
|
||||
// DB settings (MySQL/MariaDB only)
|
||||
if resp.DB.Name != "" && resp.DB.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,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("updated options.yml with database settings for node %s", clean.LogQuote(resp.Node.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeOptionsYaml(conf *config.Config, kv map[string]any) error {
|
||||
fileName := conf.OptionsYaml()
|
||||
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if fs.FileExists(fileName) {
|
||||
if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 {
|
||||
_ = yaml.Unmarshal(b, &m)
|
||||
}
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]any{}
|
||||
}
|
||||
for k, v := range kv {
|
||||
m[k] = v
|
||||
}
|
||||
b, err := yaml.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(fileName, b, 0o644)
|
||||
}
|
435
internal/commands/cluster_register_http_test.go
Normal file
435
internal/commands/cluster_register_http_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
|
||||
// Fake Portal register endpoint
|
||||
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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": false,
|
||||
"alreadyProvisioned": false,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-02", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
// Parse JSON
|
||||
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "pwd", gjson.Get(out, "db.password").String())
|
||||
dsn := gjson.Get(out, "db.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
assert.Equal(t, "user", parsed.User)
|
||||
assert.Equal(t, "pwd", parsed.Password)
|
||||
assert.Equal(t, "tcp", parsed.Net)
|
||||
assert.Equal(t, "db:3306", parsed.Server)
|
||||
assert.Equal(t, "pp_db", parsed.Name)
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
|
||||
// Fake Portal register endpoint for rotation
|
||||
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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_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",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, "pp-node-03")
|
||||
assert.Contains(t, out, "Node Secret")
|
||||
assert.Contains(t, out, "DB Password")
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
|
||||
// Fake Portal register endpoint for rotation in JSON mode
|
||||
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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_CLI")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--db", "--secret", "--yes", "pp-node-04",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret3", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "pwd3", gjson.Get(out, "db.password").String())
|
||||
dsn := gjson.Get(out, "db.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
assert.Equal(t, "user", parsed.User)
|
||||
assert.Equal(t, "pwd3", parsed.Password)
|
||||
assert.Equal(t, "tcp", parsed.Net)
|
||||
assert.Equal(t, "db:3306", parsed.Server)
|
||||
assert.Equal(t, "pp_db", parsed.Name)
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_DBOnly_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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Read payload to assert rotate flags
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
rotate := gjson.GetBytes(b, "rotate").Bool()
|
||||
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
|
||||
// Expect DB rotation only
|
||||
if !rotate || rotateSecret {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
// secrets omitted on DB-only rotate
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
|
||||
_ = os.Setenv("PHOTOPRISM_YES", "true")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
defer os.Unsetenv("PHOTOPRISM_YES")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--db", "--yes", "pp-node-05",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-05", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "pwd4", gjson.Get(out, "db.password").String())
|
||||
dsn := gjson.Get(out, "db.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
assert.Equal(t, "pp_user", parsed.User)
|
||||
assert.Equal(t, "pwd4", parsed.Password)
|
||||
assert.Equal(t, "tcp", parsed.Net)
|
||||
assert.Equal(t, "db:3306", parsed.Server)
|
||||
assert.Equal(t, "pp_db", parsed.Name)
|
||||
assert.Equal(t, "", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_SecretOnly_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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
rotate := gjson.GetBytes(b, "rotate").Bool()
|
||||
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
|
||||
// Expect secret-only rotation
|
||||
if rotate || !rotateSecret {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"secrets": map[string]any{"nodeSecret": "secret4", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--secret", "--yes", "pp-node-06",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "secret4", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "db.password").String())
|
||||
}
|
||||
|
||||
func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-unauth", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "wrong", "--json",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterRegister_HTTPConflict(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp-node-conflict", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterRegister_HTTPBadRequest(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
|
||||
"register", "--name", "pp node invalid", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
|
||||
calls := 0
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
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",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPUnauthorized_JSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=wrong", "--db", "--yes", "pp-node-x",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-x",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp node invalid",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
|
||||
calls := 0
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
|
||||
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
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",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "pwd7", gjson.Get(out, "db.password").String())
|
||||
dsn := gjson.Get(out, "db.dsn").String()
|
||||
parsed := cfg.NewDSN(dsn)
|
||||
assert.Equal(t, "pp_user", parsed.User)
|
||||
assert.Equal(t, "pwd7", parsed.Password)
|
||||
assert.Equal(t, "tcp", parsed.Net)
|
||||
assert.Equal(t, "db:3306", parsed.Server)
|
||||
assert.Equal(t, "pp_db", parsed.Name)
|
||||
}
|
||||
|
||||
func TestClusterRegister_RotateSecret_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)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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"},
|
||||
"secrets": map[string]any{"nodeSecret": "pwd8secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
|
||||
"alreadyRegistered": true,
|
||||
"alreadyProvisioned": true,
|
||||
})
|
||||
}))
|
||||
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",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
|
||||
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.nodeSecret").String())
|
||||
assert.Equal(t, "", gjson.Get(out, "db.password").String())
|
||||
}
|
56
internal/commands/cluster_summary.go
Normal file
56
internal/commands/cluster_summary.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// ClusterSummaryCommand prints a minimal cluster summary (Portal-only).
|
||||
var ClusterSummaryCommand = &cli.Command{
|
||||
Name: "summary",
|
||||
Usage: "Shows cluster summary (Portal-only)",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Action: clusterSummaryAction,
|
||||
}
|
||||
|
||||
func clusterSummaryAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
if !conf.IsPortal() {
|
||||
return fmt.Errorf("cluster summary is only available on a Portal node")
|
||||
}
|
||||
|
||||
r, err := reg.NewFileRegistry(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
b, _ := json.Marshal(resp)
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
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}}
|
||||
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
return err
|
||||
})
|
||||
}
|
132
internal/commands/cluster_test.go
Normal file
132
internal/commands/cluster_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestClusterSummaryCommand(t *testing.T) {
|
||||
t.Run("NotPortal", func(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterSummaryCommand, []string{"summary"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterNodesListCommand(t *testing.T) {
|
||||
t.Run("NotPortal", func(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterNodesListCommand, []string{"ls"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterNodesShowCommand(t *testing.T) {
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
_ = os.Setenv("PHOTOPRISM_NODE_TYPE", "portal")
|
||||
defer os.Unsetenv("PHOTOPRISM_NODE_TYPE")
|
||||
out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterThemePullCommand(t *testing.T) {
|
||||
t.Run("NotPortal", func(t *testing.T) {
|
||||
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
}
|
||||
|
||||
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"})
|
||||
assert.Error(t, err)
|
||||
_ = out
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
// Enable portal mode for local admin commands.
|
||||
c := get.Config()
|
||||
c.Options().NodeType = "portal"
|
||||
|
||||
// Ensure registry and theme paths exist.
|
||||
portCfg := c.PortalConfigPath()
|
||||
nodesDir := filepath.Join(portCfg, "nodes")
|
||||
themeDir := filepath.Join(portCfg, "theme")
|
||||
assert.NoError(t, fs.MkdirAll(nodesDir))
|
||||
assert.NoError(t, fs.MkdirAll(themeDir))
|
||||
|
||||
// Create a theme file to zip.
|
||||
themeFile := filepath.Join(themeDir, "test.txt")
|
||||
assert.NoError(t, os.WriteFile(themeFile, []byte("ok"), 0o600))
|
||||
|
||||
// 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"}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
// nodes ls (JSON)
|
||||
out, err := RunWithTestContext(ClusterNodesListCommand, []string{"ls", "--json"})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, "pp-node-01")
|
||||
|
||||
// nodes show by name
|
||||
out, err = RunWithTestContext(ClusterNodesShowCommand, []string{"show", "pp-node-01"})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, "pp-node-01")
|
||||
|
||||
// nodes mod: add another label (non-interactive)
|
||||
out, err = RunWithTestContext(ClusterNodesModCommand, []string{"mod", "pp-node-01", "--label", "region=us-east-1", "-y"})
|
||||
assert.NoError(t, err)
|
||||
_ = out
|
||||
|
||||
// theme pull via HTTP: fake portal endpoint returns a zip with test.txt
|
||||
// Prepare temp destination
|
||||
destDir := t.TempDir()
|
||||
|
||||
// Create a fake portal theme zip server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/cluster/theme" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
// Build a small zip in-memory
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, _ := zw.Create("test.txt")
|
||||
_, _ = f.Write([]byte("ok"))
|
||||
_ = zw.Close()
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
|
||||
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
|
||||
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
|
||||
|
||||
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--portal-token=test-token"})
|
||||
assert.NoError(t, err)
|
||||
// Expect extracted file
|
||||
assert.FileExists(t, filepath.Join(destDir, "test.txt"))
|
||||
}
|
230
internal/commands/cluster_theme_pull.go
Normal file
230
internal/commands/cluster_theme_pull.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// ClusterThemePullCommand downloads the Portal theme and installs it.
|
||||
var ClusterThemePullCommand = &cli.Command{
|
||||
Name: "theme",
|
||||
Usage: "Theme subcommands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "pull",
|
||||
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path",
|
||||
Flags: []cli.Flag{
|
||||
&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)"},
|
||||
JsonFlag,
|
||||
},
|
||||
Action: clusterThemePullAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func clusterThemePullAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
portalURL := strings.TrimRight(ctx.String("portal-url"), "/")
|
||||
if portalURL == "" {
|
||||
portalURL = strings.TrimRight(conf.PortalUrl(), "/")
|
||||
}
|
||||
if portalURL == "" {
|
||||
portalURL = strings.TrimRight(os.Getenv(config.EnvVar("portal-url")), "/")
|
||||
}
|
||||
if portalURL == "" {
|
||||
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
|
||||
}
|
||||
token := ctx.String("portal-token")
|
||||
if token == "" {
|
||||
token = conf.PortalToken()
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv(config.EnvVar("portal-token"))
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("portal-token not configured; set --portal-token or PHOTOPRISM_PORTAL_TOKEN")
|
||||
}
|
||||
|
||||
dest := ctx.Path("dest")
|
||||
if dest == "" {
|
||||
dest = conf.ThemePath()
|
||||
}
|
||||
dest = fs.Abs(dest)
|
||||
|
||||
// Destination must be a directory. Create if needed.
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
return fmt.Errorf("destination is a file, expected a directory: %s", clean.Log(dest))
|
||||
} else if err != nil {
|
||||
if err := fs.MkdirAll(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If destination contains files and --force not set, refuse.
|
||||
if !ctx.Bool("force") {
|
||||
if nonEmpty, _ := dirNonEmpty(dest); nonEmpty {
|
||||
return fmt.Errorf("destination is not empty; use --force to replace existing files: %s", clean.Log(dest))
|
||||
}
|
||||
} else {
|
||||
// Clean destination contents, but keep the directory itself.
|
||||
if err := removeDirContents(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Download zip to a temp file.
|
||||
zipURL := portalURL + "/api/v1/cluster/theme"
|
||||
tmpFile, err := os.CreateTemp("", "photoprism-theme-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, zipURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Map common codes to clearer messages
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("unauthorized; check portal token and permissions (%s)", resp.Status)
|
||||
case http.StatusTooManyRequests:
|
||||
return fmt.Errorf("rate limited by portal (%s)", resp.Status)
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("portal theme not found (%s)", resp.Status)
|
||||
default:
|
||||
return fmt.Errorf("download failed: %s", resp.Status)
|
||||
}
|
||||
}
|
||||
if _, err = io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract safely into destination.
|
||||
if err := unzipSafe(tmpFile.Name(), dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
fmt.Printf("{\"installed\":\"%s\"}\n", clean.Log(dest))
|
||||
} else {
|
||||
log.Infof("installed theme files to %s", clean.Log(dest))
|
||||
fmt.Println(dest)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func dirNonEmpty(dir string) (bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for range entries {
|
||||
// Ignore typical dotfiles? Keep it simple: any entry counts
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func removeDirContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
p := filepath.Join(dir, e.Name())
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipSafe(zipPath, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if len(r.File) == 0 {
|
||||
return errors.New("theme archive is empty")
|
||||
}
|
||||
for _, f := range r.File {
|
||||
// Directories are indicated by trailing '/'; ensure canonical path
|
||||
name := filepath.Clean(f.Name)
|
||||
if name == "." || name == ".." || strings.HasPrefix(name, "../") || strings.Contains(name, ":") {
|
||||
continue
|
||||
}
|
||||
// Disallow absolute and Windows drive paths
|
||||
if filepath.IsAbs(name) {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(dest, name)
|
||||
// Ensure path stays within dest
|
||||
if !strings.HasPrefix(target+string(os.PathSeparator), dest+string(os.PathSeparator)) && target != dest {
|
||||
continue
|
||||
}
|
||||
// Skip entries that look like hidden files or directories
|
||||
base := filepath.Base(name)
|
||||
if fs.FileNameHidden(base) {
|
||||
continue
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := fs.MkdirAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Ensure parent exists
|
||||
if err := fs.MkdirAll(filepath.Dir(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Open for read
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Create/truncate target
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
@@ -27,6 +27,7 @@ package commands
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/sevlyar/go-daemon"
|
||||
@@ -37,7 +38,15 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
const NONINTERACTIVE = "noninteractive"
|
||||
|
||||
var log = event.Log
|
||||
var cliMode = strings.ToLower(os.Getenv(config.EnvVar("cli")))
|
||||
|
||||
// RunNonInteractively checks if command should run non-interactively.
|
||||
func RunNonInteractively(confirmed bool) bool {
|
||||
return confirmed || cliMode == NONINTERACTIVE
|
||||
}
|
||||
|
||||
// PhotoPrism contains the photoprism CLI (sub-)commands.
|
||||
var PhotoPrism = []*cli.Command{
|
||||
@@ -66,6 +75,7 @@ var PhotoPrism = []*cli.Command{
|
||||
PasswdCommand,
|
||||
UsersCommands,
|
||||
ClientsCommands,
|
||||
ClusterCommands,
|
||||
AuthCommands,
|
||||
ShowCommands,
|
||||
VersionCommand,
|
||||
|
@@ -63,7 +63,7 @@ func placesUpdateAction(ctx *cli.Context) error {
|
||||
conf.InitDb()
|
||||
defer conf.Shutdown()
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
if !RunNonInteractively(ctx.Bool("yes")) {
|
||||
confirmPrompt := promptui.Prompt{
|
||||
Label: "Interrupting the update may lead to inconsistent location information. Continue?",
|
||||
IsConfirm: true,
|
||||
|
@@ -54,7 +54,7 @@ func resetAction(ctx *cli.Context) error {
|
||||
|
||||
defer conf.Shutdown()
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
if !RunNonInteractively(ctx.Bool("yes")) {
|
||||
log.Warnf("This will delete and recreate your index database after confirmation")
|
||||
|
||||
if !ctx.Bool("index") {
|
||||
@@ -67,7 +67,7 @@ func resetAction(ctx *cli.Context) error {
|
||||
log.Infoln("reset: enabled trace mode")
|
||||
}
|
||||
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
@@ -94,48 +94,55 @@ func resetAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Clear cache.
|
||||
removeCachePrompt := promptui.Prompt{
|
||||
Label: "Clear cache incl thumbnails?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if RunNonInteractively(false) {
|
||||
log.Infof("keeping cache files")
|
||||
} else {
|
||||
removeCachePrompt := promptui.Prompt{Label: "Clear cache incl thumbnails?", IsConfirm: true}
|
||||
if _, err = removeCachePrompt.Run(); err == nil {
|
||||
resetCache(conf)
|
||||
} else {
|
||||
log.Infof("keeping cache files")
|
||||
}
|
||||
|
||||
// *.json sidecar files.
|
||||
removeSidecarJsonPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.json sidecar files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
// *.json sidecar files.
|
||||
if RunNonInteractively(false) {
|
||||
log.Infof("keeping *.json sidecar files")
|
||||
} else {
|
||||
removeSidecarJsonPrompt := promptui.Prompt{Label: "Delete all *.json sidecar files?", IsConfirm: true}
|
||||
if _, err = removeSidecarJsonPrompt.Run(); err == nil {
|
||||
resetSidecarJson(conf)
|
||||
} else {
|
||||
log.Infof("keeping *.json sidecar files")
|
||||
}
|
||||
|
||||
// *.yml metadata files.
|
||||
removeSidecarYamlPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.yml metadata files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
// *.yml metadata files.
|
||||
if RunNonInteractively(false) {
|
||||
log.Infof("keeping *.yml metadata files")
|
||||
} else {
|
||||
removeSidecarYamlPrompt := promptui.Prompt{Label: "Delete all *.yml metadata files?", IsConfirm: true}
|
||||
if _, err = removeSidecarYamlPrompt.Run(); err == nil {
|
||||
resetSidecarYaml(conf)
|
||||
} else {
|
||||
log.Infof("keeping *.yml metadata files")
|
||||
}
|
||||
|
||||
// *.yml album files.
|
||||
removeAlbumYamlPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.yml album files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if _, err = removeAlbumYamlPrompt.Run(); err == nil {
|
||||
// *.yml album files.
|
||||
if !RunNonInteractively(false) {
|
||||
removeAlbumYamlPrompt := promptui.Prompt{Label: "Delete all *.yml album files?", IsConfirm: true}
|
||||
if _, err = removeAlbumYamlPrompt.Run(); err != nil {
|
||||
log.Infof("keeping *.yml album files")
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
log.Infof("keeping *.yml album files")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If confirmed, proceed to delete album YAML files
|
||||
{
|
||||
start := time.Now()
|
||||
|
||||
matches, globErr := filepath.Glob(regexp.QuoteMeta(conf.BackupAlbumsPath()) + "/**/*.yml")
|
||||
@@ -161,8 +168,6 @@ func resetAction(ctx *cli.Context) error {
|
||||
} else {
|
||||
log.Infof("found no *.yml album files")
|
||||
}
|
||||
} else {
|
||||
log.Infof("keeping *.yml album files")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@@ -54,7 +54,7 @@ func usersRemoveAction(ctx *cli.Context) error {
|
||||
return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
if !ctx.Bool("force") {
|
||||
if !ctx.Bool("force") && !RunNonInteractively(false) {
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Delete user %s?", m.String()),
|
||||
IsConfirm: true,
|
||||
|
@@ -34,7 +34,7 @@ var UsersResetCommand = &cli.Command{
|
||||
// usersResetAction deletes recreates the user management database tables.
|
||||
func usersResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
@@ -86,6 +86,8 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var best *Node
|
||||
var bestTime time.Time
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
|
||||
continue
|
||||
@@ -96,11 +98,19 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
|
||||
}
|
||||
var n Node
|
||||
if yaml.Unmarshal(b, &n) == nil && n.Name == name {
|
||||
return &n, nil
|
||||
// prefer most recently updated
|
||||
if t, _ := time.Parse(time.RFC3339, n.UpdatedAt); best == nil || t.After(bestTime) {
|
||||
cp := n
|
||||
best = &cp
|
||||
bestTime = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// List returns all registered nodes (without filtering), sorted by UpdatedAt descending.
|
||||
func (r *FileRegistry) List() ([]Node, error) {
|
||||
|
62
internal/service/cluster/registry/file_test.go
Normal file
62
internal/service/cluster/registry/file_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
// TestFindByNameDeterministic verifies that FindByName returns the most
|
||||
// recently updated node when multiple registry entries share the same Name.
|
||||
func TestFindByNameDeterministic(t *testing.T) {
|
||||
// Isolate storage/config to avoid interference from other tests.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("PHOTOPRISM_STORAGE_PATH", tmp)
|
||||
|
||||
conf := config.NewTestConfig("cluster-registry-findbyname")
|
||||
|
||||
r, err := NewFileRegistry(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Two nodes with the same name but different UpdatedAt timestamps.
|
||||
old := Node{
|
||||
ID: "id-old",
|
||||
Name: "pp-node-01",
|
||||
Type: "instance",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
newer := Node{
|
||||
ID: "id-new",
|
||||
Name: "pp-node-01",
|
||||
Type: "instance",
|
||||
CreatedAt: "2024-02-01T00:00:00Z",
|
||||
UpdatedAt: "2024-02-01T00:00:00Z",
|
||||
}
|
||||
|
||||
// Write YAML files directly to avoid timing flakiness.
|
||||
b1, err := yaml.Marshal(old)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, os.WriteFile(r.fileName(old.ID), b1, 0o600))
|
||||
|
||||
b2, err := yaml.Marshal(newer)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, os.WriteFile(r.fileName(newer.ID), b2, 0o600))
|
||||
|
||||
// Expect the most recently updated node (id-new).
|
||||
got, err := r.FindByName("pp-node-01")
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, got) {
|
||||
assert.Equal(t, "id-new", got.ID)
|
||||
assert.Equal(t, "pp-node-01", got.Name)
|
||||
}
|
||||
|
||||
// Non-existent name should return os.ErrNotExist.
|
||||
_, err = r.FindByName("does-not-exist")
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
Reference in New Issue
Block a user