Compare commits
3
Commits
cc69561b3e
...
1723961380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1723961380 | ||
|
|
d8f78a1f45 | ||
|
|
2c087be577 |
@@ -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
|
store — its remote key (or an unambiguous leading part of it) as shown
|
||||||
by `snapshot list`. See
|
by `snapshot list`. See
|
||||||
[restoring on another machine](#restoring-on-another-machine).
|
[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),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
* `--verify`: After restoring, verify every restored file's chunk hashes
|
* `--verify`: After restoring, verify every restored file's chunk hashes
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Words and flags of `snapshot purge` used to build argument vectors
|
||||||
|
// below.
|
||||||
|
const (
|
||||||
|
cmdPurge = "purge"
|
||||||
|
flagOlderThan = "--older-than"
|
||||||
|
flagForce = "--force"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
|
// the local index refuse to delete an old snapshot and checks that
|
||||||
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
|
// the destination store. It used to log the error, delete the metadata
|
||||||
|
// and exit 0.
|
||||||
|
//
|
||||||
|
// `snapshot create --prune` runs the same purge, but cannot be driven
|
||||||
|
// this way: the refusal also breaks the backup's metadata export, which
|
||||||
|
// deletes the other snapshots from its copy of the local index.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
||||||
|
// only; the manifest planted here puts it on the destination store
|
||||||
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ This command will restore files from the specified snapshot to the
|
|||||||
target directory.
|
target directory.
|
||||||
If no paths are specified, all files are restored.
|
If no paths are specified, all files are restored.
|
||||||
If paths are specified, only matching files/directories 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
|
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
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
|
|||||||
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"**/ file pattern should match at the top level and nested")
|
"**/ file pattern should match at the top level and nested")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, /**/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// /**/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"/**/ pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||||
|
// is a glob: with a plain name the glob library already matched a/b.
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||||
|
// Total files: 3, excluded: 2
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
pattern = strings.TrimSuffix(pattern, "/")
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
// A leading "**/" demands a "/" before the name and so misses an
|
// A leading "**/" demands a "/" before the name and so misses an
|
||||||
// entry directly under the source path. An unanchored pattern
|
// entry directly under the source path. It means "at every depth",
|
||||||
// already matches at every depth, so drop the prefix: as in
|
// which is what an unanchored pattern already does, so drop the
|
||||||
// gitignore, "**/x" then also matches a top-level "x".
|
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||||
if !anchored {
|
// "/**/x" then also match a top-level "x".
|
||||||
pattern = strings.TrimPrefix(pattern, "**/")
|
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||||
|
pattern = rest
|
||||||
|
anchored = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the glob pattern
|
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||||
// For patterns without path separators, we need to match them as components
|
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||||
// e.g., ".git" should match ".git" anywhere in the path
|
// zero or more directories, so also compile the pattern with it
|
||||||
g, err := glob.Compile(pattern, '/')
|
// made a single "/". The glob library's {a,b} is no help: with a
|
||||||
if err != nil {
|
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
for _, variant := range doubleStarVariants(pattern) {
|
||||||
|
// For patterns without path separators, we need to match them as components
|
||||||
|
// e.g., ".git" should match ".git" anywhere in the path
|
||||||
|
g, err := glob.Compile(variant, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||||
|
|
||||||
continue
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledPattern{
|
||||||
|
pattern: g,
|
||||||
|
anchored: anchored,
|
||||||
|
original: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
|
||||||
pattern: g,
|
|
||||||
anchored: anchored,
|
|
||||||
original: p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleStarVariants returns pattern once for every way of keeping each
|
||||||
|
// "/**/" in it or making it a single "/".
|
||||||
|
func doubleStarVariants(pattern string) []string {
|
||||||
|
const doubleStar = "/**/"
|
||||||
|
|
||||||
|
before, after, found := strings.Cut(pattern, doubleStar)
|
||||||
|
if !found {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
|
||||||
|
var variants []string
|
||||||
|
|
||||||
|
for _, rest := range doubleStarVariants(after) {
|
||||||
|
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
// filePath is the full path to the file
|
// filePath is the full path to the file
|
||||||
// rootPath is the root of the backup source directory
|
// rootPath is the root of the backup source directory
|
||||||
|
|||||||
+55
-24
@@ -27,6 +27,7 @@ import (
|
|||||||
// Sentinel errors for restore failures.
|
// Sentinel errors for restore failures.
|
||||||
var (
|
var (
|
||||||
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
errFilesFailedRestore = errors.New("file(s) failed to restore")
|
||||||
|
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||||
errFilesFailedVerify = errors.New("files failed verification")
|
errFilesFailedVerify = errors.New("files failed verification")
|
||||||
errDecryptionKeyRequired = errors.New(
|
errDecryptionKeyRequired = errors.New(
|
||||||
"decryption key required for restore\n\n" +
|
"decryption key required for restore\n\n" +
|
||||||
@@ -150,16 +151,20 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
repos := database.NewRepositories(tempDB)
|
repos := database.NewRepositories(tempDB)
|
||||||
|
|
||||||
// Step 2: Get list of files to restore
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("getting files to restore: %w", err)
|
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 {
|
if len(files) == 0 {
|
||||||
log.Warn("No files found to restore")
|
log.Warn("No files found to restore")
|
||||||
v.UI.Warningf("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))
|
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)
|
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",
|
log.Info("Restore complete",
|
||||||
"files_restored", result.FilesRestored,
|
"files_restored", result.FilesRestored,
|
||||||
"bytes_restored", ubytes(result.BytesRestored),
|
"bytes_restored", ubytes(result.BytesRestored),
|
||||||
@@ -199,17 +218,6 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
v.UI.Duration(result.Duration),
|
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 {
|
if os.Geteuid() != 0 {
|
||||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
"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 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
|
// restoreIdentities parses the configured age secret key once into every
|
||||||
@@ -840,27 +864,34 @@ func (v *Vaultik) materializeSnapshotDB(
|
|||||||
return db, tempDir, nil
|
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(
|
func (v *Vaultik) getFilesToRestore(
|
||||||
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
ctx context.Context, repos *database.Repositories, pathFilters []string,
|
||||||
) ([]*database.File, error) {
|
) ([]*database.File, []string, error) {
|
||||||
// If no filters, get all files
|
// If no filters, get all files
|
||||||
if len(pathFilters) == 0 {
|
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
|
// Get files matching the path filters
|
||||||
var result []*database.File
|
var (
|
||||||
|
result []*database.File
|
||||||
|
unmatched []string
|
||||||
|
)
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, filter := range pathFilters {
|
for _, filter := range pathFilters {
|
||||||
// Normalize the filter path
|
files, err := repos.Files.ListUnderPath(ctx, filepath.Clean(filter))
|
||||||
filter = filepath.Clean(filter)
|
|
||||||
|
|
||||||
files, err := repos.Files.ListUnderPath(ctx, filter)
|
|
||||||
if err != nil {
|
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 {
|
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
|
// 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")
|
||||||
|
}
|
||||||
@@ -669,10 +669,11 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
snapshotID := snap.ID.String()
|
snapshotID := snap.ID.String()
|
||||||
log.Info("Deleting snapshot", "id", snapshotID)
|
log.Info("Deleting snapshot", "id", snapshotID)
|
||||||
|
|
||||||
|
// Stop before the remote delete, so a snapshot still in the local
|
||||||
|
// index keeps its metadata on the destination store.
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to delete from local database",
|
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
|||||||
Reference in New Issue
Block a user