forked from TrueCloudLab/restic
Merge pull request #2876 from aawsome/new-repair-command
Add repair command
This commit is contained in:
commit
ee3c55ea3d
17 changed files with 901 additions and 108 deletions
20
changelog/unreleased/issue-1759
Normal file
20
changelog/unreleased/issue-1759
Normal file
|
@ -0,0 +1,20 @@
|
|||
Enhancement: Add `repair index` and `repair snapshots` commands
|
||||
|
||||
The `rebuild-index` command has been renamed to `repair index`. The old name
|
||||
will still work, but is deprecated.
|
||||
|
||||
When a snapshot was damaged, the only option up to now was to completely forget
|
||||
the snapshot, even if only some unimportant file was damaged.
|
||||
|
||||
We've added a `repair snapshots` command, which can repair snapshots by removing
|
||||
damaged directories and missing files contents. Note that using this command
|
||||
can lead to data loss! Please see the "Troubleshooting" section in the documentation
|
||||
for more details.
|
||||
|
||||
https://github.com/restic/restic/issues/1759
|
||||
https://github.com/restic/restic/issues/1714
|
||||
https://github.com/restic/restic/issues/1798
|
||||
https://github.com/restic/restic/issues/2334
|
||||
https://github.com/restic/restic/pull/2876
|
||||
https://forum.restic.net/t/corrupted-repo-how-to-repair/799
|
||||
https://forum.restic.net/t/recovery-options-for-damaged-repositories/1571
|
|
@ -245,7 +245,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
|
|||
}
|
||||
|
||||
if suggestIndexRebuild {
|
||||
Printf("Duplicate packs/old indexes are non-critical, you can run `restic rebuild-index' to correct this.\n")
|
||||
Printf("Duplicate packs/old indexes are non-critical, you can run `restic repair index' to correct this.\n")
|
||||
}
|
||||
if mixedFound {
|
||||
Printf("Mixed packs with tree and data blobs are non-critical, you can run `restic prune` to correct this.\n")
|
||||
|
|
|
@ -488,7 +488,7 @@ func decidePackAction(ctx context.Context, opts PruneOptions, repo restic.Reposi
|
|||
// Pack size does not fit and pack is needed => error
|
||||
// If the pack is not needed, this is no error, the pack can
|
||||
// and will be simply removed, see below.
|
||||
Warnf("pack %s: calculated size %d does not match real size %d\nRun 'restic rebuild-index'.\n",
|
||||
Warnf("pack %s: calculated size %d does not match real size %d\nRun 'restic repair index'.\n",
|
||||
id.Str(), p.unusedSize+p.usedSize, packSize)
|
||||
return errorSizeNotMatching
|
||||
}
|
||||
|
|
14
cmd/restic/cmd_repair.go
Normal file
14
cmd/restic/cmd_repair.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cmdRepair = &cobra.Command{
|
||||
Use: "repair",
|
||||
Short: "Repair the repository",
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmdRoot.AddCommand(cmdRepair)
|
||||
}
|
|
@ -7,15 +7,15 @@ import (
|
|||
"github.com/restic/restic/internal/pack"
|
||||
"github.com/restic/restic/internal/repository"
|
||||
"github.com/restic/restic/internal/restic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var cmdRebuildIndex = &cobra.Command{
|
||||
Use: "rebuild-index [flags]",
|
||||
var cmdRepairIndex = &cobra.Command{
|
||||
Use: "index [flags]",
|
||||
Short: "Build a new index",
|
||||
Long: `
|
||||
The "rebuild-index" command creates a new index based on the pack files in the
|
||||
The "repair index" command creates a new index based on the pack files in the
|
||||
repository.
|
||||
|
||||
EXIT STATUS
|
||||
|
@ -25,25 +25,37 @@ Exit status is 0 if the command was successful, and non-zero if there was any er
|
|||
`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runRebuildIndex(cmd.Context(), rebuildIndexOptions, globalOptions)
|
||||
return runRebuildIndex(cmd.Context(), repairIndexOptions, globalOptions)
|
||||
},
|
||||
}
|
||||
|
||||
// RebuildIndexOptions collects all options for the rebuild-index command.
|
||||
type RebuildIndexOptions struct {
|
||||
var cmdRebuildIndex = &cobra.Command{
|
||||
Use: "rebuild-index [flags]",
|
||||
Short: cmdRepairIndex.Short,
|
||||
Long: cmdRepairIndex.Long,
|
||||
Deprecated: `Use "repair index" instead`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: cmdRepairIndex.RunE,
|
||||
}
|
||||
|
||||
// RepairIndexOptions collects all options for the repair index command.
|
||||
type RepairIndexOptions struct {
|
||||
ReadAllPacks bool
|
||||
}
|
||||
|
||||
var rebuildIndexOptions RebuildIndexOptions
|
||||
var repairIndexOptions RepairIndexOptions
|
||||
|
||||
func init() {
|
||||
cmdRepair.AddCommand(cmdRepairIndex)
|
||||
// add alias for old name
|
||||
cmdRoot.AddCommand(cmdRebuildIndex)
|
||||
f := cmdRebuildIndex.Flags()
|
||||
f.BoolVar(&rebuildIndexOptions.ReadAllPacks, "read-all-packs", false, "read all pack files to generate new index from scratch")
|
||||
|
||||
for _, f := range []*pflag.FlagSet{cmdRepairIndex.Flags(), cmdRebuildIndex.Flags()} {
|
||||
f.BoolVar(&repairIndexOptions.ReadAllPacks, "read-all-packs", false, "read all pack files to generate new index from scratch")
|
||||
}
|
||||
}
|
||||
|
||||
func runRebuildIndex(ctx context.Context, opts RebuildIndexOptions, gopts GlobalOptions) error {
|
||||
func runRebuildIndex(ctx context.Context, opts RepairIndexOptions, gopts GlobalOptions) error {
|
||||
repo, err := OpenRepository(ctx, gopts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -58,7 +70,7 @@ func runRebuildIndex(ctx context.Context, opts RebuildIndexOptions, gopts Global
|
|||
return rebuildIndex(ctx, opts, gopts, repo, restic.NewIDSet())
|
||||
}
|
||||
|
||||
func rebuildIndex(ctx context.Context, opts RebuildIndexOptions, gopts GlobalOptions, repo *repository.Repository, ignorePacks restic.IDSet) error {
|
||||
func rebuildIndex(ctx context.Context, opts RepairIndexOptions, gopts GlobalOptions, repo *repository.Repository, ignorePacks restic.IDSet) error {
|
||||
var obsoleteIndexes restic.IDs
|
||||
packSizeFromList := make(map[restic.ID]int64)
|
||||
packSizeFromIndex := make(map[restic.ID]int64)
|
176
cmd/restic/cmd_repair_snapshots.go
Normal file
176
cmd/restic/cmd_repair_snapshots.go
Normal file
|
@ -0,0 +1,176 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/restic/restic/internal/backend"
|
||||
"github.com/restic/restic/internal/errors"
|
||||
"github.com/restic/restic/internal/restic"
|
||||
"github.com/restic/restic/internal/walker"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cmdRepairSnapshots = &cobra.Command{
|
||||
Use: "snapshots [flags] [snapshot ID] [...]",
|
||||
Short: "Repair snapshots",
|
||||
Long: `
|
||||
The "repair snapshots" command repairs broken snapshots. It scans the given
|
||||
snapshots and generates new ones with damaged directories and file contents
|
||||
removed. If the broken snapshots are deleted, a prune run will be able to
|
||||
clean up the repository.
|
||||
|
||||
The command depends on a correct index, thus make sure to run "repair index"
|
||||
first!
|
||||
|
||||
|
||||
WARNING
|
||||
=======
|
||||
|
||||
Repairing and deleting broken snapshots causes data loss! It will remove broken
|
||||
directories and modify broken files in the modified snapshots.
|
||||
|
||||
If the contents of directories and files are still available, the better option
|
||||
is to run "backup" which in that case is able to heal existing snapshots. Only
|
||||
use the "repair snapshots" command if you need to recover an old and broken
|
||||
snapshot!
|
||||
|
||||
EXIT STATUS
|
||||
===========
|
||||
|
||||
Exit status is 0 if the command was successful, and non-zero if there was any error.
|
||||
`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runRepairSnapshots(cmd.Context(), globalOptions, repairSnapshotOptions, args)
|
||||
},
|
||||
}
|
||||
|
||||
// RepairOptions collects all options for the repair command.
|
||||
type RepairOptions struct {
|
||||
DryRun bool
|
||||
Forget bool
|
||||
|
||||
restic.SnapshotFilter
|
||||
}
|
||||
|
||||
var repairSnapshotOptions RepairOptions
|
||||
|
||||
func init() {
|
||||
cmdRepair.AddCommand(cmdRepairSnapshots)
|
||||
flags := cmdRepairSnapshots.Flags()
|
||||
|
||||
flags.BoolVarP(&repairSnapshotOptions.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
|
||||
flags.BoolVarP(&repairSnapshotOptions.Forget, "forget", "", false, "remove original snapshots after creating new ones")
|
||||
|
||||
initMultiSnapshotFilter(flags, &repairSnapshotOptions.SnapshotFilter, true)
|
||||
}
|
||||
|
||||
func runRepairSnapshots(ctx context.Context, gopts GlobalOptions, opts RepairOptions, args []string) error {
|
||||
repo, err := OpenRepository(ctx, globalOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.DryRun {
|
||||
var lock *restic.Lock
|
||||
var err error
|
||||
lock, ctx, err = lockRepoExclusive(ctx, repo, gopts.RetryLock, gopts.JSON)
|
||||
defer unlockRepo(lock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
repo.SetDryRun()
|
||||
}
|
||||
|
||||
snapshotLister, err := backend.MemorizeList(ctx, repo.Backend(), restic.SnapshotFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo.LoadIndex(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Three error cases are checked:
|
||||
// - tree is a nil tree (-> will be replaced by an empty tree)
|
||||
// - trees which cannot be loaded (-> the tree contents will be removed)
|
||||
// - files whose contents are not fully available (-> file will be modified)
|
||||
rewriter := walker.NewTreeRewriter(walker.RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
if node.Type != "file" {
|
||||
return node
|
||||
}
|
||||
|
||||
ok := true
|
||||
var newContent restic.IDs = restic.IDs{}
|
||||
var newSize uint64
|
||||
// check all contents and remove if not available
|
||||
for _, id := range node.Content {
|
||||
if size, found := repo.LookupBlobSize(id, restic.DataBlob); !found {
|
||||
ok = false
|
||||
} else {
|
||||
newContent = append(newContent, id)
|
||||
newSize += uint64(size)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
Verbosef(" file %q: removed missing content\n", path)
|
||||
} else if newSize != node.Size {
|
||||
Verbosef(" file %q: fixed incorrect size\n", path)
|
||||
}
|
||||
// no-ops if already correct
|
||||
node.Content = newContent
|
||||
node.Size = newSize
|
||||
return node
|
||||
},
|
||||
RewriteFailedTree: func(nodeID restic.ID, path string, _ error) (restic.ID, error) {
|
||||
if path == "/" {
|
||||
Verbosef(" dir %q: not readable\n", path)
|
||||
// remove snapshots with invalid root node
|
||||
return restic.ID{}, nil
|
||||
}
|
||||
// If a subtree fails to load, remove it
|
||||
Verbosef(" dir %q: replaced with empty directory\n", path)
|
||||
emptyID, err := restic.SaveTree(ctx, repo, &restic.Tree{})
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
return emptyID, nil
|
||||
},
|
||||
AllowUnstableSerialization: true,
|
||||
})
|
||||
|
||||
changedCount := 0
|
||||
for sn := range FindFilteredSnapshots(ctx, snapshotLister, repo, &opts.SnapshotFilter, args) {
|
||||
Verbosef("\nsnapshot %s of %v at %s)\n", sn.ID().Str(), sn.Paths, sn.Time)
|
||||
changed, err := filterAndReplaceSnapshot(ctx, repo, sn,
|
||||
func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
||||
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
||||
}, opts.DryRun, opts.Forget, "repaired")
|
||||
if err != nil {
|
||||
return errors.Fatalf("unable to rewrite snapshot ID %q: %v", sn.ID().Str(), err)
|
||||
}
|
||||
if changed {
|
||||
changedCount++
|
||||
}
|
||||
}
|
||||
|
||||
Verbosef("\n")
|
||||
if changedCount == 0 {
|
||||
if !opts.DryRun {
|
||||
Verbosef("no snapshots were modified\n")
|
||||
} else {
|
||||
Verbosef("no snapshots would be modified\n")
|
||||
}
|
||||
} else {
|
||||
if !opts.DryRun {
|
||||
Verbosef("modified %v snapshots\n", changedCount)
|
||||
} else {
|
||||
Verbosef("would modify %v snapshots\n", changedCount)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -87,36 +87,67 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *resti
|
|||
return true
|
||||
}
|
||||
|
||||
rewriter := walker.NewTreeRewriter(walker.RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
if selectByName(path) {
|
||||
return node
|
||||
}
|
||||
Verbosef(fmt.Sprintf("excluding %s\n", path))
|
||||
return nil
|
||||
},
|
||||
DisableNodeCache: true,
|
||||
})
|
||||
|
||||
return filterAndReplaceSnapshot(ctx, repo, sn,
|
||||
func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
||||
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
||||
}, opts.DryRun, opts.Forget, "rewrite")
|
||||
}
|
||||
|
||||
func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *restic.Snapshot, filter func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error), dryRun bool, forget bool, addTag string) (bool, error) {
|
||||
|
||||
wg, wgCtx := errgroup.WithContext(ctx)
|
||||
repo.StartPackUploader(wgCtx, wg)
|
||||
|
||||
var filteredTree restic.ID
|
||||
wg.Go(func() error {
|
||||
filteredTree, err = walker.FilterTree(wgCtx, repo, "/", *sn.Tree, &walker.TreeFilterVisitor{
|
||||
SelectByName: selectByName,
|
||||
PrintExclude: func(path string) { Verbosef(fmt.Sprintf("excluding %s\n", path)) },
|
||||
})
|
||||
var err error
|
||||
filteredTree, err = filter(ctx, sn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo.Flush(wgCtx)
|
||||
})
|
||||
err = wg.Wait()
|
||||
err := wg.Wait()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if filteredTree.IsNull() {
|
||||
if dryRun {
|
||||
Verbosef("would delete empty snapshot\n")
|
||||
} else {
|
||||
h := restic.Handle{Type: restic.SnapshotFile, Name: sn.ID().String()}
|
||||
if err = repo.Backend().Remove(ctx, h); err != nil {
|
||||
return false, err
|
||||
}
|
||||
debug.Log("removed empty snapshot %v", sn.ID())
|
||||
Verbosef("removed empty snapshot %v\n", sn.ID().Str())
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if filteredTree == *sn.Tree {
|
||||
debug.Log("Snapshot %v not modified", sn)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
debug.Log("Snapshot %v modified", sn)
|
||||
if opts.DryRun {
|
||||
if dryRun {
|
||||
Verbosef("would save new snapshot\n")
|
||||
|
||||
if opts.Forget {
|
||||
if forget {
|
||||
Verbosef("would remove old snapshot\n")
|
||||
}
|
||||
|
||||
|
@ -125,10 +156,10 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *resti
|
|||
|
||||
// Always set the original snapshot id as this essentially a new snapshot.
|
||||
sn.Original = sn.ID()
|
||||
*sn.Tree = filteredTree
|
||||
sn.Tree = &filteredTree
|
||||
|
||||
if !opts.Forget {
|
||||
sn.AddTags([]string{"rewrite"})
|
||||
if !forget {
|
||||
sn.AddTags([]string{addTag})
|
||||
}
|
||||
|
||||
// Save the new snapshot.
|
||||
|
@ -138,7 +169,7 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *resti
|
|||
}
|
||||
Verbosef("saved new snapshot %v\n", id.Str())
|
||||
|
||||
if opts.Forget {
|
||||
if forget {
|
||||
h := restic.Handle{Type: restic.SnapshotFile, Name: sn.ID().String()}
|
||||
if err = repo.Backend().Remove(ctx, h); err != nil {
|
||||
return false, err
|
||||
|
|
135
cmd/restic/integration_repair_snapshots_test.go
Normal file
135
cmd/restic/integration_repair_snapshots_test.go
Normal file
|
@ -0,0 +1,135 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/restic/restic/internal/restic"
|
||||
rtest "github.com/restic/restic/internal/test"
|
||||
)
|
||||
|
||||
func testRunRepairSnapshot(t testing.TB, gopts GlobalOptions, forget bool) {
|
||||
opts := RepairOptions{
|
||||
Forget: forget,
|
||||
}
|
||||
|
||||
rtest.OK(t, runRepairSnapshots(context.TODO(), gopts, opts, nil))
|
||||
}
|
||||
|
||||
func createRandomFile(t testing.TB, env *testEnvironment, path string, size int) {
|
||||
fn := filepath.Join(env.testdata, path)
|
||||
rtest.OK(t, os.MkdirAll(filepath.Dir(fn), 0o755))
|
||||
|
||||
h := fnv.New64()
|
||||
_, err := h.Write([]byte(path))
|
||||
rtest.OK(t, err)
|
||||
r := rand.New(rand.NewSource(int64(h.Sum64())))
|
||||
|
||||
f, err := os.OpenFile(fn, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
rtest.OK(t, err)
|
||||
_, err = io.Copy(f, io.LimitReader(r, int64(size)))
|
||||
rtest.OK(t, err)
|
||||
rtest.OK(t, f.Close())
|
||||
}
|
||||
|
||||
func TestRepairSnapshotsWithLostData(t *testing.T) {
|
||||
env, cleanup := withTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
testRunInit(t, env.gopts)
|
||||
|
||||
createRandomFile(t, env, "foo/bar/file", 512*1024)
|
||||
testRunBackup(t, "", []string{env.testdata}, BackupOptions{}, env.gopts)
|
||||
testListSnapshots(t, env.gopts, 1)
|
||||
// damage repository
|
||||
removePacksExcept(env.gopts, t, restic.NewIDSet(), false)
|
||||
|
||||
createRandomFile(t, env, "foo/bar/file2", 256*1024)
|
||||
testRunBackup(t, "", []string{env.testdata}, BackupOptions{}, env.gopts)
|
||||
snapshotIDs := testListSnapshots(t, env.gopts, 2)
|
||||
testRunCheckMustFail(t, env.gopts)
|
||||
|
||||
// repair but keep broken snapshots
|
||||
testRunRebuildIndex(t, env.gopts)
|
||||
testRunRepairSnapshot(t, env.gopts, false)
|
||||
testListSnapshots(t, env.gopts, 4)
|
||||
testRunCheckMustFail(t, env.gopts)
|
||||
|
||||
// repository must be ok after removing the broken snapshots
|
||||
testRunForget(t, env.gopts, snapshotIDs[0].String(), snapshotIDs[1].String())
|
||||
testListSnapshots(t, env.gopts, 2)
|
||||
_, err := testRunCheckOutput(env.gopts)
|
||||
rtest.OK(t, err)
|
||||
}
|
||||
|
||||
func TestRepairSnapshotsWithLostTree(t *testing.T) {
|
||||
env, cleanup := withTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
testRunInit(t, env.gopts)
|
||||
|
||||
createRandomFile(t, env, "foo/bar/file", 12345)
|
||||
testRunBackup(t, "", []string{env.testdata}, BackupOptions{}, env.gopts)
|
||||
oldSnapshot := testListSnapshots(t, env.gopts, 1)
|
||||
oldPacks := testRunList(t, "packs", env.gopts)
|
||||
|
||||
// keep foo/bar unchanged
|
||||
createRandomFile(t, env, "foo/bar2", 1024)
|
||||
testRunBackup(t, "", []string{env.testdata}, BackupOptions{}, env.gopts)
|
||||
testListSnapshots(t, env.gopts, 2)
|
||||
|
||||
// remove tree for foo/bar and the now completely broken first snapshot
|
||||
removePacks(env.gopts, t, restic.NewIDSet(oldPacks...))
|
||||
testRunForget(t, env.gopts, oldSnapshot[0].String())
|
||||
testRunCheckMustFail(t, env.gopts)
|
||||
|
||||
// repair
|
||||
testRunRebuildIndex(t, env.gopts)
|
||||
testRunRepairSnapshot(t, env.gopts, true)
|
||||
testListSnapshots(t, env.gopts, 1)
|
||||
_, err := testRunCheckOutput(env.gopts)
|
||||
rtest.OK(t, err)
|
||||
}
|
||||
|
||||
func TestRepairSnapshotsWithLostRootTree(t *testing.T) {
|
||||
env, cleanup := withTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
testRunInit(t, env.gopts)
|
||||
|
||||
createRandomFile(t, env, "foo/bar/file", 12345)
|
||||
testRunBackup(t, "", []string{env.testdata}, BackupOptions{}, env.gopts)
|
||||
testListSnapshots(t, env.gopts, 1)
|
||||
oldPacks := testRunList(t, "packs", env.gopts)
|
||||
|
||||
// remove all trees
|
||||
removePacks(env.gopts, t, restic.NewIDSet(oldPacks...))
|
||||
testRunCheckMustFail(t, env.gopts)
|
||||
|
||||
// repair
|
||||
testRunRebuildIndex(t, env.gopts)
|
||||
testRunRepairSnapshot(t, env.gopts, true)
|
||||
testListSnapshots(t, env.gopts, 0)
|
||||
_, err := testRunCheckOutput(env.gopts)
|
||||
rtest.OK(t, err)
|
||||
}
|
||||
|
||||
func TestRepairSnapshotsIntact(t *testing.T) {
|
||||
env, cleanup := withTestEnvironment(t)
|
||||
defer cleanup()
|
||||
testSetupBackupData(t, env)
|
||||
testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, BackupOptions{}, env.gopts)
|
||||
oldSnapshotIDs := testListSnapshots(t, env.gopts, 1)
|
||||
|
||||
// use an exclude that will not exclude anything
|
||||
testRunRepairSnapshot(t, env.gopts, false)
|
||||
snapshotIDs := testListSnapshots(t, env.gopts, 1)
|
||||
rtest.Assert(t, reflect.DeepEqual(oldSnapshotIDs, snapshotIDs), "unexpected snapshot id mismatch %v vs. %v", oldSnapshotIDs, snapshotIDs)
|
||||
testRunCheck(t, env.gopts)
|
||||
}
|
|
@ -100,6 +100,13 @@ func testRunList(t testing.TB, tpe string, opts GlobalOptions) restic.IDs {
|
|||
return parseIDsFromReader(t, buf)
|
||||
}
|
||||
|
||||
func testListSnapshots(t testing.TB, opts GlobalOptions, expected int) restic.IDs {
|
||||
t.Helper()
|
||||
snapshotIDs := testRunList(t, "snapshots", opts)
|
||||
rtest.Assert(t, len(snapshotIDs) == expected, "expected %v snapshot, got %v", expected, snapshotIDs)
|
||||
return snapshotIDs
|
||||
}
|
||||
|
||||
func testRunRestore(t testing.TB, opts GlobalOptions, dir string, snapshotID restic.ID) {
|
||||
testRunRestoreExcludes(t, opts, dir, snapshotID, nil)
|
||||
}
|
||||
|
@ -164,6 +171,11 @@ func testRunCheckOutput(gopts GlobalOptions) (string, error) {
|
|||
return buf.String(), err
|
||||
}
|
||||
|
||||
func testRunCheckMustFail(t testing.TB, gopts GlobalOptions) {
|
||||
_, err := testRunCheckOutput(gopts)
|
||||
rtest.Assert(t, err != nil, "expected non nil error after check of damaged repository")
|
||||
}
|
||||
|
||||
func testRunDiffOutput(gopts GlobalOptions, firstSnapshotID string, secondSnapshotID string) (string, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
|
||||
|
@ -188,7 +200,7 @@ func testRunRebuildIndex(t testing.TB, gopts GlobalOptions) {
|
|||
globalOptions.stdout = os.Stdout
|
||||
}()
|
||||
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RebuildIndexOptions{}, gopts))
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RepairIndexOptions{}, gopts))
|
||||
}
|
||||
|
||||
func testRunLs(t testing.TB, gopts GlobalOptions, snapshotID string) []string {
|
||||
|
@ -486,7 +498,16 @@ func TestBackupNonExistingFile(t *testing.T) {
|
|||
testRunBackup(t, "", dirs, opts, env.gopts)
|
||||
}
|
||||
|
||||
func removePacksExcept(gopts GlobalOptions, t *testing.T, keep restic.IDSet, removeTreePacks bool) {
|
||||
func removePacks(gopts GlobalOptions, t testing.TB, remove restic.IDSet) {
|
||||
r, err := OpenRepository(context.TODO(), gopts)
|
||||
rtest.OK(t, err)
|
||||
|
||||
for id := range remove {
|
||||
rtest.OK(t, r.Backend().Remove(context.TODO(), restic.Handle{Type: restic.PackFile, Name: id.String()}))
|
||||
}
|
||||
}
|
||||
|
||||
func removePacksExcept(gopts GlobalOptions, t testing.TB, keep restic.IDSet, removeTreePacks bool) {
|
||||
r, err := OpenRepository(context.TODO(), gopts)
|
||||
rtest.OK(t, err)
|
||||
|
||||
|
@ -1504,8 +1525,8 @@ func testRebuildIndex(t *testing.T, backendTestHook backendWrapper) {
|
|||
t.Fatalf("expected no error from checker for test repository, got %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "restic rebuild-index") {
|
||||
t.Fatalf("did not find hint for rebuild-index command")
|
||||
if !strings.Contains(out, "restic repair index") {
|
||||
t.Fatalf("did not find hint for repair index command")
|
||||
}
|
||||
|
||||
env.gopts.backendTestHook = backendTestHook
|
||||
|
@ -1518,7 +1539,7 @@ func testRebuildIndex(t *testing.T, backendTestHook backendWrapper) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from checker after rebuild-index, got: %v", err)
|
||||
t.Fatalf("expected no error from checker after repair index, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1599,7 +1620,7 @@ func TestRebuildIndexFailsOnAppendOnly(t *testing.T) {
|
|||
env.gopts.backendTestHook = func(r restic.Backend) (restic.Backend, error) {
|
||||
return &appendOnlyBackend{r}, nil
|
||||
}
|
||||
err := runRebuildIndex(context.TODO(), RebuildIndexOptions{}, env.gopts)
|
||||
err := runRebuildIndex(context.TODO(), RepairIndexOptions{}, env.gopts)
|
||||
if err == nil {
|
||||
t.Error("expected rebuildIndex to fail")
|
||||
}
|
||||
|
@ -1887,8 +1908,8 @@ func TestListOnce(t *testing.T) {
|
|||
testRunPrune(t, env.gopts, pruneOpts)
|
||||
rtest.OK(t, runCheck(context.TODO(), checkOpts, env.gopts, nil))
|
||||
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RebuildIndexOptions{}, env.gopts))
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RebuildIndexOptions{ReadAllPacks: true}, env.gopts))
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RepairIndexOptions{}, env.gopts))
|
||||
rtest.OK(t, runRebuildIndex(context.TODO(), RepairIndexOptions{ReadAllPacks: true}, env.gopts))
|
||||
}
|
||||
|
||||
func TestHardLink(t *testing.T) {
|
||||
|
|
|
@ -472,7 +472,7 @@ space. However, a **failed** ``prune`` run can cause the repository to become
|
|||
**temporarily unusable**. Therefore, make sure that you have a stable connection to the
|
||||
repository storage, before running this command. In case the command fails, it may become
|
||||
necessary to manually remove all files from the `index/` folder of the repository and
|
||||
run `rebuild-index` afterwards.
|
||||
run `repair index` afterwards.
|
||||
|
||||
To prevent accidental usages of the ``--unsafe-recover-no-free-space`` option it is
|
||||
necessary to first run ``prune --unsafe-recover-no-free-space SOME-ID`` and then replace
|
||||
|
|
194
doc/077_troubleshooting.rst
Normal file
194
doc/077_troubleshooting.rst
Normal file
|
@ -0,0 +1,194 @@
|
|||
..
|
||||
Normally, there are no heading levels assigned to certain characters as the structure is
|
||||
determined from the succession of headings. However, this convention is used in Python’s
|
||||
Style Guide for documenting which you may follow:
|
||||
|
||||
# with overline, for parts
|
||||
* for chapters
|
||||
= for sections
|
||||
- for subsections
|
||||
^ for subsubsections
|
||||
" for paragraphs
|
||||
|
||||
#########################
|
||||
Troubleshooting
|
||||
#########################
|
||||
|
||||
The repository format used by restic is designed to be error resistant. In
|
||||
particular, commands like, for example, ``backup`` or ``prune`` can be interrupted
|
||||
at *any* point in time without damaging the repository. You might have to run
|
||||
``unlock`` manually though, but that's it.
|
||||
|
||||
However, a repository might be damaged if some of its files are damaged or lost.
|
||||
This can occur due to hardware failures, accidentally removing files from the
|
||||
repository or bugs in the implementation of restic.
|
||||
|
||||
The following steps will help you recover a repository. This guide does not cover
|
||||
all possible types of repository damages. Thus, if the steps do not work for you
|
||||
or you are unsure how to proceed, then ask for help. Please always include the
|
||||
check output discussed in the next section and what steps you've taken to repair
|
||||
the repository so far.
|
||||
|
||||
* `Forum <https://forum.restic.net/>`_
|
||||
* Our IRC channel ``#restic`` on ``irc.libera.chat``
|
||||
|
||||
Make sure that you **use the latest available restic version**. It can contain
|
||||
bugfixes, and improvements to simplify the repair of a repository. It might also
|
||||
contain a fix for your repository problems!
|
||||
|
||||
|
||||
1. Find out what is damaged
|
||||
***************************
|
||||
|
||||
The first step is always to check the repository.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ restic check --read-data
|
||||
|
||||
using temporary cache in /tmp/restic-check-cache-1418935501
|
||||
repository 12345678 opened (version 2, compression level auto)
|
||||
created new cache in /tmp/restic-check-cache-1418935501
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
error for tree 7ef8ebab:
|
||||
id 7ef8ebabc59aadda1a237d23ca7abac487b627a9b86508aa0194690446ff71f6 not found in repository
|
||||
[0:02] 100.00% 7 / 7 snapshots
|
||||
read all data
|
||||
[0:05] 100.00% 25 / 25 packs
|
||||
Fatal: repository contains errors
|
||||
|
||||
.. note::
|
||||
|
||||
This will download the whole repository. If retrieving data from the backend is
|
||||
expensive, then omit the ``--read-data`` option. Keep a copy of the check output
|
||||
as it might be necessary later on!
|
||||
|
||||
If the output contains warnings that the ``ciphertext verification failed`` for
|
||||
some blobs in the repository, then please ask for help in the forum or our IRC
|
||||
channel. These errors are often caused by hardware problems which **must** be
|
||||
investigated and fixed. Otherwise, the backup will be damaged again and again.
|
||||
|
||||
Similarly, if a repository is repeatedly damaged, please open an `issue on Github
|
||||
<https://github.com/restic/restic/issues/new/choose>`_ as this could indicate a bug
|
||||
somewhere. Please include the check output and additional information that might
|
||||
help locate the problem.
|
||||
|
||||
|
||||
2. Backup the repository
|
||||
************************
|
||||
|
||||
Create a full copy of the repository if possible. Or at the very least make a
|
||||
copy of the ``index`` and ``snapshots`` folders. This will allow you to roll back
|
||||
the repository if the repair procedure fails. If your repository resides in a
|
||||
cloud storage, then you can for example use `rclone <https://rclone.org/>`_ to
|
||||
make such a copy.
|
||||
|
||||
Please disable all regular operations on the repository to prevent unexpected
|
||||
changes. Especially, ``forget`` or ``prune`` must be disabled as they could
|
||||
remove data unexpectedly.
|
||||
|
||||
.. warning::
|
||||
|
||||
If you suspect hardware problems, then you *must* investigate those first.
|
||||
Otherwise, the repository will soon be damaged again.
|
||||
|
||||
Please take the time to understand what the commands described in the following
|
||||
do. If you are unsure, then ask for help in the forum or our IRC channel. Search
|
||||
whether your issue is already known and solved. Please take a look at the
|
||||
`forum`_ and `Github issues <https://github.com/restic/restic/issues>`_.
|
||||
|
||||
|
||||
3. Repair the index
|
||||
*******************
|
||||
|
||||
Restic relies on its index to contain correct information about what data is
|
||||
stored in the repository. Thus, the first step to repair a repository is to
|
||||
repair the index:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ restic repair index
|
||||
|
||||
repository a14e5863 opened (version 2, compression level auto)
|
||||
loading indexes...
|
||||
getting pack files to read...
|
||||
removing not found pack file 83ad44f59b05f6bce13376b022ac3194f24ca19e7a74926000b6e316ec6ea5a4
|
||||
rebuilding index
|
||||
[0:00] 100.00% 27 / 27 packs processed
|
||||
deleting obsolete index files
|
||||
[0:00] 100.00% 3 / 3 files deleted
|
||||
done
|
||||
|
||||
This ensures that no longer existing files are removed from the index. All later
|
||||
steps to repair the repository rely on a correct index. That is, you must always
|
||||
repair the index first!
|
||||
|
||||
Please note that it is not recommended to repair the index unless the repository
|
||||
is actually damaged.
|
||||
|
||||
|
||||
4. Run all backups (optional)
|
||||
*****************************
|
||||
|
||||
With a correct index, the ``backup`` command guarantees that newly created
|
||||
snapshots can be restored successfully. It can also heal older snapshots,
|
||||
if the missing data is also contained in the new snapshot.
|
||||
|
||||
Therefore, it is recommended to run all your ``backup`` tasks again. In some
|
||||
cases, this is enough to fully repair the repository.
|
||||
|
||||
|
||||
5. Remove missing data from snapshots
|
||||
*************************************
|
||||
|
||||
If your repository is still missing data, then you can use the ``repair snapshots``
|
||||
command to remove all inaccessible data from the snapshots. That is, this will
|
||||
result in a limited amount of data loss. Using the ``--forget`` option, the
|
||||
command will automatically remove the original, damaged snapshots.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ restic repair snapshots --forget
|
||||
|
||||
snapshot 6979421e of [/home/user/restic/restic] at 2022-11-02 20:59:18.617503315 +0100 CET)
|
||||
file "/restic/internal/fuse/snapshots_dir.go": removed missing content
|
||||
file "/restic/internal/restorer/restorer_unix_test.go": removed missing content
|
||||
file "/restic/internal/walker/walker.go": removed missing content
|
||||
saved new snapshot 7b094cea
|
||||
removed old snapshot 6979421e
|
||||
|
||||
modified 1 snapshots
|
||||
|
||||
If you did not add the ``--forget`` option, then you have to manually delete all
|
||||
modified snapshots using the ``forget`` command. In the example above, you'd have
|
||||
to run ``restic forget 6979421e``.
|
||||
|
||||
|
||||
6. Check the repository again
|
||||
*****************************
|
||||
|
||||
Phew, we're almost done now. To make sure that the repository has been successfully
|
||||
repaired please run ``check`` again.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ restic check --read-data
|
||||
|
||||
using temporary cache in /tmp/restic-check-cache-2569290785
|
||||
repository a14e5863 opened (version 2, compression level auto)
|
||||
created new cache in /tmp/restic-check-cache-2569290785
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
[0:00] 100.00% 7 / 7 snapshots
|
||||
read all data
|
||||
[0:00] 100.00% 25 / 25 packs
|
||||
no errors were found
|
||||
|
||||
If the ``check`` command did not complete with ``no errors were found``, then
|
||||
the repository is still damaged. At this point, please ask for help at the
|
||||
`forum`_ or our IRC channel ``#restic`` on ``irc.libera.chat``.
|
|
@ -14,6 +14,7 @@ Restic Documentation
|
|||
060_forget
|
||||
070_encryption
|
||||
075_scripting
|
||||
077_troubleshooting
|
||||
080_examples
|
||||
090_participating
|
||||
100_references
|
||||
|
|
|
@ -35,8 +35,8 @@ Usage help is available:
|
|||
migrate Apply migrations
|
||||
mount Mount the repository
|
||||
prune Remove unneeded data from the repository
|
||||
rebuild-index Build a new index
|
||||
recover Recover data from the repository not referenced by snapshots
|
||||
repair Repair the repository
|
||||
restore Extract the data from a snapshot
|
||||
rewrite Rewrite snapshots to exclude unwanted files
|
||||
self-update Update the restic binary
|
||||
|
|
|
@ -207,7 +207,7 @@ func (arch *Archiver) wrapLoadTreeError(id restic.ID, err error) error {
|
|||
if arch.Repo.Index().Has(restic.BlobHandle{ID: id, Type: restic.TreeBlob}) {
|
||||
err = errors.Errorf("tree %v could not be loaded; the repository could be damaged: %v", id, err)
|
||||
} else {
|
||||
err = errors.Errorf("tree %v is not known; the repository could be damaged, run `rebuild-index` to try to repair it", id)
|
||||
err = errors.Errorf("tree %v is not known; the repository could be damaged, run `repair index` to try to repair it", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -9,13 +9,47 @@ import (
|
|||
"github.com/restic/restic/internal/restic"
|
||||
)
|
||||
|
||||
// SelectByNameFunc returns true for all items that should be included (files and
|
||||
// dirs). If false is returned, files are ignored and dirs are not even walked.
|
||||
type SelectByNameFunc func(item string) bool
|
||||
type NodeRewriteFunc func(node *restic.Node, path string) *restic.Node
|
||||
type FailedTreeRewriteFunc func(nodeID restic.ID, path string, err error) (restic.ID, error)
|
||||
|
||||
type TreeFilterVisitor struct {
|
||||
SelectByName SelectByNameFunc
|
||||
PrintExclude func(string)
|
||||
type RewriteOpts struct {
|
||||
// return nil to remove the node
|
||||
RewriteNode NodeRewriteFunc
|
||||
// decide what to do with a tree that could not be loaded. Return nil to remove the node. By default the load error is returned which causes the operation to fail.
|
||||
RewriteFailedTree FailedTreeRewriteFunc
|
||||
|
||||
AllowUnstableSerialization bool
|
||||
DisableNodeCache bool
|
||||
}
|
||||
|
||||
type idMap map[restic.ID]restic.ID
|
||||
|
||||
type TreeRewriter struct {
|
||||
opts RewriteOpts
|
||||
|
||||
replaces idMap
|
||||
}
|
||||
|
||||
func NewTreeRewriter(opts RewriteOpts) *TreeRewriter {
|
||||
rw := &TreeRewriter{
|
||||
opts: opts,
|
||||
}
|
||||
if !opts.DisableNodeCache {
|
||||
rw.replaces = make(idMap)
|
||||
}
|
||||
// setup default implementations
|
||||
if rw.opts.RewriteNode == nil {
|
||||
rw.opts.RewriteNode = func(node *restic.Node, path string) *restic.Node {
|
||||
return node
|
||||
}
|
||||
}
|
||||
if rw.opts.RewriteFailedTree == nil {
|
||||
// fail with error by default
|
||||
rw.opts.RewriteFailedTree = func(nodeID restic.ID, path string, err error) (restic.ID, error) {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
}
|
||||
return rw
|
||||
}
|
||||
|
||||
type BlobLoadSaver interface {
|
||||
|
@ -23,51 +57,58 @@ type BlobLoadSaver interface {
|
|||
restic.BlobLoader
|
||||
}
|
||||
|
||||
func FilterTree(ctx context.Context, repo BlobLoadSaver, nodepath string, nodeID restic.ID, visitor *TreeFilterVisitor) (newNodeID restic.ID, err error) {
|
||||
curTree, err := restic.LoadTree(ctx, repo, nodeID)
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
func (t *TreeRewriter) RewriteTree(ctx context.Context, repo BlobLoadSaver, nodepath string, nodeID restic.ID) (newNodeID restic.ID, err error) {
|
||||
// check if tree was already changed
|
||||
newID, ok := t.replaces[nodeID]
|
||||
if ok {
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// check that we can properly encode this tree without losing information
|
||||
// The alternative of using json/Decoder.DisallowUnknownFields() doesn't work as we use
|
||||
// a custom UnmarshalJSON to decode trees, see also https://github.com/golang/go/issues/41144
|
||||
testID, err := restic.SaveTree(ctx, repo, curTree)
|
||||
// a nil nodeID will lead to a load error
|
||||
curTree, err := restic.LoadTree(ctx, repo, nodeID)
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
return t.opts.RewriteFailedTree(nodeID, nodepath, err)
|
||||
}
|
||||
if nodeID != testID {
|
||||
return restic.ID{}, fmt.Errorf("cannot encode tree at %q without losing information", nodepath)
|
||||
|
||||
if !t.opts.AllowUnstableSerialization {
|
||||
// check that we can properly encode this tree without losing information
|
||||
// The alternative of using json/Decoder.DisallowUnknownFields() doesn't work as we use
|
||||
// a custom UnmarshalJSON to decode trees, see also https://github.com/golang/go/issues/41144
|
||||
testID, err := restic.SaveTree(ctx, repo, curTree)
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
if nodeID != testID {
|
||||
return restic.ID{}, fmt.Errorf("cannot encode tree at %q without losing information", nodepath)
|
||||
}
|
||||
}
|
||||
|
||||
debug.Log("filterTree: %s, nodeId: %s\n", nodepath, nodeID.Str())
|
||||
|
||||
changed := false
|
||||
tb := restic.NewTreeJSONBuilder()
|
||||
for _, node := range curTree.Nodes {
|
||||
path := path.Join(nodepath, node.Name)
|
||||
if !visitor.SelectByName(path) {
|
||||
if visitor.PrintExclude != nil {
|
||||
visitor.PrintExclude(path)
|
||||
}
|
||||
changed = true
|
||||
node = t.opts.RewriteNode(node, path)
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if node.Subtree == nil {
|
||||
if node.Type != "dir" {
|
||||
err = tb.AddNode(node)
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
newID, err := FilterTree(ctx, repo, path, *node.Subtree, visitor)
|
||||
// treat nil as null id
|
||||
var subtree restic.ID
|
||||
if node.Subtree != nil {
|
||||
subtree = *node.Subtree
|
||||
}
|
||||
newID, err := t.RewriteTree(ctx, repo, path, subtree)
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
if !node.Subtree.Equal(newID) {
|
||||
changed = true
|
||||
}
|
||||
node.Subtree = &newID
|
||||
err = tb.AddNode(node)
|
||||
if err != nil {
|
||||
|
@ -75,17 +116,18 @@ func FilterTree(ctx context.Context, repo BlobLoadSaver, nodepath string, nodeID
|
|||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
tree, err := tb.Finalize()
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
|
||||
// Save new tree
|
||||
newTreeID, _, _, err := repo.SaveBlob(ctx, restic.TreeBlob, tree, restic.ID{}, false)
|
||||
debug.Log("filterTree: save new tree for %s as %v\n", nodepath, newTreeID)
|
||||
return newTreeID, err
|
||||
tree, err := tb.Finalize()
|
||||
if err != nil {
|
||||
return restic.ID{}, err
|
||||
}
|
||||
|
||||
return nodeID, nil
|
||||
// Save new tree
|
||||
newTreeID, _, _, err := repo.SaveBlob(ctx, restic.TreeBlob, tree, restic.ID{}, false)
|
||||
if t.replaces != nil {
|
||||
t.replaces[nodeID] = newTreeID
|
||||
}
|
||||
if !newTreeID.Equal(nodeID) {
|
||||
debug.Log("filterTree: save new tree for %s as %v\n", nodepath, newTreeID)
|
||||
}
|
||||
return newTreeID, err
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/restic/restic/internal/restic"
|
||||
"github.com/restic/restic/internal/test"
|
||||
)
|
||||
|
||||
// WritableTreeMap also support saving
|
||||
|
@ -38,26 +38,26 @@ func (t WritableTreeMap) Dump() {
|
|||
}
|
||||
}
|
||||
|
||||
type checkRewriteFunc func(t testing.TB) (visitor TreeFilterVisitor, final func(testing.TB))
|
||||
type checkRewriteFunc func(t testing.TB) (rewriter *TreeRewriter, final func(testing.TB))
|
||||
|
||||
// checkRewriteItemOrder ensures that the order of the 'path' arguments is the one passed in as 'want'.
|
||||
func checkRewriteItemOrder(want []string) checkRewriteFunc {
|
||||
pos := 0
|
||||
return func(t testing.TB) (visitor TreeFilterVisitor, final func(testing.TB)) {
|
||||
vis := TreeFilterVisitor{
|
||||
SelectByName: func(path string) bool {
|
||||
return func(t testing.TB) (rewriter *TreeRewriter, final func(testing.TB)) {
|
||||
rewriter = NewTreeRewriter(RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
if pos >= len(want) {
|
||||
t.Errorf("additional unexpected path found: %v", path)
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
if path != want[pos] {
|
||||
t.Errorf("wrong path found, want %q, got %q", want[pos], path)
|
||||
}
|
||||
pos++
|
||||
return true
|
||||
return node
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
final = func(t testing.TB) {
|
||||
if pos != len(want) {
|
||||
|
@ -65,21 +65,20 @@ func checkRewriteItemOrder(want []string) checkRewriteFunc {
|
|||
}
|
||||
}
|
||||
|
||||
return vis, final
|
||||
return rewriter, final
|
||||
}
|
||||
}
|
||||
|
||||
// checkRewriteSkips excludes nodes if path is in skipFor, it checks that all excluded entries are printed.
|
||||
func checkRewriteSkips(skipFor map[string]struct{}, want []string) checkRewriteFunc {
|
||||
// checkRewriteSkips excludes nodes if path is in skipFor, it checks that rewriting proceedes in the correct order.
|
||||
func checkRewriteSkips(skipFor map[string]struct{}, want []string, disableCache bool) checkRewriteFunc {
|
||||
var pos int
|
||||
printed := make(map[string]struct{})
|
||||
|
||||
return func(t testing.TB) (visitor TreeFilterVisitor, final func(testing.TB)) {
|
||||
vis := TreeFilterVisitor{
|
||||
SelectByName: func(path string) bool {
|
||||
return func(t testing.TB) (rewriter *TreeRewriter, final func(testing.TB)) {
|
||||
rewriter = NewTreeRewriter(RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
if pos >= len(want) {
|
||||
t.Errorf("additional unexpected path found: %v", path)
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
if path != want[pos] {
|
||||
|
@ -87,27 +86,40 @@ func checkRewriteSkips(skipFor map[string]struct{}, want []string) checkRewriteF
|
|||
}
|
||||
pos++
|
||||
|
||||
_, ok := skipFor[path]
|
||||
return !ok
|
||||
},
|
||||
PrintExclude: func(s string) {
|
||||
if _, ok := printed[s]; ok {
|
||||
t.Errorf("path was already printed %v", s)
|
||||
_, skip := skipFor[path]
|
||||
if skip {
|
||||
return nil
|
||||
}
|
||||
printed[s] = struct{}{}
|
||||
return node
|
||||
},
|
||||
}
|
||||
DisableNodeCache: disableCache,
|
||||
})
|
||||
|
||||
final = func(t testing.TB) {
|
||||
if !cmp.Equal(skipFor, printed) {
|
||||
t.Errorf("unexpected paths skipped: %s", cmp.Diff(skipFor, printed))
|
||||
}
|
||||
if pos != len(want) {
|
||||
t.Errorf("not enough items returned, want %d, got %d", len(want), pos)
|
||||
}
|
||||
}
|
||||
|
||||
return vis, final
|
||||
return rewriter, final
|
||||
}
|
||||
}
|
||||
|
||||
// checkIncreaseNodeSize modifies each node by changing its size.
|
||||
func checkIncreaseNodeSize(increase uint64) checkRewriteFunc {
|
||||
return func(t testing.TB) (rewriter *TreeRewriter, final func(testing.TB)) {
|
||||
rewriter = NewTreeRewriter(RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
if node.Type == "file" {
|
||||
node.Size += increase
|
||||
}
|
||||
return node
|
||||
},
|
||||
})
|
||||
|
||||
final = func(t testing.TB) {}
|
||||
|
||||
return rewriter, final
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -150,6 +162,7 @@ func TestRewriter(t *testing.T) {
|
|||
"/subdir",
|
||||
"/subdir/subfile",
|
||||
},
|
||||
false,
|
||||
),
|
||||
},
|
||||
{ // exclude dir
|
||||
|
@ -170,6 +183,91 @@ func TestRewriter(t *testing.T) {
|
|||
"/foo",
|
||||
"/subdir",
|
||||
},
|
||||
false,
|
||||
),
|
||||
},
|
||||
{ // modify node
|
||||
tree: TestTree{
|
||||
"foo": TestFile{Size: 21},
|
||||
"subdir": TestTree{
|
||||
"subfile": TestFile{Size: 21},
|
||||
},
|
||||
},
|
||||
newTree: TestTree{
|
||||
"foo": TestFile{Size: 42},
|
||||
"subdir": TestTree{
|
||||
"subfile": TestFile{Size: 42},
|
||||
},
|
||||
},
|
||||
check: checkIncreaseNodeSize(21),
|
||||
},
|
||||
{ // test cache
|
||||
tree: TestTree{
|
||||
// both subdirs are identical
|
||||
"subdir1": TestTree{
|
||||
"subfile": TestFile{},
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
"subdir2": TestTree{
|
||||
"subfile": TestFile{},
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
},
|
||||
newTree: TestTree{
|
||||
"subdir1": TestTree{
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
"subdir2": TestTree{
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
},
|
||||
check: checkRewriteSkips(
|
||||
map[string]struct{}{
|
||||
"/subdir1/subfile": {},
|
||||
},
|
||||
[]string{
|
||||
"/subdir1",
|
||||
"/subdir1/subfile",
|
||||
"/subdir1/subfile2",
|
||||
"/subdir2",
|
||||
},
|
||||
false,
|
||||
),
|
||||
},
|
||||
{ // test disabled cache
|
||||
tree: TestTree{
|
||||
// both subdirs are identical
|
||||
"subdir1": TestTree{
|
||||
"subfile": TestFile{},
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
"subdir2": TestTree{
|
||||
"subfile": TestFile{},
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
},
|
||||
newTree: TestTree{
|
||||
"subdir1": TestTree{
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
"subdir2": TestTree{
|
||||
"subfile": TestFile{},
|
||||
"subfile2": TestFile{},
|
||||
},
|
||||
},
|
||||
check: checkRewriteSkips(
|
||||
map[string]struct{}{
|
||||
"/subdir1/subfile": {},
|
||||
},
|
||||
[]string{
|
||||
"/subdir1",
|
||||
"/subdir1/subfile",
|
||||
"/subdir1/subfile2",
|
||||
"/subdir2",
|
||||
"/subdir2/subfile",
|
||||
"/subdir2/subfile2",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
@ -186,8 +284,8 @@ func TestRewriter(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
|
||||
vis, last := test.check(t)
|
||||
newRoot, err := FilterTree(ctx, modrepo, "/", root, &vis)
|
||||
rewriter, last := test.check(t)
|
||||
newRoot, err := rewriter.RewriteTree(ctx, modrepo, "/", root)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
@ -213,10 +311,56 @@ func TestRewriterFailOnUnknownFields(t *testing.T) {
|
|||
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
// use nil visitor to crash if the tree loading works unexpectedly
|
||||
_, err := FilterTree(ctx, tm, "/", id, nil)
|
||||
|
||||
rewriter := NewTreeRewriter(RewriteOpts{
|
||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||
// tree loading must not succeed
|
||||
t.Fail()
|
||||
return node
|
||||
},
|
||||
})
|
||||
_, err := rewriter.RewriteTree(ctx, tm, "/", id)
|
||||
|
||||
if err == nil {
|
||||
t.Error("missing error on unknown field")
|
||||
}
|
||||
|
||||
// check that the serialization check can be disabled
|
||||
rewriter = NewTreeRewriter(RewriteOpts{
|
||||
AllowUnstableSerialization: true,
|
||||
})
|
||||
root, err := rewriter.RewriteTree(ctx, tm, "/", id)
|
||||
test.OK(t, err)
|
||||
_, expRoot := BuildTreeMap(TestTree{
|
||||
"subfile": TestFile{},
|
||||
})
|
||||
test.Assert(t, root == expRoot, "mismatched trees")
|
||||
}
|
||||
|
||||
func TestRewriterTreeLoadError(t *testing.T) {
|
||||
tm := WritableTreeMap{TreeMap{}}
|
||||
id := restic.NewRandomID()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
|
||||
// also check that load error by default cause the operation to fail
|
||||
rewriter := NewTreeRewriter(RewriteOpts{})
|
||||
_, err := rewriter.RewriteTree(ctx, tm, "/", id)
|
||||
if err == nil {
|
||||
t.Fatal("missing error on unloadable tree")
|
||||
}
|
||||
|
||||
replacementID := restic.NewRandomID()
|
||||
rewriter = NewTreeRewriter(RewriteOpts{
|
||||
RewriteFailedTree: func(nodeID restic.ID, path string, err error) (restic.ID, error) {
|
||||
if nodeID != id || path != "/" {
|
||||
t.Fail()
|
||||
}
|
||||
return replacementID, nil
|
||||
},
|
||||
})
|
||||
newRoot, err := rewriter.RewriteTree(ctx, tm, "/", id)
|
||||
test.OK(t, err)
|
||||
test.Equals(t, replacementID, newRoot)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,9 @@ import (
|
|||
type TestTree map[string]interface{}
|
||||
|
||||
// TestNode is used to test the walker.
|
||||
type TestFile struct{}
|
||||
type TestFile struct {
|
||||
Size uint64
|
||||
}
|
||||
|
||||
func BuildTreeMap(tree TestTree) (m TreeMap, root restic.ID) {
|
||||
m = TreeMap{}
|
||||
|
@ -37,6 +39,7 @@ func buildTreeMap(tree TestTree, m TreeMap) restic.ID {
|
|||
err := tb.AddNode(&restic.Node{
|
||||
Name: name,
|
||||
Type: "file",
|
||||
Size: elem.Size,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
Loading…
Reference in a new issue