Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0075c76c |
@@ -375,9 +375,7 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
* 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 file's chunk hashes match
|
||||||
match. A file skipped under `--skip-errors` is reported as failed to
|
|
||||||
restore and is not verified.
|
|
||||||
|
|
||||||
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
||||||
(1) reconcile the local index against the destination store — any
|
(1) reconcile the local index against the destination store — any
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,22 +61,24 @@ func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
rollbackErr := tx.Rollback()
|
// The panic is the failure to report, so a rollback error
|
||||||
if rollbackErr != nil {
|
// is dropped.
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
_ = tx.Rollback()
|
||||||
}
|
|
||||||
|
|
||||||
panic(p)
|
panic(p)
|
||||||
} else if err != nil {
|
|
||||||
rollbackErr := tx.Rollback()
|
|
||||||
if rollbackErr != nil {
|
|
||||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = fn(ctx, tx)
|
err = fn(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Once ctx is cancelled, database/sql rolls the transaction
|
||||||
|
// back itself and Rollback returns sql.ErrTxDone.
|
||||||
|
rollbackErr := tx.Rollback()
|
||||||
|
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||||
|
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||||
|
err, rollbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -235,6 +236,125 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the context is cancelled during the body, database/sql rolls the
|
||||||
|
// transaction back itself, so WithTx's own rollback finds it already
|
||||||
|
// done. WithTx must return the body's error alone, without that rollback.
|
||||||
|
func TestRepositoriesTransactionContextCancelled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
file := &database.File{
|
||||||
|
Path: "/test/cancelled_file.txt",
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Size: 1024,
|
||||||
|
Mode: 0644,
|
||||||
|
UID: 1000,
|
||||||
|
GID: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repos.Files.Create(ctx, tx, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// database/sql rolls back from a goroutine of its own; wait
|
||||||
|
// until it has, so the test does not depend on that race.
|
||||||
|
for {
|
||||||
|
_, err = tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
t.Fatalf("the already-done rollback was reported: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := repos.Files.GetByPath(
|
||||||
|
context.Background(), "/test/cancelled_file.txt",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error checking for file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
t.Error("file should not exist after the cancelled transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body that ends the transaction itself with ROLLBACK makes WithTx's
|
||||||
|
// own rollback fail. WithTx must return that failure together with the
|
||||||
|
// body's error.
|
||||||
|
func TestRepositoriesTransactionRollbackFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return errIntentionalRollback
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errIntentionalRollback) {
|
||||||
|
t.Fatalf("expected the body's error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "rolling back transaction") {
|
||||||
|
t.Errorf("expected the failed rollback in the error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the body ends the transaction itself with ROLLBACK and then
|
||||||
|
// panics, WithTx's own rollback fails; the panic must still reach the
|
||||||
|
// caller.
|
||||||
|
func TestRepositoriesTransactionPanicRollbackFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected the body's panic to reach the caller")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(errIntentionalRollback)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepositoriesReadTransaction(t *testing.T) {
|
func TestRepositoriesReadTransaction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+12
-27
@@ -103,12 +103,11 @@ type RestoreResult struct {
|
|||||||
BlobsDownloaded int
|
BlobsDownloaded int
|
||||||
BytesDownloaded int64
|
BytesDownloaded int64
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
FilesFailed int
|
|
||||||
FailedFiles []string // Paths of files that failed to restore
|
|
||||||
// Verification results (only populated if Verify option is set)
|
// Verification results (only populated if Verify option is set)
|
||||||
FilesVerified int
|
FilesVerified int
|
||||||
BytesVerified int64
|
BytesVerified int64
|
||||||
VerifyFailedFiles []string // Paths of restored files that failed verification
|
FilesFailed int
|
||||||
|
FailedFiles []string // Paths of files that failed verification
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore restores files from a snapshot to the target directory
|
// Restore restores files from a snapshot to the target directory
|
||||||
@@ -648,34 +647,20 @@ func (v *Vaultik) handleRestoreVerification(
|
|||||||
opts *RestoreOptions,
|
opts *RestoreOptions,
|
||||||
result *RestoreResult,
|
result *RestoreResult,
|
||||||
) error {
|
) error {
|
||||||
// A file that failed to restore was not written and is already
|
err := v.verifyRestoredFiles(v.ctx, repos, files, opts.TargetDir, result)
|
||||||
// reported as such, so it is not verified.
|
|
||||||
failedToRestore := make(map[string]bool, len(result.FailedFiles))
|
|
||||||
for _, path := range result.FailedFiles {
|
|
||||||
failedToRestore[path] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
restored := make([]*database.File, 0, len(files))
|
|
||||||
for _, file := range files {
|
|
||||||
if !failedToRestore[file.Path.String()] {
|
|
||||||
restored = append(restored, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := v.verifyRestoredFiles(v.ctx, repos, restored, opts.TargetDir, result)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("verification failed: %w", err)
|
return fmt.Errorf("verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.VerifyFailedFiles) > 0 {
|
if result.FilesFailed > 0 {
|
||||||
v.UI.Errorf("Verification failed: %s files did not match expected checksums.",
|
v.UI.Errorf("Verification failed: %s files did not match expected checksums.",
|
||||||
v.UI.Count(len(result.VerifyFailedFiles)))
|
v.UI.Count(result.FilesFailed))
|
||||||
|
|
||||||
for _, path := range result.VerifyFailedFiles {
|
for _, path := range result.FailedFiles {
|
||||||
v.UI.Detailf("%s", v.UI.Path(path))
|
v.UI.Detailf("%s", v.UI.Path(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("%d %w", len(result.VerifyFailedFiles), errFilesFailedVerify)
|
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedVerify)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Completef("Verified %s files (%s).",
|
v.UI.Completef("Verified %s files (%s).",
|
||||||
@@ -1435,8 +1420,8 @@ func (v *Vaultik) verifyRestoredFiles(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("File verification failed", "path", file.Path, "error", err)
|
log.Error("File verification failed", "path", file.Path, "error", err)
|
||||||
|
|
||||||
result.VerifyFailedFiles = append(
|
result.FilesFailed++
|
||||||
result.VerifyFailedFiles, file.Path.String())
|
result.FailedFiles = append(result.FailedFiles, file.Path.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
bytesProcessed += file.Size
|
bytesProcessed += file.Size
|
||||||
@@ -1452,7 +1437,7 @@ func (v *Vaultik) verifyRestoredFiles(
|
|||||||
log.Info("Verification complete",
|
log.Info("Verification complete",
|
||||||
"files_verified", result.FilesVerified,
|
"files_verified", result.FilesVerified,
|
||||||
"bytes_verified", ubytes(result.BytesVerified),
|
"bytes_verified", ubytes(result.BytesVerified),
|
||||||
"files_failed", len(result.VerifyFailedFiles),
|
"files_failed", result.FilesFailed,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -86,130 +84,6 @@ func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles restores with
|
|
||||||
// SkipErrors and Verify after one blob of a two-blob snapshot was
|
|
||||||
// deleted. Verification must check only the files that were written, so
|
|
||||||
// a file of the deleted blob is reported as failed to restore and not
|
|
||||||
// also as failing verification, and Restore must still return an error.
|
|
||||||
func TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
backup := backupThenDeleteOneBlob(ctx, t)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
err := v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: backup.snapshotID,
|
|
||||||
TargetDir: backup.restoreDir,
|
|
||||||
Verify: true,
|
|
||||||
SkipErrors: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err, "restore must fail when files were skipped")
|
|
||||||
assert.Contains(t, err.Error(),
|
|
||||||
fmt.Sprintf("%d file(s) failed to restore", len(backup.lost)))
|
|
||||||
|
|
||||||
written := len(backup.files) - len(backup.lost)
|
|
||||||
|
|
||||||
assert.Contains(t, out.String(), fmt.Sprintf("Verifying %d files", written))
|
|
||||||
assert.Contains(t, out.String(), fmt.Sprintf("Verified %d files", written))
|
|
||||||
assert.NotContains(t, out.String(), "Verification failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRestoreSkipErrorsVerifyReportsFailuresApart restores with
|
|
||||||
// SkipErrors and Verify after one blob of a two-blob snapshot was
|
|
||||||
// deleted, while one file that does restore is written one byte too long.
|
|
||||||
// The restore warning must list only the files of the deleted blob, the
|
|
||||||
// verification summary only the long file, each path once, and Restore
|
|
||||||
// must return an error.
|
|
||||||
func TestRestoreSkipErrorsVerifyReportsFailuresApart(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
backup := backupThenDeleteOneBlob(ctx, t)
|
|
||||||
|
|
||||||
var long string
|
|
||||||
|
|
||||||
for path := range backup.files {
|
|
||||||
if !backup.lost[path] {
|
|
||||||
long = path
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
fs := trailingByteFs{Fs: backup.fs, suffix: long}
|
|
||||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, fs)
|
|
||||||
v.UI = ui.NewWithColor(&out, false)
|
|
||||||
|
|
||||||
err := v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: backup.snapshotID,
|
|
||||||
TargetDir: backup.restoreDir,
|
|
||||||
Verify: true,
|
|
||||||
SkipErrors: true,
|
|
||||||
})
|
|
||||||
require.Error(t, err, "restore must fail when files failed")
|
|
||||||
|
|
||||||
_, afterWarning, found := strings.Cut(out.String(),
|
|
||||||
fmt.Sprintf("%d file(s) failed to restore:", len(backup.lost)))
|
|
||||||
require.Truef(t, found, "no restore warning in:\n%s", out.String())
|
|
||||||
|
|
||||||
warning, summary, found := strings.Cut(afterWarning,
|
|
||||||
"Verification failed: 1 files did not match")
|
|
||||||
require.Truef(t, found, "no verification summary in:\n%s", out.String())
|
|
||||||
|
|
||||||
for path := range backup.lost {
|
|
||||||
assert.Equalf(t, 1, strings.Count(warning, path),
|
|
||||||
"%s must be listed once as failed to restore", path)
|
|
||||||
assert.NotContainsf(t, summary, path,
|
|
||||||
"%s was not restored and must not fail verification", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NotContainsf(t, warning, long,
|
|
||||||
"%s was restored and must not be listed as failed to restore", long)
|
|
||||||
assert.Equalf(t, 1, strings.Count(summary, long),
|
|
||||||
"%s must be listed once as failing verification", long)
|
|
||||||
}
|
|
||||||
|
|
||||||
// trailingByteFs appends one byte, on close, to the file it opens whose
|
|
||||||
// path ends in suffix, so that file is restored one byte too long and
|
|
||||||
// fails verification.
|
|
||||||
type trailingByteFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
suffix string
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface
|
|
||||||
func (fs trailingByteFs) OpenFile(
|
|
||||||
name string, flag int, perm os.FileMode,
|
|
||||||
) (afero.File, error) {
|
|
||||||
f, err := fs.Fs.OpenFile(name, flag, perm)
|
|
||||||
if err != nil || !strings.HasSuffix(name, fs.suffix) {
|
|
||||||
return f, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return trailingByteFile{File: f}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type trailingByteFile struct {
|
|
||||||
afero.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f trailingByteFile) Close() error {
|
|
||||||
_, _ = f.File.Write([]byte{'x'})
|
|
||||||
|
|
||||||
return f.File.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||||
// still ends the restore with an error when SkipErrors is not set.
|
// still ends the restore with an error when SkipErrors is not set.
|
||||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user