Do not prune after a backup that found a configured path empty (closes #388)
check / check (push) Canceled after 0s

snapshot create --prune of a snapshot whose path was an empty
directory, as the mount point of a volume that is not mounted is,
recorded an empty snapshot and then purged the older snapshots that
held the data, with their blobs.

The scan now counts the entries it finds in each configured path.
Under --prune, a run in which a path of a snapshot it made held
nothing skips the prune, keeps every older snapshot, names each such
path with its snapshot, and exits non-zero, as
#382 does for unreadable
files. Only the current run is looked at. Without --prune this check
does nothing. A configured path that is a symlink whose target is
gone now fails every run, with or without --prune.

Model: opus-5-5
This commit was merged in pull request #394.
This commit is contained in:
clawbot committed 2026-10-15 02:49:21 +02:00
1 parent 850e2de25a
commit e9b17cc5fd
7 files changed
+418 -47

No files matched your search

+234
View File
@@ -1,8 +1,15 @@
package vaultik_test
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -25,3 +32,230 @@ func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
t.Error("Expected Prune to be false")
}
}
// A backup under --prune that finds nothing in a configured path, as in
// the mount point of a volume that is not mounted, does not prune: run
// after run, the snapshot that holds the files is kept with everything
// it stored, and the run fails. That holds whether the empty path is the
// snapshot's only path or one of two. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
// emptied is the path, relative to the source directory, that is
// emptied after the first backup.
const (
snapshotName = "media"
emptied = "music"
)
tests := []struct {
name string
paths []string // relative to the source directory
}{
{name: "only path", paths: []string{emptied}},
{name: "one of two paths", paths: []string{emptied, "docs"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
emptied + "/a.mp3": []byte("song a"),
emptied + "/b.mp3": []byte("song b"),
"docs/notes.txt": []byte("notes"),
})
paths := make([]string, 0, len(tt.paths))
for _, rel := range tt.paths {
paths = append(paths, filepath.Join(env.src, rel))
}
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
Paths: paths,
}
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err)
// A volume that is not mounted leaves its mount point behind
// as an empty directory.
emptiedDir := filepath.Join(env.src, emptied)
require.NoError(t, os.RemoveAll(emptiedDir))
require.NoError(t, os.Mkdir(emptiedDir, 0o750))
for range 2 {
env.out.Reset()
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
})
require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty)
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because nothing was found in ")
assert.Contains(t, env.out.String(),
emptied+" of snapshot "+snapshotName+";")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
}
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 3)
assert.Equal(t, firstID, snapshots[2].ID.String(),
"the snapshot that holds the files is kept")
for _, key := range stored {
_, err := env.v.Storage.Stat(ctx, key)
require.NoError(t, err, "%s is kept", key)
}
})
}
}
// A backup under --prune of a configured path that is a symlink whose
// target is gone, as one into a volume that is not mounted, fails before
// anything is pruned: the snapshot that holds the files is kept with
// everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const snapshotName = "photos"
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
"volume/a.jpg": []byte("photo a"),
"volume/b.jpg": []byte("photo b"),
})
target := filepath.Join(env.src, "volume")
link := filepath.Join(env.src, "photos")
require.NoError(t, os.Symlink(target, link))
env.setSnapshot(snapshotName, "photos")
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err)
// The volume is not mounted, so the symlink points at nothing.
require.NoError(t, os.RemoveAll(target))
env.out.Reset()
err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
})
require.ErrorIs(t, err, os.ErrNotExist)
assert.Contains(t, err.Error(), link)
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 1)
assert.Equal(t, firstID, snapshots[0].ID.String(),
"the snapshot that holds the files is kept")
for _, key := range stored {
_, err := env.v.Storage.Stat(ctx, key)
require.NoError(t, err, "%s is kept", key)
}
}
// A backup under --skip-errors --prune that both skipped a file it could
// not read and found nothing in a configured path names the empty path
// too when it skips the prune. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const snapshotName = "media"
env := newDeletedCountEnv(t, map[string][]byte{
"docs/notes.txt": []byte("notes"),
})
require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750))
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
Paths: []string{
filepath.Join(env.src, "docs"),
filepath.Join(env.src, "music"),
},
}
// A mode does not lock out root, who runs the tests, so opening the
// file is made to fail instead.
env.v.Fs = &openFailFs{
path: filepath.Join(env.src, "docs", "notes.txt"),
err: os.ErrPermission,
}
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{snapshotName},
})
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.Contains(t, env.out.String(),
"Skipped the post-backup prune because nothing was found in ")
assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
}
// A backup under --prune that finds something in each configured path
// prunes the previous snapshot, also when a path is a single file. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const (
snapshotName = "album"
file = "pictures/one.jpg" // relative to the source directory
)
tests := []struct {
name string
path string // relative to the source directory
}{
{name: "directory", path: filepath.Dir(file)},
{name: "single file", path: file},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
file: []byte("picture"),
})
env.setSnapshot(snapshotName, tt.path)
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
}))
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 1, "the previous snapshot is pruned")
assert.NotEqual(t, firstID, snapshots[0].ID.String())
})
}
}