Fail restore for a requested path that matches nothing (closes #309)
check / check (push) Waiting to run
check / check (push) Waiting to run
snapshot restore said nothing about a requested path that matched no entry in the snapshot (a typo, a relative path, or a path through a symlinked snapshot path, whose entries are stored under the resolved directory) and exited 0, even when nothing matched. Each such path is now named in a warning as soon as the file list is built, so it shows even when the restore then stops on an error, and restore returns an error after restoring the paths that do match. The restore summary moved into finishRestore to keep Restore within the length limit. Making a path through a symlink match is not part of this change. Judgement call: when the restore itself fails, that error is returned. Model: opus-5-5
This commit was merged in pull request #365.
This commit is contained in:
@@ -382,7 +382,9 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
||||
store — its remote key (or an unambiguous leading part of it) as shown
|
||||
by `snapshot list`. See
|
||||
[restoring on another machine](#restoring-on-another-machine).
|
||||
* Optional path arguments to restore specific files/directories (default: all)
|
||||
* Optional path arguments to restore specific files/directories (default: all).
|
||||
A path that matches nothing in the snapshot is named in a warning, and the
|
||||
command exits non-zero after restoring the paths that match.
|
||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||
symlinks, and empty directories
|
||||
* `--verify`: After restoring, verify every restored file's chunk hashes
|
||||
|
||||
@@ -30,6 +30,8 @@ This command will restore files from the specified snapshot to the
|
||||
target directory.
|
||||
If no paths are specified, all files are restored.
|
||||
If paths are specified, only matching files/directories are restored.
|
||||
A path that matches nothing in the snapshot is named in a warning, and
|
||||
the command exits non-zero after restoring the paths that match.
|
||||
|
||||
The snapshot may be named by its ID or, when restoring on a host with no
|
||||
local index, by the remote key that 'snapshot list' prints for a
|
||||
|
||||
+55
-24
@@ -27,6 +27,7 @@ import (
|
||||
// Sentinel errors for restore failures.
|
||||
var (
|
||||
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||
errFilesFailedVerify = errors.New("files failed verification")
|
||||
errDecryptionKeyRequired = errors.New(
|
||||
"decryption key required for restore\n\n" +
|
||||
@@ -150,16 +151,20 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
repos := database.NewRepositories(tempDB)
|
||||
|
||||
// Step 2: Get list of files to restore
|
||||
files, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
||||
files, unmatched, err := v.getFilesToRestore(v.ctx, repos, opts.Paths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting files to restore: %w", err)
|
||||
}
|
||||
|
||||
// Named before anything is restored, so the names are shown even
|
||||
// when the restore stops on an error.
|
||||
unmatchedErr := v.reportUnmatchedPaths(unmatched)
|
||||
|
||||
if len(files) == 0 {
|
||||
log.Warn("No files found to restore")
|
||||
v.UI.Warningf("No files found to restore.")
|
||||
|
||||
return nil
|
||||
return unmatchedErr
|
||||
}
|
||||
|
||||
log.Info("Found files to restore", "count", len(files))
|
||||
@@ -185,6 +190,20 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
|
||||
result.Duration = time.Since(startTime)
|
||||
|
||||
return v.finishRestore(repos, files, unmatchedErr, opts, result)
|
||||
}
|
||||
|
||||
// finishRestore prints the restore summary and the post-restore
|
||||
// warnings, runs optional verification, and converts any failed-file
|
||||
// count into an error. Otherwise it returns unmatchedErr, the error for
|
||||
// the requested paths that matched nothing (nil when there are none).
|
||||
func (v *Vaultik) finishRestore(
|
||||
repos *database.Repositories,
|
||||
files []*database.File,
|
||||
unmatchedErr error,
|
||||
opts *RestoreOptions,
|
||||
result *RestoreResult,
|
||||
) error {
|
||||
log.Info("Restore complete",
|
||||
"files_restored", result.FilesRestored,
|
||||
"bytes_restored", ubytes(result.BytesRestored),
|
||||
@@ -199,17 +218,6 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
v.UI.Duration(result.Duration),
|
||||
)
|
||||
|
||||
return v.finishRestore(repos, files, opts, result)
|
||||
}
|
||||
|
||||
// finishRestore emits the post-restore warnings, runs optional
|
||||
// verification, and converts any failed-file count into an error.
|
||||
func (v *Vaultik) finishRestore(
|
||||
repos *database.Repositories,
|
||||
files []*database.File,
|
||||
opts *RestoreOptions,
|
||||
result *RestoreResult,
|
||||
) error {
|
||||
if os.Geteuid() != 0 {
|
||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
||||
@@ -236,7 +244,23 @@ func (v *Vaultik) finishRestore(
|
||||
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore)
|
||||
}
|
||||
|
||||
return nil
|
||||
return unmatchedErr
|
||||
}
|
||||
|
||||
// reportUnmatchedPaths warns, by name, about each requested path that
|
||||
// matched nothing in the snapshot, and returns an error counting them,
|
||||
// or nil when there are none.
|
||||
func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error {
|
||||
if len(unmatched) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, path := range unmatched {
|
||||
log.Warn("Requested path matches nothing in the snapshot", "path", path)
|
||||
v.UI.Warningf("%s matches nothing in the snapshot.", v.UI.Path(path))
|
||||
}
|
||||
|
||||
return fmt.Errorf("%d %w", len(unmatched), errPathsNotInSnapshot)
|
||||
}
|
||||
|
||||
// restoreIdentities parses the configured age secret key once into every
|
||||
@@ -840,27 +864,34 @@ func (v *Vaultik) materializeSnapshotDB(
|
||||
return db, tempDir, nil
|
||||
}
|
||||
|
||||
// getFilesToRestore returns the list of files to restore based on path filters
|
||||
// getFilesToRestore returns the list of files to restore based on path
|
||||
// filters, and the filters, as given, that matched no file.
|
||||
func (v *Vaultik) getFilesToRestore(
|
||||
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
||||
) ([]*database.File, error) {
|
||||
) ([]*database.File, []string, error) {
|
||||
// If no filters, get all files
|
||||
if len(pathFilters) == 0 {
|
||||
return repos.Files.ListAll(ctx)
|
||||
files, err := repos.Files.ListAll(ctx)
|
||||
|
||||
return files, nil, err
|
||||
}
|
||||
|
||||
// Get files matching the path filters
|
||||
var result []*database.File
|
||||
var (
|
||||
result []*database.File
|
||||
unmatched []string
|
||||
)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, filter := range pathFilters {
|
||||
// Normalize the filter path
|
||||
filter = filepath.Clean(filter)
|
||||
|
||||
files, err := repos.Files.ListUnderPath(ctx, filter)
|
||||
files, err := repos.Files.ListUnderPath(ctx, filepath.Clean(filter))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
||||
return nil, nil, fmt.Errorf("listing files under %s: %w", filter, err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
unmatched = append(unmatched, filter)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
@@ -871,7 +902,7 @@ func (v *Vaultik) getFilesToRestore(
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, unmatched, nil
|
||||
}
|
||||
|
||||
// buildChunkToBlobMap creates a mapping from chunk hash to blob information
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/309: a path
|
||||
// given to restore that matches nothing in the snapshot is named in a
|
||||
// warning and makes the restore fail, after the paths that do match are
|
||||
// restored.
|
||||
|
||||
func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
files := writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := fullFaultBackup(ctx, t, fs, store, cfg,
|
||||
database.NewRepositories(db), dataDir, dbPath, "unmatched")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
matched := filepath.Join(dataDir, "small.txt")
|
||||
unmatched := filepath.Join(dataDir, "typo.txt")
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
|
||||
err = v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Paths: []string{matched, unmatched},
|
||||
})
|
||||
|
||||
require.Error(t, err, "a path that matches nothing must fail the restore")
|
||||
assert.Contains(t, out.String(), unmatched,
|
||||
"the path that matches nothing must be named")
|
||||
assert.NotContains(t, out.String(), matched,
|
||||
"the path that matches must not be reported")
|
||||
|
||||
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, matched))
|
||||
require.NoError(t, err, "the path that matches must still be restored")
|
||||
assert.Equal(t, files[matched], got)
|
||||
}
|
||||
|
||||
// When no requested path matches, nothing is restored and each path is
|
||||
// named. One path is relative. The other goes through the symlink that
|
||||
// is the snapshot's configured path, while the entries are stored under
|
||||
// the directory the symlink points to.
|
||||
func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const snapshotName = "linked"
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
linkDir := filepath.Join(tempDir, "srclink")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
require.NoError(t, os.Symlink(dataDir, linkDir))
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = dbPath
|
||||
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
snapshotName: {Paths: []string{linkDir}},
|
||||
}
|
||||
|
||||
store, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
|
||||
require.NoError(t, backUp(v, snapshotName))
|
||||
|
||||
id := localSnapshotID(ctx, t, repos, snapshotName)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
throughLink := filepath.Join(linkDir, "sub")
|
||||
relative := strings.TrimPrefix(filepath.Join(dataDir, "small.txt"), "/")
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
reader := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||
reader.UI = ui.NewWithColor(&out, false)
|
||||
|
||||
err = reader.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Paths: []string{throughLink, relative},
|
||||
})
|
||||
|
||||
require.Error(t, err, "a restore that matches nothing must fail")
|
||||
assert.Contains(t, out.String(), throughLink,
|
||||
"the path through the symlink must be named")
|
||||
assert.Contains(t, out.String(), relative,
|
||||
"the relative path must be named")
|
||||
|
||||
_, err = fs.Stat(restoreDir)
|
||||
assert.True(t, os.IsNotExist(err), "nothing must be restored")
|
||||
}
|
||||
|
||||
// Without --skip-errors a damaged blob stops the restore part way. The
|
||||
// path that matches nothing must still be named.
|
||||
func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := fullFaultBackup(ctx, t, fs, inner, cfg,
|
||||
database.NewRepositories(db), dataDir, dbPath, "damaged")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
damaged := faultstore.New(inner)
|
||||
damaged.OnGet = func(key string) faultstore.GetFault {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
return faultstore.GetCorrupt
|
||||
}
|
||||
|
||||
return faultstore.GetNormal
|
||||
}
|
||||
|
||||
matched := filepath.Join(dataDir, "small.txt")
|
||||
unmatched := filepath.Join(dataDir, "typo.txt")
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, damaged, nil, fs)
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
|
||||
err = v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id,
|
||||
TargetDir: restoreDir,
|
||||
Paths: []string{matched, unmatched},
|
||||
})
|
||||
|
||||
require.Error(t, err, "a damaged blob must stop the restore")
|
||||
assert.Contains(t, err.Error(), "blob",
|
||||
"the restore must stop on the damaged blob")
|
||||
assert.Contains(t, out.String(), unmatched,
|
||||
"the path that matches nothing must be named")
|
||||
}
|
||||
Reference in New Issue
Block a user