1 Commits
Author SHA1 Message Date
sneak a6b836df00 Name each prefix once in restore, verify and purge errors (closes #327)
check / check (push) Canceled after 0s
A missing blob during restore, a failed shallow verify and an invalid
purge duration each printed their prefix twice. FetchBlob now returns
the storage error as is, since restore and deep verify name the blob
themselves; purge returns the duration parser's error unchanged; and a
failed shallow verify wraps "problems found in storage" instead of
"verification failed", which the command already prints. Deep verify
words a storage error other than not found as a blob it could not
check, and its log line no longer says S3.

Already fixed for #314: the
deep verify size message naming S3 and other errors called missing;
tests now check both.
Not tested: the log line.

Model: opus-5-5
2026-10-14 00:18:08 +00:00
4 changed files with 29 additions and 89 deletions
+9 -13
View File
@@ -360,20 +360,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.`,
-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()
+14 -32
View File
@@ -1231,11 +1231,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) {
@@ -1270,10 +1268,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 {
@@ -1398,33 +1394,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 "+
@@ -1439,10 +1421,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