diff --git a/src/cmds/restic/cmd_tag.go b/src/cmds/restic/cmd_tag.go new file mode 100644 index 000000000..9235585f0 --- /dev/null +++ b/src/cmds/restic/cmd_tag.go @@ -0,0 +1,189 @@ +package main + +import ( + "github.com/spf13/cobra" + + "restic" + "restic/debug" + "restic/errors" + "restic/repository" +) + +var cmdTag = &cobra.Command{ + Use: "tag [flags] [snapshot-ID ...]", + Short: "modifies tags on snapshots", + Long: ` +The "tag" command allows you to modify tags on exiting snapshots. + +You can either set/replace the entire set of tags on a snapshot, or +add tags to/remove tags from the existing set. + +When no snapshot-ID is given, all snapshots matching the host, tag and path filter criteria are modified. +`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTag(tagOptions, globalOptions, args) + }, +} + +// TagOptions bundles all options for the 'tag' command. +type TagOptions struct { + Host string + Paths []string + Tags []string + SetTags []string + AddTags []string + RemoveTags []string +} + +var tagOptions TagOptions + +func init() { + cmdRoot.AddCommand(cmdTag) + + tagFlags := cmdTag.Flags() + tagFlags.StringSliceVar(&tagOptions.SetTags, "set", nil, "`tag` which will replace the existing tags (can be given multiple times)") + tagFlags.StringSliceVar(&tagOptions.AddTags, "add", nil, "`tag` which will be added to the existing tags (can be given multiple times)") + tagFlags.StringSliceVar(&tagOptions.RemoveTags, "remove", nil, "`tag` which will be removed from the existing tags (can be given multiple times)") + + tagFlags.StringVarP(&tagOptions.Host, "host", "H", "", `only consider snapshots for this host, when no snapshot ID is given`) + tagFlags.StringSliceVar(&tagOptions.Tags, "tag", nil, "only consider snapshots which include this `tag`, when no snapshot-ID is given") + tagFlags.StringSliceVar(&tagOptions.Paths, "path", nil, "only consider snapshots which include this (absolute) `path`, when no snapshot-ID is given") +} + +func changeTags(repo *repository.Repository, snapshotID restic.ID, setTags, addTags, removeTags, tags, paths []string, host string) (bool, error) { + var changed bool + + sn, err := restic.LoadSnapshot(repo, snapshotID) + if err != nil { + return false, err + } + if (host != "" && host != sn.Hostname) || !sn.HasTags(tags) || !restic.SamePaths(sn.Paths, paths) { + return false, nil + } + + if len(setTags) != 0 { + // Setting the tag to an empty string really means no more tags. + if len(setTags) == 1 && setTags[0] == "" { + setTags = nil + } + sn.Tags = setTags + changed = true + } else { + for _, add := range addTags { + found := false + for _, tag := range sn.Tags { + if tag == add { + found = true + break + } + } + if !found { + sn.Tags = append(sn.Tags, add) + changed = true + } + } + for _, remove := range removeTags { + for i, tag := range sn.Tags { + if tag == remove { + // https://github.com/golang/go/wiki/SliceTricks + sn.Tags[i] = sn.Tags[len(sn.Tags)-1] + sn.Tags[len(sn.Tags)-1] = "" + sn.Tags = sn.Tags[:len(sn.Tags)-1] + + changed = true + break + } + } + } + } + + if changed { + // Save the new snapshot. + id, err := repo.SaveJSONUnpacked(restic.SnapshotFile, sn) + if err != nil { + return false, err + } + + debug.Log("new snapshot saved as %v", id.Str()) + + if err = repo.Flush(); err != nil { + return false, err + } + + // Remove the old snapshot. + h := restic.Handle{Type: restic.SnapshotFile, Name: sn.ID().String()} + if err = repo.Backend().Remove(h); err != nil { + return false, err + } + + debug.Log("old snapshot %v removed", sn.ID()) + } + return changed, nil +} + +func runTag(opts TagOptions, gopts GlobalOptions, args []string) error { + if len(opts.SetTags) == 0 && len(opts.AddTags) == 0 && len(opts.RemoveTags) == 0 { + return errors.Fatal("nothing to do!") + } + if len(opts.SetTags) != 0 && (len(opts.AddTags) != 0 || len(opts.RemoveTags) != 0) { + return errors.Fatal("--set and --add/--remove cannot be given at the same time") + } + + repo, err := OpenRepository(gopts) + if err != nil { + return err + } + + if !gopts.NoLock { + Verbosef("Create exclusive lock for repository\n") + lock, err := lockRepoExclusive(repo) + defer unlockRepo(lock) + if err != nil { + return err + } + } + + var ids restic.IDs + if len(args) != 0 { + // When explit snapshot-IDs are given, the filtering does not matter anymore. + opts.Host = "" + opts.Tags = nil + opts.Paths = nil + + // Process all snapshot IDs given as arguments. + for _, s := range args { + snapshotID, err := restic.FindSnapshot(repo, s) + if err != nil { + Warnf("could not find a snapshot for ID %q, ignoring: %v\n", s, err) + continue + } + ids = append(ids, snapshotID) + } + ids = ids.Uniq() + } else { + // If there were no arguments, just get all snapshots. + done := make(chan struct{}) + defer close(done) + for snapshotID := range repo.List(restic.SnapshotFile, done) { + ids = append(ids, snapshotID) + } + } + + changeCnt := 0 + for _, id := range ids { + changed, err := changeTags(repo, id, opts.SetTags, opts.AddTags, opts.RemoveTags, opts.Tags, opts.Paths, opts.Host) + if err != nil { + Warnf("unable to modify the tags for snapshot ID %q, ignoring: %v\n", id, err) + continue + } + if changed { + changeCnt++ + } + } + if changeCnt == 0 { + Verbosef("No snapshots were modified\n") + } else { + Verbosef("Modified tags on %v snapshots\n", changeCnt) + } + return nil +} diff --git a/src/cmds/restic/integration_test.go b/src/cmds/restic/integration_test.go index 18c103cf1..6bb7ed254 100644 --- a/src/cmds/restic/integration_test.go +++ b/src/cmds/restic/integration_test.go @@ -655,6 +655,62 @@ func TestBackupTags(t *testing.T) { }) } +func testRunTag(t testing.TB, opts TagOptions, gopts GlobalOptions) { + OK(t, runTag(opts, gopts, []string{})) +} + +func TestTag(t *testing.T) { + withTestEnvironment(t, func(env *testEnvironment, gopts GlobalOptions) { + datafile := filepath.Join("testdata", "backup-data.tar.gz") + testRunInit(t, gopts) + SetupTarTestFixture(t, env.testdata, datafile) + + testRunBackup(t, []string{env.testdata}, BackupOptions{}, gopts) + testRunCheck(t, gopts) + newest, _ := testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 0, + "expected no tags, got %v", newest.Tags) + + testRunTag(t, TagOptions{SetTags: []string{"NL"}}, gopts) + testRunCheck(t, gopts) + newest, _ = testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 1 && newest.Tags[0] == "NL", + "set failed, expected one NL tag, got %v", newest.Tags) + + testRunTag(t, TagOptions{AddTags: []string{"CH"}}, gopts) + testRunCheck(t, gopts) + newest, _ = testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 2 && newest.Tags[0] == "NL" && newest.Tags[1] == "CH", + "add failed, expected CH,NL tags, got %v", newest.Tags) + + testRunTag(t, TagOptions{RemoveTags: []string{"NL"}}, gopts) + testRunCheck(t, gopts) + newest, _ = testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 1 && newest.Tags[0] == "CH", + "remove failed, expected one CH tag, got %v", newest.Tags) + + testRunTag(t, TagOptions{AddTags: []string{"US", "RU"}}, gopts) + testRunTag(t, TagOptions{RemoveTags: []string{"CH", "US", "RU"}}, gopts) + testRunCheck(t, gopts) + newest, _ = testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 0, + "expected no tags, got %v", newest.Tags) + + // Check special case of removing all tags. + testRunTag(t, TagOptions{SetTags: []string{""}}, gopts) + testRunCheck(t, gopts) + newest, _ = testRunSnapshots(t, gopts) + Assert(t, newest != nil, "expected a new backup, got nil") + Assert(t, len(newest.Tags) == 0, + "expected no tags, got %v", newest.Tags) + }) +} + func testRunKeyListOtherIDs(t testing.TB, gopts GlobalOptions) []string { buf := bytes.NewBuffer(nil)