check / check (push) Waiting to run
A snapshot ID is hostname_name_timestamp, and purge took the name to be everything between the first and the last underscore. With a hostname such as my_host the name home came out as host_home, so `snapshot purge --keep-latest --snapshot home` found nothing to delete and `snapshot create --prune` purged nothing without a message. The name is now read by removing the hostname stored with the snapshot, in the short form the ID uses, so both may contain underscores. This was chosen over rejecting underscores in `hostname` when the config loads, which would also stop restores on such a host. The purge consistency test stored a hostname that did not match its snapshot IDs; it now matches, as it always does in production. Model: opus-5-5
147 lines
4.7 KiB
Go
147 lines
4.7 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"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/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// setupConsistencyTest builds a Vaultik whose local database and mock
|
|
// remote both hold the given snapshots. Remote metadata is stored under
|
|
// the production layout, metadata/<RemoteSnapshotKey(id)>/manifest.json.zst.
|
|
// It returns the instance and the mock so a test can inspect the remote.
|
|
func setupConsistencyTest(
|
|
t *testing.T, snapshotIDs []string,
|
|
) (*vaultik.Vaultik, *MockStorer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
db, err := database.New(ctx, ":memory:")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
repos := database.NewRepositories(db)
|
|
mockStorage := NewMockStorer()
|
|
|
|
for _, id := range snapshotIDs {
|
|
parts := strings.Split(id, "_")
|
|
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
|
require.NoError(t, err, "parsing timestamp from snapshot ID %q", id)
|
|
|
|
completedAt := startedAt.Add(5 * time.Minute)
|
|
snap := &database.Snapshot{
|
|
ID: types.SnapshotID(id),
|
|
Hostname: snapHostname,
|
|
VaultikVersion: testLabel,
|
|
StartedAt: startedAt,
|
|
CompletedAt: &completedAt,
|
|
}
|
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
return repos.Snapshots.Create(ctx, tx, snap)
|
|
})
|
|
require.NoError(t, err, "creating snapshot %s", id)
|
|
|
|
metadataKey := "metadata/" + snapshot.RemoteSnapshotKey(id) +
|
|
"/manifest.json.zst"
|
|
err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub"))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
v := &vaultik.Vaultik{
|
|
Storage: mockStorage,
|
|
Repositories: repos,
|
|
DB: db,
|
|
Stdout: &bytes.Buffer{},
|
|
Stderr: &bytes.Buffer{},
|
|
Stdin: &bytes.Buffer{},
|
|
}
|
|
v.SetContext(ctx)
|
|
|
|
return v, mockStorage
|
|
}
|
|
|
|
func remoteHasSnapshot(t *testing.T, m *MockStorer, id string) bool {
|
|
t.Helper()
|
|
|
|
prefix := "metadata/" + snapshot.RemoteSnapshotKey(id) + "/"
|
|
keys, err := m.List(context.Background(), prefix)
|
|
require.NoError(t, err)
|
|
|
|
return len(keys) > 0
|
|
}
|
|
|
|
// TestPurgeKeepsRemotelyBackedLocalRows guards against issue #160
|
|
// (https://git.eeqj.de/sneak/vaultik/issues/160): purge reconciles local
|
|
// rows against the remote first, and that step compared human snapshot IDs
|
|
// against the hashed remote directory names, which never match — so it
|
|
// deleted every local record and the purge itself then removed nothing.
|
|
//
|
|
// With every snapshot still present remotely and nothing old enough to
|
|
// purge, all local rows must survive the reconcile untouched.
|
|
func TestPurgeKeepsRemotelyBackedLocalRows(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
|
|
|
|
v, _ := setupConsistencyTest(t, ids)
|
|
|
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
// 100 years: nothing is old enough to delete, so the reconcile
|
|
// is the only thing that touches the rows.
|
|
OlderThan: "36500d",
|
|
Force: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
remaining := listRemainingSnapshots(t, v)
|
|
assert.Len(t, remaining, len(ids),
|
|
"remotely-backed local rows must survive the reconcile")
|
|
assert.Contains(t, remaining, snapHomeT0)
|
|
assert.Contains(t, remaining, snapHomeT1)
|
|
assert.Contains(t, remaining, snapSystemT0)
|
|
}
|
|
|
|
// TestPurgeRemovesLocalAndRemoteTogether proves the two halves stay
|
|
// consistent: a purged snapshot is gone both locally and remotely, while a
|
|
// retained one keeps both. Before the fix, the reconcile dropped every
|
|
// local row yet the remote metadata was left in place.
|
|
func TestPurgeRemovesLocalAndRemoteTogether(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
|
|
|
|
v, mock := setupConsistencyTest(t, ids)
|
|
|
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
|
KeepLatest: true,
|
|
Force: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Keep latest per name: newest home and the lone system are kept.
|
|
remaining := listRemainingSnapshots(t, v)
|
|
assert.ElementsMatch(t, []string{snapHomeT1, snapSystemT0}, remaining)
|
|
|
|
// Local and remote agree: the older home snapshot is gone from both,
|
|
// the retained ones are present in both.
|
|
assert.False(t, remoteHasSnapshot(t, mock, snapHomeT0),
|
|
"purged snapshot must also be removed remotely")
|
|
assert.True(t, remoteHasSnapshot(t, mock, snapHomeT1),
|
|
"retained snapshot must remain remotely")
|
|
assert.True(t, remoteHasSnapshot(t, mock, snapSystemT0),
|
|
"retained snapshot must remain remotely")
|
|
}
|