check / check (push) Waiting to run
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 the walk found an entry in and that, once listed, is on a different device from its parent; listing mounts an automounted volume. Under --prune, when an earlier snapshot of that name recorded such a directory and this run does not, the run skips the prune, keeps every older snapshot and exits non-zero, naming the directory. For a volume retired on purpose, snapshot purge removes the older snapshots. Model: opus-5-5
186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
)
|
|
|
|
// Flags of `snapshot create` used to build argument vectors below.
|
|
const (
|
|
flagPrune = "--prune"
|
|
flagKeepNewerThan = "--keep-newer-than"
|
|
)
|
|
|
|
// TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a
|
|
// --keep-newer-than the post-backup prune could not use stops `snapshot
|
|
// create` before the backup: the command fails and the destination
|
|
// store stays empty. It used to be read only by the prune, after the
|
|
// snapshot was uploaded, and without --prune it was ignored.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "decimal in a day count",
|
|
args: []string{flagPrune, flagKeepNewerThan, "1.5d"},
|
|
},
|
|
{
|
|
name: "unknown unit",
|
|
args: []string{flagPrune, flagKeepNewerThan, "4x"},
|
|
},
|
|
{
|
|
name: "without --prune",
|
|
args: []string{flagKeepNewerThan, "4w"},
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
configPath, storeDir := writeBackupConfig(t)
|
|
|
|
code, _, _ := runEntry(t, append(
|
|
[]string{flagConfig, configPath, cmdSnapshot, cmdCreate},
|
|
testCase.args...)...)
|
|
|
|
assert.Equal(t, 1, code)
|
|
|
|
stored, err := os.ReadDir(storeDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, stored, "nothing may be uploaded")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
|
|
// post-backup prune fail after a good backup, by planting a manifest
|
|
// that cannot be decoded, and checks that the failure is reported as the
|
|
// prune's and not as a failed snapshot creation.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
|
configPath, storeDir := writeBackupConfig(t)
|
|
|
|
// A remote key is 64 lowercase hex characters. The prune reads every
|
|
// manifest on the store and fails on one it cannot decode.
|
|
plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64))
|
|
require.NoError(t, os.MkdirAll(plantedDir, 0o750))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(plantedDir, "manifest.json.zst"),
|
|
[]byte("not a valid manifest"), 0o600))
|
|
|
|
code, _, stderr := runEntry(t,
|
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
|
|
|
assert.Equal(t, 1, code)
|
|
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
|
|
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 2,
|
|
"the new snapshot is on the store beside the planted one")
|
|
}
|
|
|
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
|
// empties the source directory, as a volume that is not mounted leaves
|
|
// its mount point, and runs `snapshot create --prune`. 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/388.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
|
configPath, storeDir := writeBackupConfig(t)
|
|
|
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
|
require.Equal(t, 0, code)
|
|
|
|
// writeBackupConfig puts the source directory beside the config file.
|
|
require.NoError(t, os.Remove(
|
|
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
|
|
|
code, _, stderr := runEntry(t,
|
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
|
|
|
assert.Equal(t, 1, code)
|
|
assert.Contains(t, stderr,
|
|
"Backup completed, but 1 source path(s) were empty")
|
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
|
|
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
|
}
|
|
|
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
|
|
// up, records in the local index that this backup found the source
|
|
// directory as a mount point, and runs `snapshot create --prune`, which
|
|
// does not find it as one. The command exits non-zero, reports the
|
|
// backup as completed, not as a failed snapshot creation, and keeps the
|
|
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
|
|
t *testing.T,
|
|
) {
|
|
configPath, storeDir := writeBackupConfig(t)
|
|
|
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
|
require.Equal(t, 0, code)
|
|
|
|
// writeBackupConfig puts the source directory and the local index
|
|
// beside the config file.
|
|
dir := filepath.Dir(configPath)
|
|
ctx := context.Background()
|
|
|
|
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
|
require.NoError(t, err)
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
|
|
require.NoError(t, err)
|
|
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
|
|
backedUp.ID.String(), filepath.Join(dir, "source")))
|
|
require.NoError(t, db.Close())
|
|
|
|
code, _, stderr := runEntry(t,
|
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
|
|
|
assert.Equal(t, 1, code)
|
|
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
|
|
"an earlier snapshot recorded were not found")
|
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
|
|
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
|
}
|
|
|
|
// writeBackupConfig builds a hermetic config with one small file in its
|
|
// source directory and an empty destination store directory. Returns the
|
|
// config path and the store directory.
|
|
func writeBackupConfig(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
|
|
configPath := writeHermeticPruneConfig(t, false)
|
|
|
|
// writeHermeticPruneConfig puts the source and store directories
|
|
// beside the config file.
|
|
dir := filepath.Dir(configPath)
|
|
sourceDir := filepath.Join(dir, "source")
|
|
|
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
|
|
|
return configPath, filepath.Join(dir, "store")
|
|
}
|