1 Commits
Author SHA1 Message Date
sneak ef0dc84ccd Make snapshot list report a changed destination instead of advising prune (closes #317)
check / check (push) Canceled after 0s
When the local index is bound to a different destination than the
configured one, snapshot list now gives the warning prune, snapshot
purge and snapshot remove refuse with: both destinations and the two
ways out (revert the configuration, or run vaultik database delete).
Below the table it replaces the local-only note and its prune advice.
With --json it goes to stderr through the logger, so stdout stays the
document, whose local rows show remote_present false.

Reading the binding moves out of EnsureStorageBinding into
storageBinding, which records nothing, so the read-only list can call
it without binding a fresh index.

Model: opus-5-5
2026-10-14 00:00:41 +00:00
5 changed files with 33 additions and 157 deletions

No files matched your search

+9 -13
View File
@@ -366,20 +366,16 @@ snapshot from the local index and strips the snapshot's metadata from
the backup destination store. Blobs are NOT touched — deleting blobs the backup destination store. Blobs are NOT touched — deleting blobs
requires reading every remaining remote manifest (the destination store requires reading every remaining remote manifest (the destination store
may hold snapshots this host doesn't know about), which is what may hold snapshots this host doesn't know about), which is what
`vaultik prune` does. The command prints the `vaultik prune` invocation `vaultik prune` does. On success the command prints the exact `vaultik
to run as a follow-up only when it removed the snapshot's metadata from prune` invocation to run as a follow-up. An ID that names no snapshot in
the destination store, and never under `--json`. An ID that names no the local index or on the destination store is an error; with
snapshot in the local index or on the destination store is an error; `--local-only`, the snapshot must be in the local index. Local row
with `--local-only`, the snapshot must be in the local index. Local row
cleanup (files, chunks, blobs the snapshot was the last referrer for) cleanup (files, chunks, blobs the snapshot was the last referrer for)
runs automatically. If removing the snapshot's metadata from the runs automatically. If the destination store is unreachable, the local-DB
destination store fails (for example because the store is unreachable), removal still completes and a warning is emitted; run `vaultik snapshot
the local-DB removal still completes and a warning is emitted; run remove <snapshot-id>` again once the store is reachable to remove the
`vaultik snapshot remove <snapshot-id>` again once the store is reachable snapshot's metadata from it (`vaultik prune` does not). To wipe everything
to remove the snapshot's metadata from it (`vaultik prune` does not). For on the destination in one go, use `vaultik remote nuke --force`.
a snapshot that is only on the destination store, that failure is an
error. To wipe everything on the destination in one go, use `vaultik
remote nuke --force`.
* `--local-only`: Skip remote cleanup; only touch the local index * `--local-only`: Skip remote cleanup; only touch the local index
* `--dry-run`: Show what would be deleted without deleting * `--dry-run`: Show what would be deleted without deleting
* `--force`: Skip confirmation prompt * `--force`: Skip confirmation prompt
+6 -9
View File
@@ -270,9 +270,8 @@ By default, this removes the snapshot from the local index database and
strips the snapshot's metadata from the backup destination store. Blobs strips the snapshot's metadata from the backup destination store. Blobs
are NOT touched: deleting them requires reading every remaining remote are NOT touched: deleting them requires reading every remaining remote
manifest (the destination store may hold snapshots this host doesn't manifest (the destination store may hold snapshots this host doesn't
know about), which is what 'vaultik prune' does. The command prints the know about), which is what 'vaultik prune' does. On success the command
'vaultik prune' invocation to run as a follow-up only when it removed the prints the exact 'vaultik prune' invocation to run as a follow-up.
snapshot's metadata from the destination store, and never under --json.
An ID that names no snapshot in the local index or on the destination An ID that names no snapshot in the local index or on the destination
store is an error. store is an error.
@@ -281,12 +280,10 @@ Use --local-only to skip the remote half (e.g. when you want to forget a
snapshot locally without touching the destination store); the snapshot snapshot locally without touching the destination store); the snapshot
must then be in the local index. must then be in the local index.
If removing the snapshot's metadata from the destination store fails (for If the remote is unreachable, the local-database removal still completes
example because the remote is unreachable), the local-database removal and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again
still completes and a warning is emitted; run 'vaultik snapshot remove once the destination store is reachable to remove the snapshot's metadata
<snapshot-id>' again once the destination store is reachable to remove from it ('vaultik prune' does not).
the snapshot's metadata from it ('vaultik prune' does not). For a
snapshot that is only on the destination store, that failure is an error.
To wipe the entire destination store and start over, use 'vaultik remote To wipe the entire destination store and start over, use 'vaultik remote
nuke --force' — it is the single supported entry point for that.`, nuke --force' — it is the single supported entry point for that.`,
-60
View File
@@ -1,17 +1,13 @@
package vaultik //nolint:testpackage // exercises unexported count helpers package vaultik //nolint:testpackage // exercises unexported count helpers
import ( import (
"bytes"
"context" "context"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
) )
// TestTableCountForReportSurfacesReadFailure is the regression guard for // TestTableCountForReportSurfacesReadFailure is the regression guard for
@@ -80,59 +76,3 @@ func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta") assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
assert.Nil(t, countDiff(nil, nil)) assert.Nil(t, countDiff(nil, nil))
} }
// TestPruneDatabaseSummaryLabelsTotals removes the only incomplete
// snapshot from an index that also holds a complete one. The figure in
// brackets is every snapshot left in the local index, so the line must
// say so rather than read as incomplete snapshots that remain.
func TestPruneDatabaseSummaryLabelsTotals(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
db, err := database.New(ctx, ":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
completedAt := startedAt.Add(time.Minute)
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_complete_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: startedAt,
CompletedAt: &completedAt,
}))
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_incomplete_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: startedAt,
}))
var out bytes.Buffer
v := &Vaultik{
DB: db,
Repositories: repos,
SnapshotManager: snapshot.NewSnapshotManager(
snapshot.SnapshotManagerParams{Repos: repos}),
UI: ui.NewWithColor(&out, false),
}
v.SetContext(ctx)
result, err := v.PruneDatabase()
require.NoError(t, err)
require.Equal(t, int64(1), result.SnapshotsDeleted)
assert.Contains(t, out.String(), "Incomplete snapshots: 1 removed "+
"(total snapshots left in the local index: 1).")
assert.Contains(t, out.String(), "Orphaned files: 0 removed "+
"(total files left in the local index: 0).")
assert.Contains(t, out.String(), "Orphaned chunks: 0 removed "+
"(total chunks left in the local index: 0).")
assert.Contains(t, out.String(), "Orphaned blobs: 0 removed "+
"(total blobs left in the local index: 0).")
}
-35
View File
@@ -17,7 +17,6 @@ import (
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -432,40 +431,6 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
assert.Contains(t, out, "Removed snapshot metadata from remote storage") assert.Contains(t, out, "Removed snapshot metadata from remote storage")
} }
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
// snapshot only the destination store holds fails when its metadata
// cannot be deleted there, under --force and under --json: nothing was
// removed, so the command must not succeed.
func TestRemoveSnapshot_NotInLocalIndexDeleteFails(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for name, opts := range map[string]vaultik.RemoveOptions{
"force": {Force: true},
"json": {JSON: true},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
faulty := faultstore.New(store)
faulty.OnDelete = func(string) bool { return true }
tv := vaultik.NewForTesting(faulty)
tv.Repositories = newLocalIndex(t)
result, err := tv.RemoveSnapshot("snapshot-001", &opts)
require.ErrorIs(t, err, faultstore.ErrInjectedDelete)
assert.False(t, result.RemoteRemoved)
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String())
})
}
}
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) { func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
+18 -40
View File
@@ -1228,11 +1228,9 @@ const snapshotRemoveCommandHint = "vaultik snapshot remove"
// destination store. Blobs are NOT touched: removing a snapshot's // destination store. Blobs are NOT touched: removing a snapshot's
// blobs requires reading every remaining remote manifest (the remote // blobs requires reading every remaining remote manifest (the remote
// may hold snapshots this host doesn't know about), which is what // may hold snapshots this host doesn't know about), which is what
// `vaultik prune` is for — when metadata was removed from the // `vaultik prune` is for — the command prints the prune invocation to
// destination store, the text output names the prune invocation to run // run as a follow-up. When the remote is unreachable the command still
// as a follow-up. When removing the metadata fails, a snapshot in the // completes the local-DB removal and warns.
// local index is still removed from it, with a warning; a snapshot
// only on the destination store is an error.
func (v *Vaultik) RemoveSnapshot( func (v *Vaultik) RemoveSnapshot(
snapshotID string, opts *RemoveOptions, snapshotID string, opts *RemoveOptions,
) (*RemoveResult, error) { ) (*RemoveResult, error) {
@@ -1267,10 +1265,8 @@ func (v *Vaultik) RemoveSnapshot(
} }
} }
result.RemoteRemoved, err = v.removeSnapshotRemote( if !opts.LocalOnly {
snapshotID, inLocalIndex, opts) result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
if err != nil {
return result, err
} }
if v.SnapshotManager != nil { if v.SnapshotManager != nil {
@@ -1395,33 +1391,19 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
} }
// removeSnapshotRemote strips the snapshot's metadata from the // removeSnapshotRemote strips the snapshot's metadata from the
// destination store. It returns true only when the delete finished // destination store, warning and proceeding on failure: the local-DB
// without an error and removed at least one object; under LocalOnly it // removal has already happened, so the user is told the remote half
// does nothing. When the snapshot is only on the destination store, a // didn't finish and to run `vaultik snapshot remove` for the snapshot
// failure is returned as an error, because that removal is all the // again once the destination store is reachable (`vaultik prune` never
// command does. When the snapshot was in the local index, its removal // removes snapshot metadata). Returns true when the remote removal
// there has already happened, so a failure is only a warning telling the // succeeded and deleted something.
// user to run `vaultik snapshot remove` for the snapshot again once the func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
// destination store is reachable (`vaultik prune` never removes snapshot
// metadata).
func (v *Vaultik) removeSnapshotRemote(
snapshotID string, inLocalIndex bool, opts *RemoveOptions,
) (bool, error) {
if opts.LocalOnly {
return false, nil
}
log.Info("Removing snapshot metadata from remote storage", log.Info("Removing snapshot metadata from remote storage",
"snapshot_id", snapshotID) "snapshot_id", snapshotID)
remoteKey := snapshot.RemoteSnapshotKey(snapshotID) remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey) deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
if err != nil && !inLocalIndex {
return false, fmt.Errorf(
"removing snapshot metadata from destination store: %w", err)
}
if err != nil { if err != nil {
log.Warn("Could not remove snapshot metadata from remote storage; "+ log.Warn("Could not remove snapshot metadata from remote storage; "+
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+ "run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
@@ -1436,10 +1418,10 @@ func (v *Vaultik) removeSnapshotRemote(
err, snapshotRemoveCommandHint, snapshotID) err, snapshotRemoveCommandHint, snapshotID)
} }
return false, nil return false
} }
return deleted > 0, nil return deleted > 0
} }
// RemoveAllSnapshots removes every snapshot known to the local // RemoveAllSnapshots removes every snapshot known to the local
@@ -1920,17 +1902,13 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted) snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
v.UI.Completef("Pruned local index database.") v.UI.Completef("Pruned local index database.")
v.UI.Detailf("Incomplete snapshots: %s removed "+ v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
"(total snapshots left in the local index: %s).",
countText(&result.SnapshotsDeleted), countText(snapshotsRemain)) countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
v.UI.Detailf("Orphaned files: %s removed "+ v.UI.Detailf("Orphaned files: %s removed (%s remain).",
"(total files left in the local index: %s).",
countText(result.FilesDeleted), countText(fileCountAfter)) countText(result.FilesDeleted), countText(fileCountAfter))
v.UI.Detailf("Orphaned chunks: %s removed "+ v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
"(total chunks left in the local index: %s).",
countText(result.ChunksDeleted), countText(chunkCountAfter)) countText(result.ChunksDeleted), countText(chunkCountAfter))
v.UI.Detailf("Orphaned blobs: %s removed "+ v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
"(total blobs left in the local index: %s).",
countText(result.BlobsDeleted), countText(blobCountAfter)) countText(result.BlobsDeleted), countText(blobCountAfter))
return result, nil return result, nil