mirror of
https://github.com/octoleo/restic.git
synced 2024-12-25 12:09:59 +00:00
restore: add --delete option to remove files that are not in snapshot
This commit is contained in:
parent
144e2a451f
commit
ac44bdf6dd
@ -51,6 +51,7 @@ type RestoreOptions struct {
|
|||||||
Sparse bool
|
Sparse bool
|
||||||
Verify bool
|
Verify bool
|
||||||
Overwrite restorer.OverwriteBehavior
|
Overwrite restorer.OverwriteBehavior
|
||||||
|
Delete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var restoreOptions RestoreOptions
|
var restoreOptions RestoreOptions
|
||||||
@ -69,6 +70,7 @@ func init() {
|
|||||||
flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
|
flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
|
||||||
flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content")
|
flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content")
|
||||||
flags.Var(&restoreOptions.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)")
|
flags.Var(&restoreOptions.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)")
|
||||||
|
flags.BoolVar(&restoreOptions.Delete, "delete", false, "delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
|
func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
|
||||||
@ -149,6 +151,7 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
|
|||||||
Sparse: opts.Sparse,
|
Sparse: opts.Sparse,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Overwrite: opts.Overwrite,
|
Overwrite: opts.Overwrite,
|
||||||
|
Delete: opts.Delete,
|
||||||
})
|
})
|
||||||
|
|
||||||
totalErrors := 0
|
totalErrors := 0
|
||||||
|
@ -27,6 +27,8 @@ type Restorer struct {
|
|||||||
|
|
||||||
Error func(location string, err error) error
|
Error func(location string, err error) error
|
||||||
Warn func(message string)
|
Warn func(message string)
|
||||||
|
// SelectFilter determines whether the item is selectedForRestore or whether a childMayBeSelected.
|
||||||
|
// selectedForRestore must not depend on isDir as `removeUnexpectedFiles` always passes false to isDir.
|
||||||
SelectFilter func(item string, dstpath string, isDir bool) (selectedForRestore bool, childMayBeSelected bool)
|
SelectFilter func(item string, dstpath string, isDir bool) (selectedForRestore bool, childMayBeSelected bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,6 +446,12 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
leaveDir: func(node *restic.Node, target, location string, expectedFilenames []string) error {
|
leaveDir: func(node *restic.Node, target, location string, expectedFilenames []string) error {
|
||||||
|
if res.opts.Delete {
|
||||||
|
if err := res.removeUnexpectedFiles(target, location, expectedFilenames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -458,6 +466,50 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (res *Restorer) removeUnexpectedFiles(target, location string, expectedFilenames []string) error {
|
||||||
|
if !res.opts.Delete {
|
||||||
|
panic("internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := fs.Readdirnames(fs.Local{}, target, fs.O_NOFOLLOW)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
keep := map[string]struct{}{}
|
||||||
|
for _, name := range expectedFilenames {
|
||||||
|
keep[name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if _, ok := keep[entry]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeTarget := filepath.Join(target, entry)
|
||||||
|
nodeLocation := filepath.Join(location, entry)
|
||||||
|
|
||||||
|
if target == nodeTarget || !fs.HasPathPrefix(target, nodeTarget) {
|
||||||
|
return fmt.Errorf("skipping deletion due to invalid filename: %v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO pass a proper value to the isDir parameter once this becomes relevant for the filters
|
||||||
|
selectedForRestore, _ := res.SelectFilter(nodeLocation, nodeTarget, false)
|
||||||
|
// only delete files that were selected for restore
|
||||||
|
if selectedForRestore {
|
||||||
|
if !res.opts.DryRun {
|
||||||
|
if err := fs.RemoveAll(nodeTarget); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (res *Restorer) trackFile(location string, metadataOnly bool) {
|
func (res *Restorer) trackFile(location string, metadataOnly bool) {
|
||||||
res.fileList[location] = metadataOnly
|
res.fileList[location] = metadataOnly
|
||||||
}
|
}
|
||||||
|
@ -1219,3 +1219,115 @@ func TestRestoreDryRun(t *testing.T) {
|
|||||||
_, err := os.Stat(tempdir)
|
_, err := os.Stat(tempdir)
|
||||||
rtest.Assert(t, errors.Is(err, os.ErrNotExist), "expected no file to be created, got %v", err)
|
rtest.Assert(t, errors.Is(err, os.ErrNotExist), "expected no file to be created, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestoreDelete(t *testing.T) {
|
||||||
|
repo := repository.TestRepository(t)
|
||||||
|
tempdir := rtest.TempDir(t)
|
||||||
|
|
||||||
|
sn, _ := saveSnapshot(t, repo, Snapshot{
|
||||||
|
Nodes: map[string]Node{
|
||||||
|
"dir": Dir{
|
||||||
|
Mode: normalizeFileMode(0755 | os.ModeDir),
|
||||||
|
Nodes: map[string]Node{
|
||||||
|
"file1": File{Data: "content: file\n"},
|
||||||
|
"anotherfile": File{Data: "content: file\n"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"dir2": Dir{
|
||||||
|
Mode: normalizeFileMode(0755 | os.ModeDir),
|
||||||
|
Nodes: map[string]Node{
|
||||||
|
"anotherfile": File{Data: "content: file\n"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"anotherfile": File{Data: "content: file\n"},
|
||||||
|
},
|
||||||
|
}, noopGetGenericAttributes)
|
||||||
|
|
||||||
|
// should delete files that no longer exist in the snapshot
|
||||||
|
deleteSn, _ := saveSnapshot(t, repo, Snapshot{
|
||||||
|
Nodes: map[string]Node{
|
||||||
|
"dir": Dir{
|
||||||
|
Mode: normalizeFileMode(0755 | os.ModeDir),
|
||||||
|
Nodes: map[string]Node{
|
||||||
|
"file1": File{Data: "content: file\n"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, noopGetGenericAttributes)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
selectFilter func(item string, dstpath string, isDir bool) (selectedForRestore bool, childMayBeSelected bool)
|
||||||
|
fileState map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
selectFilter: nil,
|
||||||
|
fileState: map[string]bool{
|
||||||
|
"dir": true,
|
||||||
|
filepath.Join("dir", "anotherfile"): false,
|
||||||
|
filepath.Join("dir", "file1"): true,
|
||||||
|
"dir2": false,
|
||||||
|
filepath.Join("dir2", "anotherfile"): false,
|
||||||
|
"anotherfile": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectFilter: func(item, dstpath string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
|
||||||
|
return false, false
|
||||||
|
},
|
||||||
|
fileState: map[string]bool{
|
||||||
|
"dir": true,
|
||||||
|
filepath.Join("dir", "anotherfile"): true,
|
||||||
|
filepath.Join("dir", "file1"): true,
|
||||||
|
"dir2": true,
|
||||||
|
filepath.Join("dir2", "anotherfile"): true,
|
||||||
|
"anotherfile": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectFilter: func(item, dstpath string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
|
||||||
|
switch item {
|
||||||
|
case filepath.FromSlash("/dir"):
|
||||||
|
selectedForRestore = true
|
||||||
|
case filepath.FromSlash("/dir2"):
|
||||||
|
selectedForRestore = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
},
|
||||||
|
fileState: map[string]bool{
|
||||||
|
"dir": true,
|
||||||
|
filepath.Join("dir", "anotherfile"): true,
|
||||||
|
filepath.Join("dir", "file1"): true,
|
||||||
|
"dir2": false,
|
||||||
|
filepath.Join("dir2", "anotherfile"): false,
|
||||||
|
"anotherfile": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
res := NewRestorer(repo, sn, Options{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := res.RestoreTo(ctx, tempdir)
|
||||||
|
rtest.OK(t, err)
|
||||||
|
|
||||||
|
res = NewRestorer(repo, deleteSn, Options{Delete: true})
|
||||||
|
if test.selectFilter != nil {
|
||||||
|
res.SelectFilter = test.selectFilter
|
||||||
|
}
|
||||||
|
err = res.RestoreTo(ctx, tempdir)
|
||||||
|
rtest.OK(t, err)
|
||||||
|
|
||||||
|
for fn, shouldExist := range test.fileState {
|
||||||
|
_, err := os.Stat(filepath.Join(tempdir, fn))
|
||||||
|
if shouldExist {
|
||||||
|
rtest.OK(t, err)
|
||||||
|
} else {
|
||||||
|
rtest.Assert(t, errors.Is(err, os.ErrNotExist), "file %v: unexpected error got %v, expected ErrNotExist", fn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user