check / check (push) Canceled after 0s
A source host whose credentials allow only PUT, which the documents say is enough to back up, failed every backup after its first: the marker check looks the marker up with an S3 HEAD, which those credentials are refused, and any error other than not found was fatal. Such an error now means the check could not be made: it is logged at info level, so --cron stays silent, and the command goes on. Not found still stops it. The documents now say so, and that such a host does not notice an emptied destination, so vaultik database delete must be run on it after the destination is emptied. The fault-injecting test store gains a Stat hook for the test. Model: opus-5-5
332 lines
11 KiB
Go
332 lines
11 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/storage/faultstore"
|
|
"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 that answers that it has no
|
|
// 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)
|
|
}
|
|
|
|
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
|
|
// and writes the marker when looking it up fails with an error other
|
|
// than not found, as S3 answers credentials that allow only PUT
|
|
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
|
|
// that is not found still stops it.
|
|
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
storeDir := t.TempDir()
|
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
|
|
|
// Removed so the test sees the backup write it again: with the
|
|
// look-up failing, the backup cannot tell that it is gone.
|
|
markerPath := filepath.Join(storeDir, markerFile)
|
|
require.NoError(t, os.Remove(markerPath))
|
|
|
|
store := faultstore.New(v.Storage)
|
|
store.OnStat = func(key string) bool { return key == markerFile }
|
|
v.Storage = store
|
|
|
|
require.NoError(t, backUp(v, "second"))
|
|
localSnapshotID(ctx, t, repos, "second")
|
|
assert.FileExists(t, markerPath)
|
|
|
|
require.NoError(t, os.Remove(markerPath))
|
|
|
|
store.OnStat = nil
|
|
|
|
require.ErrorContains(t, backUp(v, "third"), markerFile)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
|
|
// fails, deletes no local snapshot record, and neither reports the
|
|
// destination empty nor advises running nuke again when the destination
|
|
// has no marker.
|
|
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
|
|
|
err := v.NukeRemote(true)
|
|
require.ErrorContains(t, err, markerFile)
|
|
assert.NotContains(t, out.String(), "now empty")
|
|
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
|
|
|
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))
|
|
}
|