1 Commits
Author SHA1 Message Date
sneak c10252dac8 Exit non-zero when snapshot create --skip-errors skips anything it cannot read (closes #310)
check / check (push) Canceled after 0s
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 then returns
an error the command reports as "Backup completed, but N file(s) could
not be read". A file removed during the run is still counted as failed
in the summary but leaves the exit status at 0, with or without the
flag.

Model: opus-5-5
2026-10-14 03:21:31 +00:00
8 changed files with 41 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -178,7 +178,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. 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. * `--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
@@ -25,6 +25,6 @@ func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.") "Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
assert.NotContains(t, stdout, "Finished successfully.") assert.NotContains(t, stdout, "Finished successfully.")
assert.Contains(t, stderr, assert.Contains(t, stderr,
"Backup completed, but 1 file(s) failed to back up") "Backup completed, but 1 file(s) could not be read")
assert.NotContains(t, stderr, "Snapshot creation failed") assert.NotContains(t, stderr, "Snapshot creation failed")
} }
+2 -2
View File
@@ -115,8 +115,8 @@ on Linux), then /etc/vaultik/config.yml.`,
return return
} }
if errors.Is(err, vaultik.ErrFilesFailedBackup) { if errors.Is(err, vaultik.ErrFilesUnreadable) {
log.Error("Backup completed with failed files", "error", err) log.Error("Backup completed with unreadable files", "error", err)
ReportErrorf("Backup completed, but %v", err) ReportErrorf("Backup completed, but %v", err)
return return
+8 -1
View File
@@ -162,6 +162,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
@@ -985,7 +990,7 @@ func (s *Scanner) recordSpecialEntry(
// handleWalkError deals with a filesystem error surfaced by the walk: // handleWalkError deals with a filesystem error surfaced by the walk:
// under --skip-errors the entry is skipped with a UI notice and counted // under --skip-errors the entry is skipped with a UI notice and counted
// as examined and failed, otherwise the error is fatal. // as examined, failed and unreadable, otherwise the error is fatal.
func (s *Scanner) handleWalkError( func (s *Scanner) handleWalkError(
filePath string, err error, result *ScanResult, filePath string, err error, result *ScanResult,
) error { ) error {
@@ -997,6 +1002,7 @@ func (s *Scanner) handleWalkError(
result.FilesScanned++ result.FilesScanned++
result.FilesFailed++ result.FilesFailed++
result.FilesUnreadable++
return nil // Continue scanning return nil // Continue scanning
} }
@@ -1429,6 +1435,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
} }
+22 -16
View File
@@ -39,10 +39,10 @@ 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")
// ErrFilesFailedBackup is returned when --skip-errors skipped files or // ErrFilesUnreadable is returned when --skip-errors skipped files or
// directories. The snapshot is complete and kept, so the caller can // directories it could not read or list. The snapshot is complete and
// report it apart from a failed backup. // kept, so the caller can report it apart from a failed backup.
var ErrFilesFailedBackup = errors.New("file(s) failed to back up") 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.
@@ -140,16 +140,16 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
} }
// Process each named snapshot // Process each named snapshot
filesFailed := 0 filesUnreadable := 0
for snapIdx, snapName := range snapshotNames { for snapIdx, snapName := range snapshotNames {
failed, 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
} }
filesFailed += failed filesUnreadable += unreadable
} }
// Print overall summary if multiple snapshots // Print overall summary if multiple snapshots
@@ -165,17 +165,17 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
} }
} }
return v.finishCreateSnapshot(opts.SkipErrors, filesFailed) return v.finishCreateSnapshot(filesUnreadable)
} }
// finishCreateSnapshot ends a run whose snapshots are all complete and // finishCreateSnapshot ends a run whose snapshots are all complete and
// kept. Like finishRestore, it returns an error when --skip-errors // kept. Like finishRestore, it returns an error when --skip-errors
// skipped anything; otherwise it prints the closing line. // skipped anything it could not read or list; otherwise it prints the
func (v *Vaultik) finishCreateSnapshot(skipErrors bool, filesFailed int) error { // closing line. A file removed during the run is counted as failed in
// Without --skip-errors a file is counted as failed only when it was // the summary but is not an error, with or without --skip-errors.
// removed during the run, which is not an error. func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
if skipErrors && filesFailed > 0 { if filesUnreadable > 0 {
return fmt.Errorf("%d %w", filesFailed, ErrFilesFailedBackup) 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
@@ -245,6 +245,10 @@ 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
@@ -252,7 +256,8 @@ type snapshotStats struct {
} }
// createNamedSnapshot creates a single named snapshot and returns the // createNamedSnapshot creates a single named snapshot and returns the
// number of files counted as failed. // 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,
) (int, error) { ) (int, error) {
@@ -310,7 +315,7 @@ func (v *Vaultik) createNamedSnapshot(
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats) v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
return stats.totalFilesFailed, 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
@@ -396,6 +401,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
@@ -182,7 +182,7 @@ func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
SkipErrors: true, SkipErrors: true,
Snapshots: []string{"work"}, Snapshots: []string{"work"},
}) })
require.ErrorIs(t, err, vaultik.ErrFilesFailedBackup) require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
assert.Contains(t, env.out.String(), assert.Contains(t, env.out.String(),
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.") "Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
} }
@@ -54,10 +54,14 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
// 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.ErrFilesFailedBackup}, 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 {
@@ -112,7 +112,7 @@ func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
SkipErrors: true, SkipErrors: true,
Snapshots: []string{"work"}, Snapshots: []string{"work"},
}) })
require.ErrorIs(t, err, vaultik.ErrFilesFailedBackup) require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
assert.Contains(t, env.out.String(), assert.Contains(t, env.out.String(),
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.") "Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")