From dbf1650c1cf83c755a2297c2079d3f17cba7650d Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 16 Sep 2025 18:09:09 +0200 Subject: [PATCH] CLI: Add cluster operations and management commands #98 Signed-off-by: Michael Mayer --- internal/api/cluster_theme.go | 2 +- internal/commands/auth_remove.go | 21 +- internal/commands/auth_reset.go | 2 +- internal/commands/clients_remove.go | 2 +- internal/commands/clients_reset.go | 2 +- internal/commands/cluster.go | 31 ++ internal/commands/cluster_health.go | 47 ++ internal/commands/cluster_nodes_list.go | 113 +++++ internal/commands/cluster_nodes_mod.go | 103 +++++ internal/commands/cluster_nodes_remove.go | 67 +++ internal/commands/cluster_nodes_rotate.go | 139 ++++++ internal/commands/cluster_nodes_show.go | 72 +++ internal/commands/cluster_register.go | 295 ++++++++++++ .../commands/cluster_register_http_test.go | 435 ++++++++++++++++++ internal/commands/cluster_summary.go | 56 +++ internal/commands/cluster_test.go | 132 ++++++ internal/commands/cluster_theme_pull.go | 230 +++++++++ internal/commands/commands.go | 10 + internal/commands/places.go | 2 +- internal/commands/reset.go | 69 +-- internal/commands/users_remove.go | 2 +- internal/commands/users_reset.go | 2 +- internal/service/cluster/registry/file.go | 14 +- .../service/cluster/registry/file_test.go | 62 +++ 24 files changed, 1862 insertions(+), 48 deletions(-) create mode 100644 internal/commands/cluster.go create mode 100644 internal/commands/cluster_health.go create mode 100644 internal/commands/cluster_nodes_list.go create mode 100644 internal/commands/cluster_nodes_mod.go create mode 100644 internal/commands/cluster_nodes_remove.go create mode 100644 internal/commands/cluster_nodes_rotate.go create mode 100644 internal/commands/cluster_nodes_show.go create mode 100644 internal/commands/cluster_register.go create mode 100644 internal/commands/cluster_register_http_test.go create mode 100644 internal/commands/cluster_summary.go create mode 100644 internal/commands/cluster_test.go create mode 100644 internal/commands/cluster_theme_pull.go create mode 100644 internal/service/cluster/registry/file_test.go diff --git a/internal/api/cluster_theme.go b/internal/api/cluster_theme.go index 15fb65729..c0614cea0 100644 --- a/internal/api/cluster_theme.go +++ b/internal/api/cluster_theme.go @@ -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 { diff --git a/internal/commands/auth_remove.go b/internal/commands/auth_remove.go index 493ea61e0..9d2bd819a 100644 --- a/internal/commands/auth_remove.go +++ b/internal/commands/auth_remove.go @@ -30,12 +30,8 @@ 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 _, err := actionPrompt.Run(); err == nil { + 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 { @@ -44,7 +40,18 @@ func authRemoveAction(ctx *cli.Context) error { log.Infof("session %s has been removed", clean.LogQuote(id)) } } else { - log.Infof("session %s was not removed", clean.LogQuote(id)) + 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") + } else if err := m.Delete(); err != nil { + return err + } else { + log.Infof("session %s has been removed", clean.LogQuote(id)) + } + } else { + log.Infof("session %s was not removed", clean.LogQuote(id)) + } } return nil diff --git a/internal/commands/auth_reset.go b/internal/commands/auth_reset.go index 9efdc806a..aa01fed3f 100644 --- a/internal/commands/auth_reset.go +++ b/internal/commands/auth_reset.go @@ -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 { diff --git a/internal/commands/clients_remove.go b/internal/commands/clients_remove.go index ea80518ca..e40e7ea2f 100644 --- a/internal/commands/clients_remove.go +++ b/internal/commands/clients_remove.go @@ -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, diff --git a/internal/commands/clients_reset.go b/internal/commands/clients_reset.go index 4d745e1d0..e1add37fb 100644 --- a/internal/commands/clients_reset.go +++ b/internal/commands/clients_reset.go @@ -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 { diff --git a/internal/commands/cluster.go b/internal/commands/cluster.go new file mode 100644 index 000000000..a1b8959b3 --- /dev/null +++ b/internal/commands/cluster.go @@ -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, + }, +} diff --git a/internal/commands/cluster_health.go b/internal/commands/cluster_health.go new file mode 100644 index 000000000..c60caf270 --- /dev/null +++ b/internal/commands/cluster_health.go @@ -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 + }) +} diff --git a/internal/commands/cluster_nodes_list.go b/internal/commands/cluster_nodes_list.go new file mode 100644 index 000000000..25995940d --- /dev/null +++ b/internal/commands/cluster_nodes_list.go @@ -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, ", ") +} diff --git a/internal/commands/cluster_nodes_mod.go b/internal/commands/cluster_nodes_mod.go new file mode 100644 index 000000000..1cf1efdd5 --- /dev/null +++ b/internal/commands/cluster_nodes_mod.go @@ -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: "", + 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 +} diff --git a/internal/commands/cluster_nodes_remove.go b/internal/commands/cluster_nodes_remove.go new file mode 100644 index 000000000..c9b213744 --- /dev/null +++ b/internal/commands/cluster_nodes_remove.go @@ -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: "", + 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 + }) +} diff --git a/internal/commands/cluster_nodes_rotate.go b/internal/commands/cluster_nodes_rotate.go new file mode 100644 index 000000000..863506c06 --- /dev/null +++ b/internal/commands/cluster_nodes_rotate.go @@ -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: "", + 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 + }) +} diff --git a/internal/commands/cluster_nodes_show.go b/internal/commands/cluster_nodes_show.go new file mode 100644 index 000000000..fdbd50f7b --- /dev/null +++ b/internal/commands/cluster_nodes_show.go @@ -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: "", + 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 + }) +} diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go new file mode 100644 index 000000000..bb354e842 --- /dev/null +++ b/internal/commands/cluster_register.go @@ -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) +} diff --git a/internal/commands/cluster_register_http_test.go b/internal/commands/cluster_register_http_test.go new file mode 100644 index 000000000..0a9c3a1f4 --- /dev/null +++ b/internal/commands/cluster_register_http_test.go @@ -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()) +} diff --git a/internal/commands/cluster_summary.go b/internal/commands/cluster_summary.go new file mode 100644 index 000000000..18dfe9372 --- /dev/null +++ b/internal/commands/cluster_summary.go @@ -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 + }) +} diff --git a/internal/commands/cluster_test.go b/internal/commands/cluster_test.go new file mode 100644 index 000000000..f93db7b13 --- /dev/null +++ b/internal/commands/cluster_test.go @@ -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")) +} diff --git a/internal/commands/cluster_theme_pull.go b/internal/commands/cluster_theme_pull.go new file mode 100644 index 000000000..1cdcae245 --- /dev/null +++ b/internal/commands/cluster_theme_pull.go @@ -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 +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 4ba74c585..87d16538b 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -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, diff --git a/internal/commands/places.go b/internal/commands/places.go index d7e0a92a5..bbfd8fce1 100644 --- a/internal/commands/places.go +++ b/internal/commands/places.go @@ -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, diff --git a/internal/commands/reset.go b/internal/commands/reset.go index 5be7f44de..0683f0ce6 100644 --- a/internal/commands/reset.go +++ b/internal/commands/reset.go @@ -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 _, err = removeCachePrompt.Run(); err == nil { - resetCache(conf) - } else { + 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, - } - - if _, err = removeSidecarJsonPrompt.Run(); err == nil { - resetSidecarJson(conf) - } else { + 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, - } - - if _, err = removeSidecarYamlPrompt.Run(); err == nil { - resetSidecarYaml(conf) - } else { + 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 !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 _, err = removeAlbumYamlPrompt.Run(); err == 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 diff --git a/internal/commands/users_remove.go b/internal/commands/users_remove.go index ff62e1998..75c6e979a 100644 --- a/internal/commands/users_remove.go +++ b/internal/commands/users_remove.go @@ -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, diff --git a/internal/commands/users_reset.go b/internal/commands/users_reset.go index 67b3c56b7..6f91ef671 100644 --- a/internal/commands/users_reset.go +++ b/internal/commands/users_reset.go @@ -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 { diff --git a/internal/service/cluster/registry/file.go b/internal/service/cluster/registry/file.go index 5c3cceb1e..e783fe81e 100644 --- a/internal/service/cluster/registry/file.go +++ b/internal/service/cluster/registry/file.go @@ -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,10 +98,18 @@ 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 + } } } - return nil, os.ErrNotExist + if best == nil { + return nil, os.ErrNotExist + } + return best, nil } // List returns all registered nodes (without filtering), sorted by UpdatedAt descending. diff --git a/internal/service/cluster/registry/file_test.go b/internal/service/cluster/registry/file_test.go new file mode 100644 index 000000000..58d175afd --- /dev/null +++ b/internal/service/cluster/registry/file_test.go @@ -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) +}