Keep remote metadata when purge cannot remove a snapshot locally (closes #337)
check / check (push) Waiting to run
check / check (push) Waiting to run
snapshot purge, and the purge step of snapshot create --prune, logged a failure to delete a snapshot from the local index, deleted that snapshot's metadata from the destination store anyway, and exited 0. The purge now stops at that snapshot with an error before touching its remote metadata, so the command exits non-zero. Stopping matches what the same loop already does when the remote delete fails; snapshots purged before the failure stay purged. The test drives snapshot purge only: the trigger that makes the local index refuse the delete also breaks the backup's metadata export, so snapshot create --prune cannot be tested that way. Model: opus-5-5
This commit was merged in pull request #371.
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Words and flags of `snapshot purge` used to build argument vectors
|
||||||
|
// below.
|
||||||
|
const (
|
||||||
|
cmdPurge = "purge"
|
||||||
|
flagOlderThan = "--older-than"
|
||||||
|
flagForce = "--force"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
||||||
|
// the local index refuse to delete an old snapshot and checks that
|
||||||
|
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
||||||
|
// the destination store. It used to log the error, delete the metadata
|
||||||
|
// and exit 0.
|
||||||
|
//
|
||||||
|
// `snapshot create --prune` runs the same purge, but cannot be driven
|
||||||
|
// this way: the refusal also breaks the backup's metadata export, which
|
||||||
|
// deletes the other snapshots from its copy of the local index.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
|
// seedStaleSnapshotRecord writes an old snapshot to the local index
|
||||||
|
// only; the manifest planted here puts it on the destination store
|
||||||
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(manifestDir, "manifest.json.zst")
|
||||||
|
require.NoError(t, os.WriteFile(manifestPath, []byte("stub"), 0o600))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, indexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Conn().ExecContext(ctx, `
|
||||||
|
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath,
|
||||||
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.FileExists(t, manifestPath,
|
||||||
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
|
}
|
||||||
@@ -669,10 +669,11 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
snapshotID := snap.ID.String()
|
snapshotID := snap.ID.String()
|
||||||
log.Info("Deleting snapshot", "id", snapshotID)
|
log.Info("Deleting snapshot", "id", snapshotID)
|
||||||
|
|
||||||
|
// Stop before the remote delete, so a snapshot still in the local
|
||||||
|
// index keeps its metadata on the destination store.
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to delete from local database",
|
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
|||||||
Reference in New Issue
Block a user