check / check (push) Waiting to run
A backup that stopped between uploading db.zst.age and its manifest left that directory under metadata/ on the destination for good: prune deleted nothing there. The blob step of prune, which snapshot create --prune also runs, now deletes each directory under metadata/ that has no manifest.json.zst, that no local snapshot record claims, and all of whose objects are older than prune_grace_period; an object listed with no time keeps its directory, and only names that are remote keys are deleted. The prompt and the --force output name each such directory, the --json result counts them (metadata_dirs_deleted, metadata_dirs_failed), and a failed delete fails the run. Every description of the grace period now covers these directories. Model: opus-5-5
226 lines
7.4 KiB
Go
226 lines
7.4 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/storage"
|
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// metadataObjectKey returns the key of the named file in the metadata
|
|
// directory of the snapshot with the given human ID.
|
|
func metadataObjectKey(snapshotID, name string) string {
|
|
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name
|
|
}
|
|
|
|
// TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks
|
|
// that prune deletes a directory under metadata/ holding only
|
|
// db.zst.age, as a backup leaves when its manifest upload fails, once it
|
|
// is older than the grace period, and keeps one newer than that. A
|
|
// directory with a manifest is never deleted, however old.
|
|
func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
|
|
t *testing.T,
|
|
) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
storeDir := t.TempDir()
|
|
|
|
store, err := storage.NewFileStorer(storeDir)
|
|
require.NoError(t, err)
|
|
|
|
const (
|
|
oldID = "testhost_home_2026-01-01T00:00:00Z"
|
|
newID = "testhost_home_2026-01-02T00:00:00Z"
|
|
completeID = "testhost_home_2026-01-03T00:00:00Z"
|
|
)
|
|
|
|
oldDB := metadataObjectKey(oldID, "db.zst.age")
|
|
newDB := metadataObjectKey(newID, "db.zst.age")
|
|
completeDB := metadataObjectKey(completeID, "db.zst.age")
|
|
completeManifest := metadataObjectKey(completeID, "manifest.json.zst")
|
|
|
|
manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{
|
|
SnapshotID: snapshot.RemoteSnapshotKey(completeID),
|
|
}, 3)
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range []string{oldDB, newDB, completeDB} {
|
|
require.NoError(t, store.Put(ctx, key, strings.NewReader("db")))
|
|
}
|
|
|
|
require.NoError(t, store.Put(ctx, completeManifest,
|
|
bytes.NewReader(manifest)))
|
|
|
|
gracePeriod := time.Hour
|
|
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
|
|
|
|
for _, key := range []string{oldDB, completeDB, completeManifest} {
|
|
require.NoError(t, os.Chtimes(filepath.Join(storeDir, key),
|
|
beforeGracePeriod, beforeGracePeriod))
|
|
}
|
|
|
|
stdout := &bytes.Buffer{}
|
|
v := &vaultik.Vaultik{
|
|
Config: &config.Config{PruneGracePeriod: gracePeriod},
|
|
Storage: store,
|
|
Stdout: stdout,
|
|
UI: ui.NewWithColor(stdout, false),
|
|
}
|
|
v.SetContext(ctx)
|
|
|
|
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
|
|
|
_, err = store.Stat(ctx, oldDB)
|
|
require.ErrorIs(t, err, storage.ErrNotFound,
|
|
"a directory with no manifest older than the grace period "+
|
|
"must be deleted")
|
|
|
|
for _, key := range []string{newDB, completeDB, completeManifest} {
|
|
_, err = store.Stat(ctx, key)
|
|
require.NoError(t, err, "%s must be kept", key)
|
|
}
|
|
|
|
var result vaultik.PruneBlobsResult
|
|
|
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
|
|
assert.Equal(t, 1, result.MetadataDirsDeleted)
|
|
}
|
|
|
|
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
|
|
// prune --force keeps a directory under metadata/ with no manifest when a
|
|
// local snapshot record claims it, when the listing gives no time for an
|
|
// object in it, or when its name is not a remote key. A fourth such
|
|
// directory is named, deleted and reported, although there are no
|
|
// unreferenced blobs to delete.
|
|
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
env := newListEnv(t)
|
|
|
|
const (
|
|
claimedID = "testhost_home_2026-01-01T00:00:00Z"
|
|
leftID = "testhost_home_2026-01-03T00:00:00Z"
|
|
)
|
|
|
|
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
|
|
|
|
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
|
|
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
|
|
"db.zst.age")
|
|
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
|
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
|
|
|
|
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
|
|
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
|
|
}
|
|
|
|
// Written into the mock store's map directly rather than through
|
|
// Put, so its listing gives no time.
|
|
env.store.data[unknownAgeDB] = []byte("db")
|
|
|
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true}))
|
|
|
|
assert.True(t, env.store.hasKey(claimedDB),
|
|
"a directory a local snapshot record claims must be kept")
|
|
assert.True(t, env.store.hasKey(unknownAgeDB),
|
|
"a directory holding an object of unknown age must be kept")
|
|
assert.True(t, env.store.hasKey(notRemoteKeyDB),
|
|
"a directory whose name is not a remote key must be kept")
|
|
assert.False(t, env.store.hasKey(leftDB),
|
|
"a directory with no manifest, unclaimed and old, must be deleted")
|
|
|
|
output := env.stdout.String()
|
|
|
|
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
|
|
"backups that stopped before their manifest:")
|
|
assert.Contains(t, output,
|
|
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
|
|
assert.NotContains(t, output, "not-a-remote-key")
|
|
assert.Contains(t, output,
|
|
"Deleted 1 metadata director(ies) with no manifest.")
|
|
assert.NotContains(t, output, "No unreferenced blobs to remove.")
|
|
}
|
|
|
|
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
|
|
// names a directory under metadata/ with no manifest that prune is about
|
|
// to delete, and that answering no keeps it.
|
|
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
const leftID = "testhost_home_2026-01-01T00:00:00Z"
|
|
|
|
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
|
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
|
strings.NewReader("db")))
|
|
|
|
env.v.Stdin = strings.NewReader("n\n")
|
|
|
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
|
|
|
assert.True(t, env.store.hasKey(leftDB),
|
|
"a declined prune must keep the directory")
|
|
|
|
output := env.stdout.String()
|
|
|
|
assert.Contains(t, output,
|
|
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
|
|
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
|
|
assert.Contains(t, output, "Cancelled")
|
|
assert.NotContains(t, output, "Deleted")
|
|
}
|
|
|
|
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json
|
|
// counts a directory under metadata/ with no manifest that it cannot
|
|
// delete, and then returns an error, so the command exits non-zero.
|
|
func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z",
|
|
"db.zst.age")
|
|
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
|
strings.NewReader("db")))
|
|
|
|
store := faultstore.New(env.store)
|
|
store.OnDelete = func(key string) bool {
|
|
return strings.HasPrefix(key, "metadata/")
|
|
}
|
|
env.v.Storage = store
|
|
|
|
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
|
|
|
require.ErrorContains(t, err,
|
|
"1 metadata director(ies) with no manifest could not be deleted")
|
|
assert.True(t, env.store.hasKey(leftDB))
|
|
|
|
var result vaultik.PruneBlobsResult
|
|
|
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
|
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
|
|
assert.Equal(t, 1, result.MetadataDirsFailed)
|
|
assert.Equal(t, 0, result.MetadataDirsDeleted)
|
|
}
|