check / check (push) Canceled after 0s
snapshot create --prune purged the older snapshots holding a volume's files when the volume, mounted below a configured path, was not mounted: its empty mount point read as deleted files. The scan now records in the local index each directory at or below a configured path that is on a different device from its parent directory. Under --prune, a run that does not find such a directory recorded by an earlier snapshot of that name skips the prune, keeps every older snapshot, names the directory and its snapshot, and exits non-zero, as for an empty configured path. A volume retired on purpose is cleared by removing the older snapshots with snapshot purge. Model: opus-5-5
358 lines
11 KiB
Go
358 lines
11 KiB
Go
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/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestSnapshotCreateOptions_PruneFlag verifies the Prune field exists on
|
|
// SnapshotCreateOptions and can be set.
|
|
func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := &vaultik.SnapshotCreateOptions{
|
|
Prune: true,
|
|
}
|
|
if !opts.Prune {
|
|
t.Error("Expected Prune to be true")
|
|
}
|
|
|
|
opts2 := &vaultik.SnapshotCreateOptions{
|
|
Prune: false,
|
|
}
|
|
if opts2.Prune {
|
|
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())
|
|
})
|
|
}
|
|
}
|
|
|
|
// A backup under --prune in which a volume that an earlier snapshot found
|
|
// mounted below the configured path is not mounted does not prune: run
|
|
// after run, the snapshot that holds the volume's files is kept with
|
|
// everything it stored, and the run fails. Once the volume is mounted
|
|
// again, the prune runs. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
|
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
// volume is the directory, below the configured path, where the
|
|
// volume is mounted.
|
|
const (
|
|
snapshotName = "data"
|
|
volume = "nas"
|
|
)
|
|
|
|
ctx := context.Background()
|
|
env := newDeletedCountEnv(t, map[string][]byte{
|
|
volume + "/a.txt": []byte("file a"),
|
|
"notes.txt": []byte("notes"),
|
|
})
|
|
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
|
Paths: []string{env.src},
|
|
}
|
|
prune := &vaultik.SnapshotCreateOptions{
|
|
Prune: true, Snapshots: []string{snapshotName},
|
|
}
|
|
|
|
// While the volume is mounted, its directory is on a device of its
|
|
// own.
|
|
mounted := true
|
|
newScanner := env.v.ScannerFactory
|
|
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
|
|
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
|
if mounted && info.Name() == volume {
|
|
return 2, true
|
|
}
|
|
|
|
return 1, true
|
|
}
|
|
|
|
return newScanner(params)
|
|
}
|
|
|
|
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 on its parent's device.
|
|
volumeDir := filepath.Join(env.src, volume)
|
|
require.NoError(t, os.RemoveAll(volumeDir))
|
|
require.NoError(t, os.Mkdir(volumeDir, 0o750))
|
|
|
|
mounted = false
|
|
|
|
for range 2 {
|
|
env.out.Reset()
|
|
|
|
err := env.v.CreateSnapshot(prune)
|
|
require.ErrorIs(t, err, vaultik.ErrVolumeNotMounted)
|
|
assert.Contains(t, env.out.String(),
|
|
"no volume is mounted at "+volumeDir+" of snapshot "+snapshotName)
|
|
assert.Contains(t, env.out.String(), "'vaultik snapshot purge'")
|
|
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 volume's files is kept")
|
|
|
|
for _, key := range stored {
|
|
_, err := env.v.Storage.Stat(ctx, key)
|
|
require.NoError(t, err, "%s is kept", key)
|
|
}
|
|
|
|
// The volume is mounted again.
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
|
|
|
|
mounted = true
|
|
|
|
require.NoError(t, env.v.CreateSnapshot(prune))
|
|
|
|
snapshots, err = env.v.Repositories.Snapshots.ListRecent(
|
|
ctx, listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
require.Len(t, snapshots, 1, "the older snapshots are pruned")
|
|
}
|