CLI: Refactor "dry-run" and "yes" command flags to use helper functions

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-09 15:41:35 +02:00
parent f125bfd638
commit 9d968ab357
8 changed files with 124 additions and 36 deletions

View File

@@ -20,10 +20,7 @@ var CleanUpCommand = &cli.Command{
} }
var cleanUpFlags = []cli.Flag{ var cleanUpFlags = []cli.Flag{
&cli.BoolFlag{ DryRunFlag("performs a dry run that doesn't actually remove anything"),
Name: "dry",
Usage: "performs a dry run that doesn't actually remove anything",
},
} }
// cleanUpAction removes orphaned index entries, sidecar and thumbnail files. // cleanUpAction removes orphaned index entries, sidecar and thumbnail files.
@@ -49,7 +46,7 @@ func cleanUpAction(ctx *cli.Context) error {
w := get.CleanUp() w := get.CleanUp()
opt := photoprism.CleanUpOptions{ opt := photoprism.CleanUpOptions{
Dry: ctx.Bool("dry"), Dry: ctx.Bool("dry-run"),
} }
// Start cleanup worker. // Start cleanup worker.

View File

@@ -24,7 +24,10 @@ var ClusterNodesModCommand = &cli.Command{
Name: "mod", Name: "mod",
Usage: "Updates node properties", Usage: "Updates node properties",
ArgsUsage: "<id|name>", ArgsUsage: "<id|name>",
Flags: []cli.Flag{nodesModRoleFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}}, Flags: []cli.Flag{nodesModRoleFlag, nodesModInternal, nodesModLabel,
DryRunFlag("preview updates without modifying the registry"),
YesFlag(),
},
Hidden: true, // Required for cluster-management only. Hidden: true, // Required for cluster-management only.
Action: clusterNodesModAction, Action: clusterNodesModAction,
} }
@@ -62,11 +65,15 @@ func clusterNodesModAction(ctx *cli.Context) error {
return cli.Exit(fmt.Errorf("node not found"), 3) return cli.Exit(fmt.Errorf("node not found"), 3)
} }
changes := make([]string, 0, 4)
if v := ctx.String("role"); v != "" { if v := ctx.String("role"); v != "" {
n.Role = clean.TypeLowerDash(v) n.Role = clean.TypeLowerDash(v)
changes = append(changes, fmt.Sprintf("role=%s", clean.Log(n.Role)))
} }
if v := ctx.String("advertise-url"); v != "" { if v := ctx.String("advertise-url"); v != "" {
n.AdvertiseUrl = 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 labels := ctx.StringSlice("label"); len(labels) > 0 {
if n.Labels == nil { if n.Labels == nil {
@@ -77,6 +84,16 @@ func clusterNodesModAction(ctx *cli.Context) error {
n.Labels[k] = v 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")) confirmed := RunNonInteractively(ctx.Bool("yes"))

View File

@@ -20,9 +20,10 @@ var ClusterNodesRemoveCommand = &cli.Command{
Usage: "Deletes a node from the registry", Usage: "Deletes a node from the registry",
ArgsUsage: "<id|name>", ArgsUsage: "<id|name>",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, YesFlag(),
&cli.BoolFlag{Name: "all-ids", Usage: "delete all records that share the same UUID (admin cleanup)"}, &cli.BoolFlag{Name: "all-ids", Usage: "delete all records that share the same UUID (admin cleanup)"},
&cli.BoolFlag{Name: "drop-db", Aliases: []string{"d"}, Usage: "drop the nodes provisioned database and user after registry deletion"}, &cli.BoolFlag{Name: "drop-db", Aliases: []string{"d"}, Usage: "drop the nodes provisioned database and user after registry deletion"},
DryRunFlag("preview deletion without modifying the registry or database"),
}, },
Hidden: true, // Required for cluster-management only. Hidden: true, // Required for cluster-management only.
Action: clusterNodesRemoveAction, Action: clusterNodesRemoveAction,
@@ -66,33 +67,54 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
uuid := node.UUID uuid := node.UUID
confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed {
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(uuid)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("node %s was not deleted", clean.Log(uuid))
return nil
}
}
dropDB := ctx.Bool("drop-db") dropDB := ctx.Bool("drop-db")
dbName, dbUser := "", "" dbName, dbUser := "", ""
if dropDB && node.Database != nil { if dropDB && node.Database != nil {
dbName = node.Database.Name dbName = node.Database.Name
dbUser = node.Database.User dbUser = node.Database.User
} }
if ctx.Bool("dry-run") {
log.Infof("dry-run: would delete node %s (uuid=%s, clientId=%s)", clean.LogQuote(node.Name), clean.Log(uuid), clean.Log(node.ClientID))
if ctx.Bool("all-ids") { if ctx.Bool("all-ids") {
if err := r.DeleteAllByUUID(uuid); err != nil { log.Infof("dry-run: would remove all registry entries that share uuid %s", clean.Log(uuid))
return cli.Exit(err, 1)
}
} else if err := r.Delete(uuid); err != nil {
return cli.Exit(err, 1)
} }
if dropDB {
if dbName == "" && dbUser == "" {
log.Infof("dry-run: --drop-db requested but no database credentials are recorded for node %s", clean.LogQuote(node.Name))
} else {
log.Infof("dry-run: would drop database %s and user %s", clean.Log(dbName), clean.Log(dbUser))
}
}
return nil
}
if !RunNonInteractively(ctx.Bool("yes")) {
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(uuid)), IsConfirm: true}
if _, err = prompt.Run(); err != nil {
log.Infof("node %s was not deleted", clean.Log(uuid))
return nil
}
}
if ctx.Bool("all-ids") {
if err = r.DeleteAllByUUID(uuid); err != nil {
return cli.Exit(err, 1)
}
} else if err = r.Delete(uuid); err != nil {
return cli.Exit(err, 1)
}
loggedDeletion := false
if dropDB { if dropDB {
if dbName == "" && dbUser == "" { if dbName == "" && dbUser == "" {
log.Infof("node %s has been deleted (no database credentials recorded)", clean.Log(uuid)) log.Infof("node %s has been deleted (no database credentials recorded)", clean.Log(uuid))
loggedDeletion = true
} else { } else {
dropCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) dropCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -103,7 +125,9 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
} }
} }
if !loggedDeletion {
log.Infof("node %s has been deleted", clean.Log(uuid)) log.Infof("node %s has been deleted", clean.Log(uuid))
}
return nil return nil
}) })

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry" reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/txt/report" "github.com/photoprism/photoprism/pkg/txt/report"
) )
@@ -28,7 +29,10 @@ var ClusterNodesRotateCommand = &cli.Command{
Name: "rotate", Name: "rotate",
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)", Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
ArgsUsage: "<id|name>", ArgsUsage: "<id|name>",
Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok}, report.CliFlags...), Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag,
DryRunFlag("preview rotation without contacting the Portal"),
YesFlag(),
rotatePortalURL, rotatePortalTok}, report.CliFlags...),
Action: clusterNodesRotateAction, Action: clusterNodesRotateAction,
} }
@@ -64,9 +68,6 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
if portalURL == "" { if portalURL == "" {
portalURL = os.Getenv(config.EnvVar("portal-url")) portalURL = os.Getenv(config.EnvVar("portal-url"))
} }
if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
}
token := ctx.String("join-token") token := ctx.String("join-token")
if token == "" { if token == "" {
token = conf.JoinToken() token = conf.JoinToken()
@@ -74,14 +75,41 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
if token == "" { if token == "" {
token = os.Getenv(config.EnvVar("join-token")) token = os.Getenv(config.EnvVar("join-token"))
} }
if token == "" {
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
}
// Default: rotate DB only if no flag given (safer default) // Default: rotate DB only if no flag given (safer default)
rotateDatabase := ctx.Bool("database") || (!ctx.IsSet("database") && !ctx.IsSet("secret")) rotateDatabase := ctx.Bool("database") || (!ctx.IsSet("database") && !ctx.IsSet("secret"))
rotateSecret := ctx.Bool("secret") rotateSecret := ctx.Bool("secret")
if ctx.Bool("dry-run") {
target := clean.LogQuote(name)
if target == "" {
target = "(unnamed node)"
}
var what []string
if rotateDatabase {
what = append(what, "database credentials")
}
if rotateSecret {
what = append(what, "node secret")
}
if len(what) == 0 {
what = append(what, "no resources (no rotation flags set)")
}
if portalURL == "" {
log.Infof("dry-run: would rotate %s for %s (portal URL not set)", txt.JoinAnd(what), target)
} else {
log.Infof("dry-run: would rotate %s for %s via %s", txt.JoinAnd(what), target, clean.Log(portalURL))
}
return nil
}
if portalURL == "" {
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
}
if token == "" {
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
}
confirmed := RunNonInteractively(ctx.Bool("yes")) confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed { if !confirmed {
var what string var what string

View File

@@ -37,7 +37,7 @@ var (
regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"} regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"} 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)"} regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
regDryRun = &cli.BoolFlag{Name: "dry-run", Usage: "print derived values and payload without performing registration"} regDryRun = DryRunFlag("print derived values and payload without performing registration")
) )
// ClusterRegisterCommand registers a node with the Portal via HTTP. // ClusterRegisterCommand registers a node with the Portal via HTTP.

View File

@@ -14,6 +14,19 @@ func JsonFlag() *cli.BoolFlag {
return &cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"} return &cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"}
} }
// DryRunFlag returns the shared CLI flag definition for dry runs across commands.
func DryRunFlag(usage string) *cli.BoolFlag {
if usage == "" {
usage = "performs a dry run without making any destructive changes"
}
return &cli.BoolFlag{Name: "dry-run", Aliases: []string{"dry"}, Usage: usage}
}
// YesFlag returns the shared CLI flag definition for non-interactive runs across commands.
func YesFlag() *cli.BoolFlag {
return &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}
}
// PicturesCountFlag returns a shared flag definition limiting how many pictures a batch operation processes. // PicturesCountFlag returns a shared flag definition limiting how many pictures a batch operation processes.
// Usage: commands from the vision or import tooling that need to cap result size per invocation. // Usage: commands from the vision or import tooling that need to cap result size per invocation.
func PicturesCountFlag() *cli.IntFlag { func PicturesCountFlag() *cli.IntFlag {

View File

@@ -28,10 +28,7 @@ var purgeFlags = []cli.Flag{
Name: "hard", Name: "hard",
Usage: "permanently removes data from the index", Usage: "permanently removes data from the index",
}, },
&cli.BoolFlag{ DryRunFlag("performs a dry run that doesn't actually remove anything"),
Name: "dry",
Usage: "performs a dry run that doesn't actually remove anything",
},
} }
// purgeAction removes missing files from search results // purgeAction removes missing files from search results
@@ -67,7 +64,7 @@ func purgeAction(ctx *cli.Context) error {
opt := photoprism.PurgeOptions{ opt := photoprism.PurgeOptions{
Path: subPath, Path: subPath,
Dry: ctx.Bool("dry"), Dry: ctx.Bool("dry-run"),
Hard: ctx.Bool("hard"), Hard: ctx.Bool("hard"),
Force: true, Force: true,
} }

View File

@@ -30,6 +30,7 @@ var VisionRunCommand = &cli.Command{
Aliases: []string{"f"}, Aliases: []string{"f"},
Usage: "replaces existing data if the model supports it and the source priority is equal or higher", Usage: "replaces existing data if the model supports it and the source priority is equal or higher",
}, },
DryRunFlag("preview the run without executing any models"),
}, },
Action: visionRunAction, Action: visionRunAction,
} }
@@ -45,10 +46,21 @@ func visionRunAction(ctx *cli.Context) error {
return cli.Exit(err.Error(), 1) return cli.Exit(err.Error(), 1)
} }
models := vision.ParseModelTypes(ctx.String("models"))
if ctx.Bool("dry-run") {
modelList := strings.Join(models, ",")
if modelList == "" {
modelList = "(none)"
}
log.Infof("dry-run: vision run would execute models [%s] with filter=%q (count=%d, source=%s, force=%v)", modelList, filter, ctx.Int("count"), string(source), ctx.Bool("force"))
return nil
}
return worker.Start( return worker.Start(
filter, filter,
ctx.Int("count"), ctx.Int("count"),
vision.ParseModelTypes(ctx.String("models")), models,
string(source), string(source),
ctx.Bool("force"), ctx.Bool("force"),
vision.RunManual, vision.RunManual,