diff --git a/changelog/unreleased/issue-5440 b/changelog/unreleased/issue-5440 new file mode 100644 index 000000000..895542898 --- /dev/null +++ b/changelog/unreleased/issue-5440 @@ -0,0 +1,12 @@ +Enhancement: Allow overriding RESTIC_HOST environment variable with --host flag + +When the `RESTIC_HOST` environment variable was set, there was no way to list or +operate on snapshots from all hosts, as the environment variable would always +filter to that specific host. Restic now allows overriding `RESTIC_HOST` by +explicitly providing the `--host` flag with an empty string (e.g., `--host=""` or +`--host=`), which will show snapshots from all hosts. This works for all commands +that support snapshot filtering: `snapshots`, `forget`, `find`, `stats`, `copy`, +`tag`, `repair snapshots`, `rewrite`, `mount`, `restore`, `dump`, and `ls`. + +https://github.com/restic/restic/issues/5440 +https://github.com/restic/restic/pull/5541 diff --git a/cmd/restic/cmd_copy.go b/cmd/restic/cmd_copy.go index d21e5e4f5..210e68e05 100644 --- a/cmd/restic/cmd_copy.go +++ b/cmd/restic/cmd_copy.go @@ -49,6 +49,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runCopy(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_dump.go b/cmd/restic/cmd_dump.go index a3406328f..c538b9153 100644 --- a/cmd/restic/cmd_dump.go +++ b/cmd/restic/cmd_dump.go @@ -48,6 +48,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runDump(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_find.go b/cmd/restic/cmd_find.go index 70043d7ca..f8f124f9a 100644 --- a/cmd/restic/cmd_find.go +++ b/cmd/restic/cmd_find.go @@ -52,6 +52,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runFind(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go index a3d6246c2..bc5fd8d11 100644 --- a/cmd/restic/cmd_forget.go +++ b/cmd/restic/cmd_forget.go @@ -50,6 +50,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runForget(cmd.Context(), opts, pruneOpts, *globalOptions, globalOptions.term, args) }, } diff --git a/cmd/restic/cmd_forget_test.go b/cmd/restic/cmd_forget_test.go index b4175042a..2dc191a4c 100644 --- a/cmd/restic/cmd_forget_test.go +++ b/cmd/restic/cmd_forget_test.go @@ -96,7 +96,38 @@ func TestForgetOptionValues(t *testing.T) { func TestForgetHostnameDefaulting(t *testing.T) { t.Setenv("RESTIC_HOST", "testhost") - opts := ForgetOptions{} - opts.AddFlags(pflag.NewFlagSet("test", pflag.ContinueOnError)) - rtest.Equals(t, []string{"testhost"}, opts.Hosts) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "env default when flag not set", + args: nil, + want: []string{"testhost"}, + }, + { + name: "flag overrides env", + args: []string{"--host", "flaghost"}, + want: []string{"flaghost"}, + }, + { + name: "empty flag clears env", + args: []string{"--host", ""}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + set := pflag.NewFlagSet(tt.name, pflag.ContinueOnError) + opts := ForgetOptions{} + opts.AddFlags(set) + err := set.Parse(tt.args) + rtest.Assert(t, err == nil, "expected no error for input") + finalizeSnapshotFilter(&opts.SnapshotFilter) + rtest.Equals(t, tt.want, opts.Hosts) + }) + } } diff --git a/cmd/restic/cmd_ls.go b/cmd/restic/cmd_ls.go index a65867994..2bdf2bfa3 100644 --- a/cmd/restic/cmd_ls.go +++ b/cmd/restic/cmd_ls.go @@ -61,6 +61,7 @@ Exit status is 12 if the password is incorrect. DisableAutoGenTag: true, GroupID: cmdGroupDefault, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runLs(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_mount.go b/cmd/restic/cmd_mount.go index f94f999f2..9bcf1295c 100644 --- a/cmd/restic/cmd_mount.go +++ b/cmd/restic/cmd_mount.go @@ -82,6 +82,7 @@ Exit status is 12 if the password is incorrect. DisableAutoGenTag: true, GroupID: cmdGroupDefault, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runMount(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_repair_snapshots.go b/cmd/restic/cmd_repair_snapshots.go index d0c9663c6..0a702e596 100644 --- a/cmd/restic/cmd_repair_snapshots.go +++ b/cmd/restic/cmd_repair_snapshots.go @@ -51,6 +51,7 @@ Exit status is 12 if the password is incorrect. `, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runRepairSnapshots(cmd.Context(), *globalOptions, opts, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go index 8641ef75e..407e2ab32 100644 --- a/cmd/restic/cmd_restore.go +++ b/cmd/restic/cmd_restore.go @@ -46,6 +46,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runRestore(cmd.Context(), opts, *globalOptions, globalOptions.term, args) }, } diff --git a/cmd/restic/cmd_rewrite.go b/cmd/restic/cmd_rewrite.go index d8671ddc2..9d502b483 100644 --- a/cmd/restic/cmd_rewrite.go +++ b/cmd/restic/cmd_rewrite.go @@ -61,6 +61,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runRewrite(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go index 67b458d1b..1f9d46801 100644 --- a/cmd/restic/cmd_snapshots.go +++ b/cmd/restic/cmd_snapshots.go @@ -37,6 +37,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runSnapshots(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_stats.go b/cmd/restic/cmd_stats.go index dcddbb6af..db4dc686e 100644 --- a/cmd/restic/cmd_stats.go +++ b/cmd/restic/cmd_stats.go @@ -64,6 +64,7 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) return runStats(cmd.Context(), opts, *globalOptions, args, globalOptions.term) }, } diff --git a/cmd/restic/cmd_tag.go b/cmd/restic/cmd_tag.go index bc1ca4905..6009fb9fd 100644 --- a/cmd/restic/cmd_tag.go +++ b/cmd/restic/cmd_tag.go @@ -40,6 +40,8 @@ Exit status is 12 if the password is incorrect. GroupID: cmdGroupDefault, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + finalizeSnapshotFilter(&opts.SnapshotFilter) + finalizeSnapshotFilter(&opts.SnapshotFilter) return runTag(cmd.Context(), opts, *globalOptions, globalOptions.term, args) }, } diff --git a/cmd/restic/find.go b/cmd/restic/find.go index 9d820b66a..6c844289f 100644 --- a/cmd/restic/find.go +++ b/cmd/restic/find.go @@ -12,31 +12,39 @@ import ( // initMultiSnapshotFilter is used for commands that work on multiple snapshots // MUST be combined with restic.FindFilteredSnapshots or FindFilteredSnapshots +// MUST be followed by finalizeSnapshotFilter after flag parsing func initMultiSnapshotFilter(flags *pflag.FlagSet, filt *data.SnapshotFilter, addHostShorthand bool) { hostShorthand := "H" if !addHostShorthand { hostShorthand = "" } - flags.StringArrayVarP(&filt.Hosts, "host", hostShorthand, nil, "only consider snapshots for this `host` (can be specified multiple times) (default: $RESTIC_HOST)") + flags.StringArrayVarP(&filt.Hosts, "host", hostShorthand, nil, "only consider snapshots for this `host` (can be specified multiple times, use empty string to unset default value) (default: $RESTIC_HOST)") flags.Var(&filt.Tags, "tag", "only consider snapshots including `tag[,tag,...]` (can be specified multiple times)") flags.StringArrayVar(&filt.Paths, "path", nil, "only consider snapshots including this (absolute) `path` (can be specified multiple times, snapshots must include all specified paths)") - - // set default based on env if set - if host := os.Getenv("RESTIC_HOST"); host != "" { - filt.Hosts = []string{host} - } } // initSingleSnapshotFilter is used for commands that work on a single snapshot // MUST be combined with restic.FindFilteredSnapshot +// MUST be followed by finalizeSnapshotFilter after flag parsing func initSingleSnapshotFilter(flags *pflag.FlagSet, filt *data.SnapshotFilter) { - flags.StringArrayVarP(&filt.Hosts, "host", "H", nil, "only consider snapshots for this `host`, when snapshot ID \"latest\" is given (can be specified multiple times) (default: $RESTIC_HOST)") + flags.StringArrayVarP(&filt.Hosts, "host", "H", nil, "only consider snapshots for this `host`, when snapshot ID \"latest\" is given (can be specified multiple times, use empty string to unset default value) (default: $RESTIC_HOST)") flags.Var(&filt.Tags, "tag", "only consider snapshots including `tag[,tag,...]`, when snapshot ID \"latest\" is given (can be specified multiple times)") flags.StringArrayVar(&filt.Paths, "path", nil, "only consider snapshots including this (absolute) `path`, when snapshot ID \"latest\" is given (can be specified multiple times, snapshots must include all specified paths)") +} - // set default based on env if set - if host := os.Getenv("RESTIC_HOST"); host != "" { - filt.Hosts = []string{host} +// finalizeSnapshotFilter applies RESTIC_HOST default only if --host flag wasn't explicitly set. +// This allows users to override RESTIC_HOST by providing --host="" or --host with explicit values. +func finalizeSnapshotFilter(filt *data.SnapshotFilter) { + // Only apply RESTIC_HOST default if the --host flag wasn't changed by the user + if filt.Hosts == nil { + if host := os.Getenv("RESTIC_HOST"); host != "" { + filt.Hosts = []string{host} + } + } + // If flag was set to empty string explicitly (e.g., --host=""), + // filt.Hosts will be []string{""} which should be cleaned up to allow all hosts + if len(filt.Hosts) == 1 && filt.Hosts[0] == "" { + filt.Hosts = nil } } diff --git a/cmd/restic/find_test.go b/cmd/restic/find_test.go index 3fffddcd8..261371c70 100644 --- a/cmd/restic/find_test.go +++ b/cmd/restic/find_test.go @@ -39,6 +39,24 @@ func TestSnapshotFilter(t *testing.T) { []string{"abc"}, "def", }, + { + "env set, empty flag overrides", + []string{"--host", ""}, + nil, // empty host filter means all hosts + "envhost", + }, + { + "env set, multiple flags override", + []string{"--host", "host1", "--host", "host2"}, + []string{"host1", "host2"}, + "envhost", + }, + { + "env set, multiple hosts including empty", + []string{"--host", "host1", "--host", ""}, + []string{"host1", ""}, + "envhost", + }, } { t.Run(test.name, func(t *testing.T) { t.Setenv("RESTIC_HOST", test.env) @@ -54,6 +72,9 @@ func TestSnapshotFilter(t *testing.T) { err := set.Parse(test.args) rtest.OK(t, err) + // Apply the finalization logic to handle env defaults + finalizeSnapshotFilter(flt) + rtest.Equals(t, test.expected, flt.Hosts, "unexpected hosts") } })