package commands import ( "fmt" "strings" "github.com/manifoldco/promptui" "github.com/urfave/cli/v2" "github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" reg "github.com/photoprism/photoprism/internal/service/cluster/registry" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/log/status" ) // flags for nodes mod var ( nodesModRoleFlag = &cli.StringFlag{Name: "role", Aliases: []string{"t"}, Usage: "node `ROLE` (portal, app, service)"} nodesModInternal = &cli.StringFlag{Name: "advertise-url", Aliases: []string{"i"}, Usage: "internal service `URL`"} nodesModLabel = &cli.StringSliceFlag{Name: "label", Aliases: []string{"l"}, Usage: "`k=v` label (repeatable)"} ) // ClusterNodesModCommand updates node fields. var ClusterNodesModCommand = &cli.Command{ Name: "mod", Usage: "Updates node properties", ArgsUsage: "", Flags: []cli.Flag{ DryRunFlag("preview updates without modifying the registry"), nodesModRoleFlag, nodesModInternal, nodesModLabel, YesFlag(), }, Hidden: true, // Required for cluster-management only. Action: clusterNodesModAction, } func clusterNodesModAction(ctx *cli.Context) error { return CallWithDependencies(ctx, func(conf *config.Config) error { if !conf.Portal() { return cli.Exit(fmt.Errorf("node update is only available on a Portal node"), 2) } key := ctx.Args().First() if key == "" { return cli.Exit(fmt.Errorf("node id or name is required"), 2) } r, err := reg.NewClientRegistryWithConfig(conf) if err != nil { return cli.Exit(err, 1) } // Resolve by NodeUUID first, then by client UID, then by normalized name. var n *reg.Node var getErr error if n, getErr = r.FindByNodeUUID(key); getErr != nil || n == nil { n, getErr = r.FindByClientID(key) } if getErr != nil || n == nil { name := clean.DNSLabel(key) if name == "" { return cli.Exit(fmt.Errorf("invalid node identifier"), 2) } n, getErr = r.FindByName(name) } if getErr != nil || n == nil { return cli.Exit(fmt.Errorf("node not found"), 3) } changes := make([]string, 0, 4) if v := ctx.String("role"); v != "" { n.Role = clean.TypeLowerDash(v) changes = append(changes, fmt.Sprintf("role=%s", clean.Log(n.Role))) } if v := ctx.String("advertise-url"); v != "" { n.AdvertiseUrl = v changes = append(changes, fmt.Sprintf("advertise-url=%s", clean.Log(n.AdvertiseUrl))) } 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 } } changes = append(changes, fmt.Sprintf("labels+=%s", clean.Log(strings.Join(labels, ",")))) } if ctx.Bool("dry-run") { if len(changes) == 0 { log.Infof("dry-run: no updates to apply for node %s", clean.LogQuote(n.Name)) } else { log.Infof("dry-run: would update node %s (%s)", clean.LogQuote(n.Name), strings.Join(changes, ", ")) } return nil } 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 cli.Exit(err, 1) } nodeID := n.UUID if nodeID == "" { nodeID = n.Name } changeSummary := strings.Join(changes, ", ") who := clusterAuditWho(ctx, conf) segments := []string{ string(acl.ResourceCluster), "update node", "%s", } args := []interface{}{clean.Log(nodeID)} if changeSummary != "" { segments = append(segments, "%s") args = append(args, clean.Log(changeSummary)) } segments = append(segments, status.Updated) event.AuditInfo(append(who, segments...), args...) 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 }