Exit non-zero when snapshot create --skip-errors skips anything it cannot read (closes #310)
check / check (push) Waiting to run
check / check (push) Waiting to run
A backup under --skip-errors printed its skip errors and failed count, then "Finished successfully.", and exited 0, so a cron wrapper could not tell it from a complete backup. A directory the walk could not list was not counted at all. Each entry the walk skips is now counted as examined and failed. When --skip-errors skipped anything it could not read or list, the run keeps its snapshots, runs the post-backup prune as before, and returns an error the command reports as "Backup completed, but N file(s) could not be read". A file removed during the run, including one the walk finds gone after listing its directory, is counted once, as failed, and leaves the exit status at 0 with or without the flag; without it, such a removal no longer stops the backup. Model: opus-5-5
This commit is contained in:
@@ -182,7 +182,7 @@ vaultik version
|
|||||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
||||||
* `--debug`: Enable debug output (on stderr — see below)
|
* `--debug`: Enable debug output (on stderr — see below)
|
||||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run.
|
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run. A run that skipped anything still finishes but exits non-zero: `snapshot create` keeps the snapshot and counts each file or directory it skipped as failed. A file removed during the backup is also counted as failed but does not change the exit status.
|
||||||
|
|
||||||
### locking
|
### locking
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||||
|
// under --skip-errors, a source directory that does not exist, which the
|
||||||
|
// walk skips. The command exits non-zero and reports the backup as
|
||||||
|
// completed, not as a failed snapshot creation. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||||
|
// writeBackupConfig would create the source directory.
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, stdout, "Finished successfully.")
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
}
|
||||||
@@ -115,6 +115,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
||||||
|
log.Error("Backup completed with unreadable files", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -141,10 +141,12 @@ type ScannerConfig struct {
|
|||||||
// ScanResult contains the results of a scan operation. Files and bytes
|
// ScanResult contains the results of a scan operation. Files and bytes
|
||||||
// are counted per file: BytesScanned is the size of the new and changed
|
// are counted per file: BytesScanned is the size of the new and changed
|
||||||
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
||||||
// new and changed files that phase 2 could not store; FilesScanned
|
// new and changed files that phase 2 could not store, each entry removed
|
||||||
// includes them and BytesScanned does not. ChunksCreated counts the
|
// after the walk listed its directory and, under --skip-errors, each
|
||||||
// chunks that were not already stored, and NewChunkSize is their total
|
// entry the walk could not read, such as a directory it could not list;
|
||||||
// size.
|
// FilesScanned includes them and BytesScanned does not. ChunksCreated
|
||||||
|
// counts the chunks that were not already stored, and NewChunkSize is
|
||||||
|
// their total size.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
FilesScanned int
|
FilesScanned int
|
||||||
FilesSkipped int
|
FilesSkipped int
|
||||||
@@ -161,6 +163,11 @@ type ScanResult struct {
|
|||||||
UploadDuration time.Duration
|
UploadDuration time.Duration
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
|
|
||||||
|
// FilesUnreadable is the part of FilesFailed that --skip-errors
|
||||||
|
// skipped because it could not be read or listed; the rest are files
|
||||||
|
// removed after the walk listed them.
|
||||||
|
FilesUnreadable int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new scanner instance
|
// NewScanner creates a new scanner instance
|
||||||
@@ -882,8 +889,16 @@ func (s *Scanner) scanPhase(
|
|||||||
log.Debug("Starting directory walk", "path", path)
|
log.Debug("Starting directory walk", "path", path)
|
||||||
|
|
||||||
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
||||||
|
// The source path is in no directory listing, so its absence is
|
||||||
|
// an error rather than a removal.
|
||||||
|
if errors.Is(err, os.ErrNotExist) && filePath != path {
|
||||||
|
countRemovedDuringWalk(filePath, existingFiles, result)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.handleWalkError(filePath, err)
|
return s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
@@ -959,7 +974,7 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
file, err := s.buildSymlinkEntry(filePath, info)
|
file, err := s.buildSymlinkEntry(filePath, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, s.handleWalkError(filePath, err)
|
return true, s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
@@ -982,15 +997,38 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countRemovedDuringWalk counts an entry that was in its directory's
|
||||||
|
// listing but was gone when the walk looked it up, like a file removed
|
||||||
|
// before phase 2 reads it: as examined and failed, and not as an error,
|
||||||
|
// with or without --skip-errors.
|
||||||
|
func countRemovedDuringWalk(
|
||||||
|
filePath string, existingFiles map[string]struct{}, result *ScanResult,
|
||||||
|
) {
|
||||||
|
log.Warn("File was deleted during the directory walk, skipping",
|
||||||
|
"path", filePath)
|
||||||
|
|
||||||
|
// The walk found it, so it is not also counted as deleted.
|
||||||
|
existingFiles[filePath] = struct{}{}
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
}
|
||||||
|
|
||||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||||
// skipped (with a UI notice) under --skip-errors, fatal otherwise.
|
// under --skip-errors the entry is skipped with a UI notice and counted
|
||||||
func (s *Scanner) handleWalkError(filePath string, err error) error {
|
// as examined, failed and unreadable, otherwise the error is fatal.
|
||||||
|
func (s *Scanner) handleWalkError(
|
||||||
|
filePath string, err error, result *ScanResult,
|
||||||
|
) error {
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("Failed to access file (skipping due to --skip-errors)",
|
log.Error("Failed to access file (skipping due to --skip-errors)",
|
||||||
"path", filePath, "error", err)
|
"path", filePath, "error", err)
|
||||||
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
||||||
s.ui.Path(filePath), err)
|
s.ui.Path(filePath), err)
|
||||||
|
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,6 +1460,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
s.ui.Path(fileToProcess.Path), err)
|
s.ui.Path(fileToProcess.Path), err)
|
||||||
|
|
||||||
countFailedFile(fileToProcess, result)
|
countFailedFile(fileToProcess, result)
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ var (
|
|||||||
// caller can report it apart from a failed backup.
|
// caller can report it apart from a failed backup.
|
||||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||||
|
|
||||||
|
// ErrFilesUnreadable is returned when --skip-errors skipped files or
|
||||||
|
// directories it could not read or list. The snapshot is complete and
|
||||||
|
// kept, so the caller can report it apart from a failed backup.
|
||||||
|
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||||
|
|
||||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||||
// local index for listing and purge operations.
|
// local index for listing and purge operations.
|
||||||
const listRecentLimit = 10000
|
const listRecentLimit = 10000
|
||||||
@@ -130,12 +135,16 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process each named snapshot
|
// Process each named snapshot
|
||||||
|
filesUnreadable := 0
|
||||||
|
|
||||||
for snapIdx, snapName := range snapshotNames {
|
for snapIdx, snapName := range snapshotNames {
|
||||||
err = v.createNamedSnapshot(
|
unreadable, err := v.createNamedSnapshot(
|
||||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filesUnreadable += unreadable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print overall summary if multiple snapshots
|
// Print overall summary if multiple snapshots
|
||||||
@@ -151,6 +160,19 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return v.finishCreateSnapshot(filesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishCreateSnapshot ends a run whose snapshots are all complete and
|
||||||
|
// kept. Like finishRestore, it returns an error when --skip-errors
|
||||||
|
// skipped anything it could not read or list; otherwise it prints the
|
||||||
|
// closing line. A file removed during the run is counted as failed in
|
||||||
|
// the summary but is not an error, with or without --skip-errors.
|
||||||
|
func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
|
||||||
|
if filesUnreadable > 0 {
|
||||||
|
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
// Terminus must obey the --cron invariant: silent on total
|
// Terminus must obey the --cron invariant: silent on total
|
||||||
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
||||||
// the success path), but if any warnings fired during the run we
|
// the success path), but if any warnings fired during the run we
|
||||||
@@ -240,16 +262,22 @@ type snapshotStats struct {
|
|||||||
totalBlobsUploaded int
|
totalBlobsUploaded int
|
||||||
uploadDuration time.Duration
|
uploadDuration time.Duration
|
||||||
|
|
||||||
|
// The part of totalFilesFailed that --skip-errors skipped because it
|
||||||
|
// could not be read or listed.
|
||||||
|
totalFilesUnreadable int
|
||||||
|
|
||||||
// The sizes of all blobs the snapshot references, set by
|
// The sizes of all blobs the snapshot references, set by
|
||||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||||
blobSize int64
|
blobSize int64
|
||||||
blobUncompressedSize int64
|
blobUncompressedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNamedSnapshot creates a single named snapshot
|
// createNamedSnapshot creates a single named snapshot and returns the
|
||||||
|
// number of entries --skip-errors skipped because it could not read or
|
||||||
|
// list them.
|
||||||
func (v *Vaultik) createNamedSnapshot(
|
func (v *Vaultik) createNamedSnapshot(
|
||||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||||
) error {
|
) (int, error) {
|
||||||
snapshotStartTime := time.Now()
|
snapshotStartTime := time.Now()
|
||||||
|
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
@@ -258,12 +286,12 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||||
@@ -278,7 +306,7 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating snapshot: %w", err)
|
return 0, fmt.Errorf("creating snapshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||||
@@ -286,12 +314,12 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Snapshot complete",
|
log.Info("Snapshot complete",
|
||||||
@@ -304,7 +332,7 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||||
|
|
||||||
return nil
|
return stats.totalFilesUnreadable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// previousSnapshotID returns the ID of the most recent completed snapshot
|
// previousSnapshotID returns the ID of the most recent completed snapshot
|
||||||
@@ -390,6 +418,7 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBlobs += result.BlobsCreated
|
stats.totalBlobs += result.BlobsCreated
|
||||||
stats.totalFilesSkipped += result.FilesSkipped
|
stats.totalFilesSkipped += result.FilesSkipped
|
||||||
stats.totalFilesFailed += result.FilesFailed
|
stats.totalFilesFailed += result.FilesFailed
|
||||||
|
stats.totalFilesUnreadable += result.FilesUnreadable
|
||||||
stats.totalBytesSkipped += result.BytesSkipped
|
stats.totalBytesSkipped += result.BytesSkipped
|
||||||
stats.totalFilesDeleted += result.FilesDeleted
|
stats.totalFilesDeleted += result.FilesDeleted
|
||||||
stats.totalBytesDeleted += result.BytesDeleted
|
stats.totalBytesDeleted += result.BytesDeleted
|
||||||
|
|||||||
@@ -178,11 +178,13 @@ func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env.out.Reset()
|
env.out.Reset()
|
||||||
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
SkipErrors: true,
|
SkipErrors: true,
|
||||||
Snapshots: []string{"work"},
|
Snapshots: []string{"work"},
|
||||||
}))
|
})
|
||||||
assert.Contains(t, env.out.String(), filesLine(1, 0, 1))
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A backup counts only files that the previous snapshot of its own name
|
// A backup counts only files that the previous snapshot of its own name
|
||||||
|
|||||||
@@ -49,13 +49,19 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
openErr error
|
openErr error
|
||||||
skipErrors bool
|
skipErrors bool
|
||||||
|
wantErr error // nil for none
|
||||||
}{
|
}{
|
||||||
// What a normal user gets opening a file with mode 000.
|
// What a normal user gets opening a file with mode 000.
|
||||||
{name: "unopenable under skip-errors",
|
{name: "unopenable under skip-errors",
|
||||||
openErr: os.ErrPermission, skipErrors: true},
|
openErr: os.ErrPermission, skipErrors: true,
|
||||||
|
wantErr: vaultik.ErrFilesUnreadable},
|
||||||
// What opening a file removed after phase 1 gives.
|
// What opening a file removed after phase 1 gives.
|
||||||
{name: "removed between the phases",
|
{name: "removed between the phases",
|
||||||
openErr: os.ErrNotExist, skipErrors: false},
|
openErr: os.ErrNotExist, skipErrors: false},
|
||||||
|
// A removal is not an error, so --skip-errors does not make the
|
||||||
|
// run fail. See https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
{name: "removed between the phases under skip-errors",
|
||||||
|
openErr: os.ErrNotExist, skipErrors: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -99,10 +105,11 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
v.UI = ui.NewWithColor(out, false)
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
SkipErrors: tt.skipErrors,
|
SkipErrors: tt.skipErrors,
|
||||||
Snapshots: []string{"src"},
|
Snapshots: []string{"src"},
|
||||||
}))
|
})
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
|
||||||
summary := out.String()
|
summary := out.String()
|
||||||
assert.Contains(t, summary,
|
assert.Contains(t, summary,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vaultik_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -73,3 +74,120 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
require.Len(t, snapshots, 1)
|
require.Len(t, snapshots, 1)
|
||||||
assert.Nil(t, snapshots[0].CompletedAt)
|
assert.Nil(t, snapshots[0].CompletedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup under --skip-errors that skips a file it cannot open, or a
|
||||||
|
// directory it cannot list, keeps the snapshot, counts what it skipped
|
||||||
|
// as failed and returns an error instead of finishing successfully. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
skipped string // relative to the source directory
|
||||||
|
}{
|
||||||
|
{name: "file it cannot open", skipped: "skipdir/file.txt"},
|
||||||
|
{name: "directory it cannot list", skipped: "skipdir"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"stored.txt": []byte("stored"),
|
||||||
|
"skipdir/file.txt": []byte("in skipdir"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("work", "")
|
||||||
|
|
||||||
|
// A mode does not lock out root, who runs the tests, so
|
||||||
|
// opening the skipped path is made to fail instead.
|
||||||
|
env.v.Fs = &openFailFs{
|
||||||
|
path: filepath.Join(env.src, tt.skipped),
|
||||||
|
err: os.ErrPermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"work"},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, env.out.String(), "Finished successfully.")
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
context.Background(), listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 1)
|
||||||
|
assert.NotNil(t, snapshots[0].CompletedAt, "the snapshot is kept")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOnLookupFs is the real filesystem, except that path is removed
|
||||||
|
// just before it is looked up. The walk looks an entry up after listing
|
||||||
|
// its directory, so path is listed but gone when the walk reaches it.
|
||||||
|
type removeOnLookupFs struct {
|
||||||
|
afero.OsFs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *removeOnLookupFs) LstatIfPossible(
|
||||||
|
name string,
|
||||||
|
) (os.FileInfo, bool, error) {
|
||||||
|
if name == f.path {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.OsFs.LstatIfPossible(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file removed before the walk reaches it is counted once, as failed,
|
||||||
|
// like a file removed before its data is read, and the backup finishes
|
||||||
|
// successfully, with or without --skip-errors. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
func TestBackupCountsFileRemovedBeforeWalkReachesItAsFailed(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
skipErrors bool
|
||||||
|
}{
|
||||||
|
{name: "under skip-errors", skipErrors: true},
|
||||||
|
{name: "without skip-errors", skipErrors: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"kept.txt": []byte("kept"),
|
||||||
|
"removed.txt": []byte("removed"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("work", "")
|
||||||
|
|
||||||
|
// The previous snapshot has the file, so a removal the walk
|
||||||
|
// also counted as deleted would show.
|
||||||
|
env.backUp(t, "work")
|
||||||
|
|
||||||
|
env.v.Fs = &removeOnLookupFs{
|
||||||
|
path: filepath.Join(env.src, "removed.txt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: tt.skipErrors,
|
||||||
|
Snapshots: []string{"work"},
|
||||||
|
}))
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||||
|
assert.Contains(t, env.out.String(), "Finished successfully.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user