check / check (push) Canceled after 0s
Every backup writes vaultik-destination.txt at the destination root. While the local index holds snapshots stored there, a destination without it is not present: snapshot create refuses before writing, prune and snapshot purge fail, and snapshot list and snapshot remove act as for a destination that cannot be listed. snapshot create and snapshot remove check it first. remote nuke deletes the marker last. The file:// backend never creates its destination directory, which protects a directory on a removable volume, not a mount point. Tests back up into directories that exist; fixtures for a written destination carry the marker. Judgement call: the marker is rewritten on every backup. Deviation: for the complexity limit, two blocks moved, unchanged, into snapshotNamesToCreate and confirmRemoveSnapshot. Model: opus-5-5
279 lines
9.3 KiB
Go
279 lines
9.3 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
|
|
// file:// destination at a volume's mount point leaves an empty directory
|
|
// behind when the volume is unplugged. While the local index holds
|
|
// snapshots, a destination without vaultik-destination.txt is not present,
|
|
// so no command may write to it or conclude from it that the local
|
|
// snapshots are gone.
|
|
|
|
// markerFile is the name of the file a backup writes at the root of the
|
|
// destination.
|
|
const markerFile = "vaultik-destination.txt"
|
|
|
|
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
|
// moves the destination directory away and leaves an empty directory in
|
|
// its place, as unplugging a volume mounted there would. It returns the
|
|
// path of that empty directory.
|
|
func backUpThenUnplugMountPoint(
|
|
ctx context.Context, t *testing.T,
|
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
|
t.Helper()
|
|
|
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
|
|
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
|
|
return v, repos, out, storeDir
|
|
}
|
|
|
|
// assertUntouched checks that the local index still holds its one
|
|
// snapshot and that nothing was written to the empty mount point.
|
|
func assertUntouched(
|
|
ctx context.Context, t *testing.T,
|
|
repos *database.Repositories, storeDir string,
|
|
) {
|
|
t.Helper()
|
|
|
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
|
require.NoError(t, err)
|
|
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
|
|
|
entries, err := os.ReadDir(storeDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
|
}
|
|
|
|
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
|
// file at the root of the destination.
|
|
func TestFirstBackupWritesMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
storeDir := t.TempDir()
|
|
backUpToFileDestination(context.Background(), t, storeDir)
|
|
|
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
}
|
|
|
|
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
|
// --prune writes nothing and drops no local snapshot record when the
|
|
// destination has no marker.
|
|
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
|
|
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
|
Cron: true,
|
|
Prune: true,
|
|
})
|
|
require.ErrorContains(t, err, markerFile)
|
|
|
|
assertUntouched(ctx, t, repos, storeDir)
|
|
}
|
|
|
|
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
|
|
// snapshot create writes nothing when the local index holds only an
|
|
// incomplete snapshot and the destination has no marker. The backup drops
|
|
// incomplete snapshots before it scans; checked after that, the local
|
|
// index would hold none and nothing would be checked.
|
|
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
storeDir := t.TempDir()
|
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
|
|
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
|
ID: "host_first_2026-01-02T03:04:05Z",
|
|
Hostname: "host",
|
|
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
}))
|
|
|
|
err := backUp(v, "first")
|
|
require.ErrorContains(t, err, markerFile)
|
|
|
|
assertUntouched(ctx, t, repos, storeDir)
|
|
}
|
|
|
|
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
|
// deletes no local snapshot record when the destination has no marker.
|
|
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
|
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
|
require.ErrorContains(t, err, markerFile)
|
|
|
|
assertUntouched(ctx, t, repos, storeDir)
|
|
}
|
|
|
|
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
|
// fails and deletes no local snapshot record when the destination has no
|
|
// marker.
|
|
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
|
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
KeepLatest: true,
|
|
Force: true,
|
|
})
|
|
require.ErrorContains(t, err, markerFile)
|
|
|
|
assertUntouched(ctx, t, repos, storeDir)
|
|
}
|
|
|
|
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
// remove warns that the metadata could not be removed from a destination
|
|
// without a marker, instead of reporting that it was.
|
|
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
|
|
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
|
&vaultik.RemoveOptions{Force: true})
|
|
require.NoError(t, err)
|
|
|
|
assert.False(t, result.RemoteRemoved)
|
|
assert.Contains(t, out.String(),
|
|
"Could not remove snapshot metadata from remote")
|
|
assert.Contains(t, out.String(), markerFile)
|
|
assert.NotContains(t, out.String(),
|
|
"Removed snapshot metadata from remote storage")
|
|
}
|
|
|
|
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
|
|
// snapshot remove, for a snapshot that is only on the destination, fails
|
|
// saying the destination is not present when it has no marker, instead
|
|
// of saying the snapshot does not exist.
|
|
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
require.NoError(t, backUp(v, "second"))
|
|
|
|
id := localSnapshotID(ctx, t, repos, "first")
|
|
_, err := v.RemoveSnapshot(id,
|
|
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
|
|
require.NoError(t, err)
|
|
|
|
// Unplug the volume: an empty directory stays at its mount point.
|
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
|
|
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
|
|
require.ErrorContains(t, err, markerFile)
|
|
require.ErrorContains(t, err, "mount the volume")
|
|
|
|
assertUntouched(ctx, t, repos, storeDir)
|
|
}
|
|
|
|
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
|
// list warns and shows the local index alone when the destination has no
|
|
// marker, without reporting the local snapshot as missing from it.
|
|
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
|
|
id := localSnapshotID(ctx, t, repos, "first")
|
|
|
|
require.NoError(t, v.ListSnapshots(false))
|
|
|
|
assert.Contains(t, out.String(), "Could not list backup destination store")
|
|
assert.Contains(t, out.String(), markerFile)
|
|
assert.Contains(t, out.String(), id)
|
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
|
}
|
|
|
|
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
|
|
// snapshot list, after storage_url is changed to an empty directory,
|
|
// warns only that the local index is bound to a different destination:
|
|
// the local snapshots are stored at the old one, so the new one's lack
|
|
// of a marker says nothing. With --json the local row is not on the new
|
|
// destination, so remote_present is false, not unknown.
|
|
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
|
|
id := localSnapshotID(ctx, t, repos, "first")
|
|
|
|
newDir := t.TempDir()
|
|
store, err := storage.NewFileStorer(newDir)
|
|
require.NoError(t, err)
|
|
|
|
v.Storage = store
|
|
v.Config.StorageURL = "file://" + newDir
|
|
|
|
require.NoError(t, v.ListSnapshots(false))
|
|
|
|
assert.Contains(t, out.String(), id)
|
|
assert.Contains(t, out.String(),
|
|
"local index is bound to a different backup destination")
|
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
|
assert.NotContains(t, out.String(), markerFile)
|
|
|
|
out.Reset()
|
|
require.NoError(t, v.ListSnapshots(true))
|
|
|
|
rows := decodeListJSON(t, out.String())
|
|
require.Len(t, rows, 1)
|
|
require.NotNil(t, rows[0].RemotePresent)
|
|
assert.False(t, *rows[0].RemotePresent)
|
|
}
|
|
|
|
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
|
// destination ready for a new first backup, which writes the marker
|
|
// again.
|
|
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
storeDir := t.TempDir()
|
|
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
|
|
|
require.NoError(t, v.NukeRemote(true))
|
|
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
|
|
|
require.NoError(t, backUp(v, "second"))
|
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
|
}
|