Keep the local index when remote nuke cannot list or find the destination (closes #292)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
`remote nuke` took a destination store it could not list, or one without `vaultik-destination.txt` while the local index held snapshots, as a warning: it then cleared the local snapshot records, and for an unplugged volume's empty mount point reported the store empty and exited zero. The bulk remove nuke runs now fails on either before it removes anything; only a local-only remove still goes on with a warning. It also deletes each snapshot's remote metadata before its local record, so a failed delete keeps the snapshot in the local index under its name instead of leaving it on the destination store as a remote-only snapshot. Model: opus-5-5
This commit is contained in:
+6
-5
@@ -385,11 +385,12 @@ bucket/
|
|||||||
A destination without `vaultik-destination.txt` is treated as not present
|
A destination without `vaultik-destination.txt` is treated as not present
|
||||||
while the local index holds snapshots stored there (`checkDestinationMarker`
|
while the local index holds snapshots stored there (`checkDestinationMarker`
|
||||||
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
in `internal/vaultik/destination_marker.go`): `snapshot create` refuses to
|
||||||
write to it, `prune` and `snapshot purge` fail, and `snapshot list` and
|
write to it, `prune`, `snapshot purge` and `remote nuke` fail, and
|
||||||
`snapshot remove` behave as for a destination that cannot be listed. This
|
`snapshot list` and `snapshot remove` behave as for a destination that cannot
|
||||||
keeps those commands from taking an unplugged volume's empty mount point for
|
be listed. This keeps those commands from taking an unplugged volume's empty
|
||||||
a destination whose snapshots are all gone. The `file://` backend never
|
mount point for a destination whose snapshots are all gone. The `file://`
|
||||||
creates its destination directory, so a write to a missing one fails.
|
backend never creates its destination directory, so a write to a missing one
|
||||||
|
fails.
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
||||||
|
|||||||
@@ -454,9 +454,14 @@ since `prune` never deletes it.
|
|||||||
the backup destination store, whatever its name, and then
|
the backup destination store, whatever its name, and then
|
||||||
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
|
||||||
and irreversible. This is the single supported way to wipe the entire
|
and irreversible. This is the single supported way to wipe the entire
|
||||||
destination store. If a listing afterwards still finds anything under
|
destination store. A snapshot is removed from the local index only once
|
||||||
`metadata/` or `blobs/`, the command names each object left and exits
|
its metadata is deleted from the destination store. If the destination
|
||||||
non-zero; running it again deletes what is left.
|
store cannot be listed, or the local index holds snapshots and the
|
||||||
|
destination has no `vaultik-destination.txt` (an unplugged volume's
|
||||||
|
empty mount point), the command fails before it removes anything. If a
|
||||||
|
listing afterwards still finds anything under `metadata/` or `blobs/`,
|
||||||
|
the command names each object left and exits non-zero; running it again
|
||||||
|
deletes what is left.
|
||||||
* `--force`: Required to confirm destruction.
|
* `--force`: Required to confirm destruction.
|
||||||
|
|
||||||
**`database delete`**: Delete the local SQLite state database file
|
**`database delete`**: Delete the local SQLite state database file
|
||||||
@@ -529,10 +534,11 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
* `vaultik-destination.txt` holds one fixed line of text, with no host
|
||||||
name, path or time. A destination without it is treated as not
|
name, path or time. A destination without it is treated as not
|
||||||
present while the local index holds snapshots stored there:
|
present while the local index holds snapshots stored there:
|
||||||
`snapshot create` refuses to write to it, `prune` and `snapshot purge`
|
`snapshot create` refuses to write to it, `prune`, `snapshot purge`
|
||||||
fail, and `snapshot list` and `snapshot remove` behave as for a
|
and `remote nuke` fail, and `snapshot list` and `snapshot remove`
|
||||||
destination that cannot be listed. This keeps an unplugged volume's empty mount point
|
behave as for a destination that cannot be listed. This keeps an
|
||||||
from being taken for a destination whose snapshots are all gone. Mount
|
unplugged volume's empty mount point from being taken for a
|
||||||
|
destination whose snapshots are all gone. Mount
|
||||||
the volume if it is removable; if the destination was emptied on
|
the volume if it is removable; if the destination was emptied on
|
||||||
purpose, run `vaultik database delete` and back up again.
|
purpose, run `vaultik database delete` and back up again.
|
||||||
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The metadata subdirectory is named with the **remote key**, a one-way hash of th
|
|||||||
|
|
||||||
## Marker File (`vaultik-destination.txt`)
|
## Marker File (`vaultik-destination.txt`)
|
||||||
|
|
||||||
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove` and `snapshot list` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. A destination without it is treated as not present while the local index holds snapshots stored there: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,23 @@ func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
|||||||
assertUntouched(ctx, t, repos, storeDir)
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
|
||||||
|
// fails, deletes no local snapshot record and does not report the
|
||||||
|
// destination empty when the destination has no marker.
|
||||||
|
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.NukeRemote(true)
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||||
// remove warns that the metadata could not be removed from a destination
|
// remove warns that the metadata could not be removed from a destination
|
||||||
// without a marker, instead of reporting that it was.
|
// without a marker, instead of reporting that it was.
|
||||||
|
|||||||
@@ -188,3 +188,39 @@ func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
|
|||||||
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
||||||
err.Error())
|
err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalIndexWhenDestinationMissing checks that remote
|
||||||
|
// nuke fails on a destination it cannot list, leaves the local index
|
||||||
|
// unchanged, and does not report the destination empty.
|
||||||
|
func TestNukeRemoteKeepsLocalIndexWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpThenUnplug(ctx, t)
|
||||||
|
|
||||||
|
filesBefore, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, filesBefore)
|
||||||
|
|
||||||
|
blobsBefore, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, blobsBefore)
|
||||||
|
|
||||||
|
err = v.NukeRemote(true)
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "listing remote snapshots")
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "nuke must delete no local snapshot record")
|
||||||
|
|
||||||
|
files, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, files, len(filesBefore), "nuke must delete no local file record")
|
||||||
|
|
||||||
|
blobs, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, blobs, len(blobsBefore), "nuke must delete no local blob record")
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,32 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted checks that
|
||||||
|
// remote nuke keeps a snapshot in the local index while its metadata
|
||||||
|
// cannot be deleted from the destination store: dropped from the local
|
||||||
|
// index, it would be left there as a remote-only snapshot without its
|
||||||
|
// name.
|
||||||
|
func TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
store := faultstore.New(v.Storage)
|
||||||
|
store.OnDelete = func(key string) bool {
|
||||||
|
return strings.HasPrefix(key, "metadata/")
|
||||||
|
}
|
||||||
|
v.Storage = store
|
||||||
|
|
||||||
|
require.Error(t, v.NukeRemote(true))
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
||||||
// it does not recognise cannot be deleted, does not report the store
|
// it does not recognise cannot be deleted, does not report the store
|
||||||
// empty but names that object and fails.
|
// empty but names that object and fails.
|
||||||
@@ -154,8 +180,7 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
|
|||||||
|
|
||||||
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
||||||
// snapshot remote nuke cannot remove from the local index fails the
|
// snapshot remote nuke cannot remove from the local index fails the
|
||||||
// run: nuke then leaves that snapshot's metadata on the destination
|
// run, and that nuke then does not report the destination store empty.
|
||||||
// store.
|
|
||||||
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -1516,7 +1516,9 @@ func (v *Vaultik) removeSnapshotRemote(
|
|||||||
// database from the local index, and (with --remote) every snapshot
|
// database from the local index, and (with --remote) every snapshot
|
||||||
// metadata directory in remote storage. Both sides are processed so a
|
// metadata directory in remote storage. Both sides are processed so a
|
||||||
// "remove --all" leaves nothing behind, even when the local DB and
|
// "remove --all" leaves nothing behind, even when the local DB and
|
||||||
// remote storage have diverged.
|
// remote storage have diverged. Unless LocalOnly is set, it fails
|
||||||
|
// before it removes anything when the destination store cannot be
|
||||||
|
// listed or is not present by checkDestinationMarker.
|
||||||
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1529,11 +1531,15 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
||||||
// destination store; failures are downgraded to a warning so a
|
// destination store. Only for a local-only remove is a failure
|
||||||
// permission-denied or unreachable remote can't block a local-only
|
// downgraded to a warning, so a permission-denied or unreachable
|
||||||
// remove.
|
// remote can't block it.
|
||||||
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
||||||
if remoteErr != nil {
|
if remoteErr != nil {
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
return nil, remoteErr
|
||||||
|
}
|
||||||
|
|
||||||
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
||||||
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
||||||
}
|
}
|
||||||
@@ -1595,8 +1601,7 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|||||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||||
// (subdirectories under metadata/) currently present in the
|
// (subdirectories under metadata/) currently present in the
|
||||||
// destination store. Returns (nil, err) when the store cannot be
|
// destination store. Returns (nil, err) when the store cannot be
|
||||||
// listed, or is not present by checkDestinationMarker; callers must
|
// listed, or is not present by checkDestinationMarker.
|
||||||
// treat that as "no remote info available," not fatal.
|
|
||||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||||
log.Info("Listing all remote snapshots")
|
log.Info("Listing all remote snapshots")
|
||||||
|
|
||||||
@@ -1780,10 +1785,12 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeAllLocalSnapshots removes each known local snapshot (and, unless
|
// removeAllLocalSnapshots removes each known local snapshot's remote
|
||||||
// --local-only, its remote metadata), returning the number of snapshots
|
// metadata, unless --local-only, and then its local record, returning
|
||||||
// it could not remove. A snapshot left in the local index keeps its
|
// the number of snapshots it could not remove. The local record goes
|
||||||
// remote metadata too, since it is not an orphan remote key.
|
// only once the metadata is deleted: dropped first, it would leave that
|
||||||
|
// metadata on the destination store as a remote-only snapshot whose name
|
||||||
|
// is lost.
|
||||||
func (v *Vaultik) removeAllLocalSnapshots(
|
func (v *Vaultik) removeAllLocalSnapshots(
|
||||||
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
||||||
) int {
|
) int {
|
||||||
@@ -1792,6 +1799,19 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
for _, snapshotID := range localSnaps {
|
for _, snapshotID := range localSnaps {
|
||||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
_, err := v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to remove snapshot metadata from remote",
|
||||||
|
"snapshot_id", snapshotID, "error", err)
|
||||||
|
|
||||||
|
failures++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to remove from local database",
|
log.Error("Failed to remove from local database",
|
||||||
@@ -1802,17 +1822,6 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(
|
|
||||||
snapshot.RemoteSnapshotKey(snapshotID))
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to remove snapshot metadata from remote",
|
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
|
|
||||||
failures++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user