check / check (push) Waiting to run
snapshot create --skip-errors --prune pruned even when files were skipped, so by default it deleted the previous snapshot of that name, the only one still holding a file that had become unreadable, along with blobs only that snapshot referenced. The prune is now skipped exactly when the run exits non-zero for entries --skip-errors could not read or list: older snapshots are kept, a warning says why, and the run still exits non-zero. A file removed during the run does not count, so such a run still prunes. The README's --prune text and the flag's help say so. Model: opus-5-5
284 lines
8.7 KiB
Go
284 lines
8.7 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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/vaultik"
|
|
)
|
|
|
|
// --skip-errors skips only a file that cannot be opened or read. A local
|
|
// index error while a directory is recorded stops the backup, and the
|
|
// snapshot is not recorded as complete. See
|
|
// https://git.eeqj.de/sneak/vaultik/issues/284.
|
|
func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
|
|
// The scan walks the source path with symlinks resolved, so dirPath
|
|
// must be spelled the same way to match the row the scan inserts.
|
|
tempDir, err := filepath.EvalSymlinks(t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
srcDir := filepath.Join(tempDir, "src")
|
|
dirPath := filepath.Join(srcDir, "dir")
|
|
|
|
fs := afero.NewOsFs()
|
|
require.NoError(t, fs.MkdirAll(dirPath, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs,
|
|
filepath.Join(dirPath, "file.txt"), []byte("file content"), 0o644))
|
|
|
|
cfg := faultTestConfig()
|
|
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
|
cfg.Snapshots = map[string]config.SnapshotConfig{
|
|
"tree": {Paths: []string{srcDir}},
|
|
}
|
|
|
|
store, err := storage.NewFileStorer(t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
db, err := database.New(ctx, cfg.IndexPath)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
// The local index refuses the directory's files row.
|
|
_, err = db.Conn().ExecContext(ctx, fmt.Sprintf(`
|
|
CREATE TRIGGER refuse_directory BEFORE INSERT ON files
|
|
WHEN NEW.path = '%s'
|
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`, dirPath))
|
|
require.NoError(t, err)
|
|
|
|
repos := database.NewRepositories(db)
|
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
|
|
|
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
SkipErrors: true,
|
|
Snapshots: []string{"tree"},
|
|
})
|
|
require.ErrorContains(t, err, "simulated index error")
|
|
|
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
require.Len(t, snapshots, 1)
|
|
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{
|
|
"stays.txt": []byte("stays"),
|
|
"removed.txt": []byte("removed"),
|
|
})
|
|
env.setSnapshot("walk", "")
|
|
|
|
// The previous snapshot has the file, so a removal the walk
|
|
// also counted as deleted would show.
|
|
env.backUp(t, "walk")
|
|
|
|
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{"walk"},
|
|
}))
|
|
assert.Contains(t, env.out.String(),
|
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
|
assert.Contains(t, env.out.String(), "Finished successfully.")
|
|
})
|
|
}
|
|
}
|
|
|
|
// A file an exclude pattern matches is not counted when it is removed
|
|
// before the walk reaches it. See
|
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
|
func TestBackupIgnoresExcludedFileRemovedBeforeWalkReachesIt(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newDeletedCountEnv(t, map[string][]byte{
|
|
"stays.txt": []byte("stays"),
|
|
"removed.tmp": []byte("removed"),
|
|
})
|
|
env.setSnapshot("walk", "", "*.tmp")
|
|
env.v.Fs = &removeOnLookupFs{
|
|
path: filepath.Join(env.src, "removed.tmp"),
|
|
}
|
|
|
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
SkipErrors: true,
|
|
Snapshots: []string{"walk"},
|
|
}))
|
|
assert.Contains(t, env.out.String(), filesLine(1, 1, 0))
|
|
assert.Contains(t, env.out.String(), "Finished successfully.")
|
|
}
|
|
|
|
// A backup under --skip-errors --prune that skipped a file it could not
|
|
// read does not prune: the previous snapshot, the only one that holds
|
|
// the file, is kept, and the output says the prune was skipped. See
|
|
// https://git.eeqj.de/sneak/vaultik/issues/382.
|
|
func TestSkipErrorsBackupDoesNotPruneWhenItSkips(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newDeletedCountEnv(t, map[string][]byte{
|
|
"other.txt": []byte("other"),
|
|
"skipped.txt": []byte("skipped"),
|
|
})
|
|
env.setSnapshot("photos", "")
|
|
env.backUp(t, "photos")
|
|
|
|
// An unchanged file is not read again, so the file is changed before
|
|
// it becomes unreadable.
|
|
skipped := filepath.Join(env.src, "skipped.txt")
|
|
require.NoError(t, os.WriteFile(skipped, []byte("changed"), 0o600))
|
|
env.v.Fs = &openFailFs{path: skipped, err: os.ErrPermission}
|
|
|
|
env.out.Reset()
|
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
Prune: true,
|
|
SkipErrors: true,
|
|
Snapshots: []string{"photos"},
|
|
})
|
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
|
assert.Contains(t, env.out.String(),
|
|
"Skipped the post-backup prune because 1 file(s) could not be read")
|
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
|
|
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
|
context.Background(), listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 2, "the previous snapshot is kept")
|
|
}
|
|
|
|
// A file removed during a backup under --skip-errors --prune is not a
|
|
// skipped file, so the backup prunes as usual. See
|
|
// https://git.eeqj.de/sneak/vaultik/issues/382.
|
|
func TestSkipErrorsBackupPrunesWhenAFileIsRemovedDuringIt(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newDeletedCountEnv(t, map[string][]byte{
|
|
"other.txt": []byte("other"),
|
|
"removed.txt": []byte("removed"),
|
|
})
|
|
env.setSnapshot("photos", "")
|
|
env.backUp(t, "photos")
|
|
|
|
env.v.Fs = &removeOnLookupFs{path: filepath.Join(env.src, "removed.txt")}
|
|
|
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
Prune: true,
|
|
SkipErrors: true,
|
|
Snapshots: []string{"photos"},
|
|
}))
|
|
|
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
|
context.Background(), listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 1, "the previous snapshot is pruned")
|
|
}
|