Merge branch 'feature/snapshot-rm-removes-unique-blobs'
All checks were successful
check / check (push) Successful in 2m6s
All checks were successful
check / check (push) Successful in 2m6s
This commit is contained in:
16
README.md
16
README.md
@@ -99,7 +99,7 @@ vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune]
|
|||||||
vaultik [--config <path>] snapshot list [--json]
|
vaultik [--config <path>] snapshot list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id|--all> [--dry-run] [--force] [--remote] [--json]
|
vaultik [--config <path>] snapshot remove <snapshot-id|--all> [--dry-run] [--force] [--local-only] [--json]
|
||||||
vaultik [--config <path>] snapshot cleanup
|
vaultik [--config <path>] snapshot cleanup
|
||||||
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
||||||
vaultik [--config <path>] prune [--force] [--json]
|
vaultik [--config <path>] prune [--force] [--json]
|
||||||
@@ -198,11 +198,15 @@ latest globally).
|
|||||||
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
**`snapshot remove`**: Remove a specific snapshot from the local database.
|
**`snapshot remove`**: Remove a snapshot. By default this removes the
|
||||||
Automatically cleans up local rows (files, chunks, blobs) that the removed
|
snapshot from the local index, strips the snapshot's metadata from the
|
||||||
snapshot was the last referrer for — you don't need a separate prune step
|
backup destination store, and prunes any blobs that are no longer
|
||||||
after removal.
|
referenced by any remaining remote manifest. Local row cleanup (files,
|
||||||
* `--remote`: Also remove snapshot metadata from remote storage
|
chunks, blobs the snapshot was the last referrer for) runs automatically;
|
||||||
|
no separate prune step is needed. If the destination store is unreachable,
|
||||||
|
the local-DB removal still completes and a warning is emitted; rerun
|
||||||
|
`vaultik prune` once the store is reachable to finish remote cleanup.
|
||||||
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--all`: Remove all snapshots (requires `--force`)
|
* `--all`: Remove all snapshots (requires `--force`)
|
||||||
* `--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
|
||||||
|
|||||||
@@ -324,14 +324,19 @@ func newSnapshotRemoveCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove [snapshot-id]",
|
Use: "remove [snapshot-id]",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a snapshot from the local database",
|
Short: "Remove a snapshot from local index and remote storage",
|
||||||
Long: `Removes a snapshot from the local database.
|
Long: `Removes a snapshot.
|
||||||
|
|
||||||
By default, only removes from the local database. Use --remote to also remove
|
By default, this removes the snapshot from the local index database, strips
|
||||||
the snapshot metadata from remote storage.
|
the snapshot's metadata from the backup destination store, and prunes any
|
||||||
|
blobs that are no longer referenced by any remaining remote snapshot.
|
||||||
|
|
||||||
Note: This does NOT remove blobs. Use 'vaultik prune' to remove orphaned blobs
|
Use --local-only to skip the remote half (e.g. when you want to forget a
|
||||||
after removing snapshots.
|
snapshot locally without touching the destination store).
|
||||||
|
|
||||||
|
If the remote is unreachable, the local-database removal still completes
|
||||||
|
and a warning is emitted; rerun 'vaultik prune' once the destination store
|
||||||
|
is reachable to finish remote cleanup.
|
||||||
|
|
||||||
Use --all --force to remove all snapshots.`,
|
Use --all --force to remove all snapshots.`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -408,7 +413,7 @@ Use --all --force to remove all snapshots.`,
|
|||||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be removed without removing")
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be removed without removing")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
||||||
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Also remove snapshot metadata from remote storage")
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false, "Skip remote cleanup; only touch the local index")
|
||||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Remove all snapshots (requires --force)")
|
cmd.Flags().BoolVar(&opts.All, "all", false, "Remove all snapshots (requires --force)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Begin("Removing all snapshot metadata from backup destination store.")
|
v.UI.Begin("Removing all snapshot metadata from backup destination store.")
|
||||||
if _, err := v.RemoveAllSnapshots(&RemoveOptions{Force: true, Remote: true}); err != nil {
|
if _, err := v.RemoveAllSnapshots(&RemoveOptions{Force: true}); err != nil {
|
||||||
return fmt.Errorf("removing all snapshots: %w", err)
|
return fmt.Errorf("removing all snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
v.UI.Begin("Removing all blobs from backup destination store.")
|
v.UI.Begin("Removing any blobs still present in backup destination store.")
|
||||||
if err := v.PruneBlobs(&PruneOptions{Force: true}); err != nil {
|
if err := v.PruneBlobs(&PruneOptions{Force: true}); err != nil {
|
||||||
return fmt.Errorf("pruning blobs: %w", err)
|
return fmt.Errorf("pruning blobs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,11 @@ func addBlob(t *testing.T, store *testStorer, hash string) {
|
|||||||
// Unit Tests for RemoveSnapshot
|
// Unit Tests for RemoveSnapshot
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
func TestRemoveSnapshot_LocalOnly(t *testing.T) {
|
// TestRemoveSnapshot_LocalOnly_PreservesRemote confirms that
|
||||||
|
// --local-only opts out of the remote-cleanup half: the snapshot is
|
||||||
|
// removed from the local index, but the remote metadata and blobs are
|
||||||
|
// untouched.
|
||||||
|
func TestRemoveSnapshot_LocalOnly_PreservesRemote(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
@@ -199,49 +203,61 @@ func TestRemoveSnapshot_LocalOnly(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{Force: true, LocalOnly: true}
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.Equal(t, 0, result.BlobsDeleted)
|
||||||
|
|
||||||
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshot_DefaultFullCleanup is the canonical case: no
|
||||||
|
// flags. The local-DB entry is removed, the snapshot's metadata is
|
||||||
|
// removed from the destination store, and any blob that was unique to
|
||||||
|
// this snapshot (i.e. not referenced by any remaining manifest) is
|
||||||
|
// pruned from the destination store too.
|
||||||
|
func TestRemoveSnapshot_DefaultFullCleanup(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
|
||||||
|
blobUnique := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
blobShared := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
addManifest(t, store, "snapshot-001", []string{blobUnique, blobShared})
|
||||||
|
addManifest(t, store, "snapshot-002", []string{blobShared})
|
||||||
|
addBlob(t, store, blobUnique)
|
||||||
|
addBlob(t, store, blobShared)
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{Force: true}
|
opts := &vaultik.RemoveOptions{Force: true}
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
|
||||||
assert.False(t, result.RemoteRemoved)
|
|
||||||
|
|
||||||
// Blobs should NOT be deleted (that's what prune is for)
|
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
|
||||||
// Remote metadata should NOT be deleted (no --remote flag)
|
|
||||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
|
||||||
|
|
||||||
// Verify output
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveSnapshot_WithRemote(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
|
|
||||||
store := newTestStorer()
|
|
||||||
|
|
||||||
blobA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
||||||
addManifest(t, store, "snapshot-001", []string{blobA})
|
|
||||||
addBlob(t, store, blobA)
|
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{Force: true, Remote: true}
|
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
||||||
assert.True(t, result.RemoteRemoved)
|
assert.True(t, result.RemoteRemoved)
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted, "exactly the unique blob should be deleted")
|
||||||
|
|
||||||
// Blobs should NOT be deleted
|
// Snapshot-001's metadata gone.
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
|
||||||
// Remote metadata SHOULD be deleted
|
|
||||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
// Snapshot-002 untouched.
|
||||||
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-002", "manifest.json.zst")))
|
||||||
|
// Unique blob deleted.
|
||||||
|
assert.False(t, store.hasKey("blobs/aa/aa/"+blobUnique))
|
||||||
|
// Shared blob preserved (still referenced by snapshot-002).
|
||||||
|
assert.True(t, store.hasKey("blobs/bb/bb/"+blobShared))
|
||||||
|
|
||||||
// Verify output mentions prune
|
out := tv.Stdout.String()
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
assert.Contains(t, out, "Removed snapshot 'snapshot-001' from local database")
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed snapshot metadata from remote storage")
|
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||||
assert.Contains(t, tv.Stdout.String(), "Run 'vaultik prune' to remove orphaned blobs")
|
assert.Contains(t, out, "Removed 1 unreferenced blob")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||||
@@ -257,18 +273,16 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{Force: true, DryRun: true, Remote: true}
|
opts := &vaultik.RemoveOptions{Force: true, DryRun: true}
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, result.DryRun)
|
assert.True(t, result.DryRun)
|
||||||
|
|
||||||
// Nothing should be deleted
|
|
||||||
assert.Equal(t, initialCount, store.keyCount())
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify dry run message
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,22 +314,22 @@ func TestRemoveAllSnapshots_WithForce(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true, Remote: true}
|
opts := &vaultik.RemoveOptions{All: true, Force: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, result.SnapshotsRemoved, 2)
|
assert.Len(t, result.SnapshotsRemoved, 2)
|
||||||
assert.True(t, result.RemoteRemoved)
|
assert.True(t, result.RemoteRemoved)
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted)
|
||||||
|
|
||||||
// Blobs should NOT be deleted
|
assert.False(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
|
||||||
// Remote metadata SHOULD be deleted
|
|
||||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-002", "manifest.json.zst")))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-002", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify output
|
out := tv.Stdout.String()
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)")
|
assert.Contains(t, out, "Removed 2 snapshot(s)")
|
||||||
assert.Contains(t, tv.Stdout.String(), "Run 'vaultik prune' to remove orphaned blobs")
|
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
|
||||||
|
assert.Contains(t, out, "Removed 1 unreferenced blob")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
||||||
@@ -329,20 +343,18 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
// --remote is required to enumerate orphan remote keys; without
|
// Default (no LocalOnly) enumerates the orphan remote keys, which
|
||||||
// it, RemoveAll only acts on local snapshots, and NewForTesting
|
// matches what NewForTesting has — local DB is empty, so the two
|
||||||
// has no local DB.
|
// addManifest calls land as orphan remote keys.
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true, Remote: true}
|
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, result.DryRun)
|
assert.True(t, result.DryRun)
|
||||||
assert.Len(t, result.SnapshotsRemoved, 2)
|
assert.Len(t, result.SnapshotsRemoved, 2)
|
||||||
|
|
||||||
// Nothing should be deleted
|
|
||||||
assert.Equal(t, initialCount, store.keyCount())
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
|
|
||||||
// Verify dry run message
|
|
||||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -976,7 +976,7 @@ type RemoveOptions struct {
|
|||||||
Force bool
|
Force bool
|
||||||
DryRun bool
|
DryRun bool
|
||||||
JSON bool
|
JSON bool
|
||||||
Remote bool // Also remove metadata from remote storage
|
LocalOnly bool // Skip remote cleanup; only touch the local index
|
||||||
All bool // Remove all snapshots (requires Force)
|
All bool // Remove all snapshots (requires Force)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,11 +985,17 @@ type RemoveResult struct {
|
|||||||
SnapshotID string `json:"snapshot_id,omitempty"`
|
SnapshotID string `json:"snapshot_id,omitempty"`
|
||||||
SnapshotsRemoved []string `json:"snapshots_removed,omitempty"`
|
SnapshotsRemoved []string `json:"snapshots_removed,omitempty"`
|
||||||
RemoteRemoved bool `json:"remote_removed,omitempty"`
|
RemoteRemoved bool `json:"remote_removed,omitempty"`
|
||||||
|
BlobsDeleted int `json:"blobs_deleted,omitempty"`
|
||||||
|
BytesFreed int64 `json:"bytes_freed,omitempty"`
|
||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSnapshot removes a snapshot from the local database and optionally from remote storage
|
// RemoveSnapshot removes a snapshot from the local index database and,
|
||||||
// Note: This does NOT remove blobs. Use 'vaultik prune' to remove orphaned blobs.
|
// unless LocalOnly is set, also strips the snapshot's metadata from the
|
||||||
|
// destination store and prunes any blobs that are no longer referenced
|
||||||
|
// by any remaining remote snapshot. When the remote is unreachable the
|
||||||
|
// command still completes the local-DB removal and warns; callers can
|
||||||
|
// retry remote cleanup later with `vaultik prune`.
|
||||||
func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
result := &RemoveResult{
|
result := &RemoveResult{
|
||||||
SnapshotID: snapshotID,
|
SnapshotID: snapshotID,
|
||||||
@@ -999,8 +1005,8 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
|
|||||||
result.DryRun = true
|
result.DryRun = true
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.printfStdout("Would remove snapshot: %s\n", snapshotID)
|
v.printfStdout("Would remove snapshot: %s\n", snapshotID)
|
||||||
if opts.Remote {
|
if !opts.LocalOnly {
|
||||||
v.printlnStdout("Would also remove from remote storage")
|
v.printlnStdout("Would also remove metadata and any unique blobs from remote storage")
|
||||||
}
|
}
|
||||||
v.printlnStdout("[Dry run - no changes made]")
|
v.printlnStdout("[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
@@ -1010,12 +1016,11 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm unless --force is used (skip in JSON mode - require --force)
|
|
||||||
if !opts.Force && !opts.JSON {
|
if !opts.Force && !opts.JSON {
|
||||||
if opts.Remote {
|
if opts.LocalOnly {
|
||||||
v.printfStdout("Remove snapshot '%s' from local database and remote storage? [y/N] ", snapshotID)
|
v.printfStdout("Remove snapshot '%s' from local database (remote untouched)? [y/N] ", snapshotID)
|
||||||
} else {
|
} else {
|
||||||
v.printfStdout("Remove snapshot '%s' from local database? [y/N] ", snapshotID)
|
v.printfStdout("Remove snapshot '%s' from local database AND remote storage (including any blobs unique to this snapshot)? [y/N] ", snapshotID)
|
||||||
}
|
}
|
||||||
var confirm string
|
var confirm string
|
||||||
if _, err := v.scanStdin(&confirm); err != nil {
|
if _, err := v.scanStdin(&confirm); err != nil {
|
||||||
@@ -1030,45 +1035,90 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
|
|||||||
|
|
||||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
// Remove from local database
|
|
||||||
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
||||||
return result, fmt.Errorf("removing from local database: %w", err)
|
return result, fmt.Errorf("removing from local database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If --remote, also remove from remote storage
|
if !opts.LocalOnly {
|
||||||
if opts.Remote {
|
|
||||||
log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID)
|
||||||
if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
return result, fmt.Errorf("removing from remote storage: %w", err)
|
if err := v.deleteRemoteSnapshotByKey(remoteKey); err != nil {
|
||||||
|
// Per design: warn-and-proceed; the local-DB removal has
|
||||||
|
// already happened, so the user can retry remote cleanup
|
||||||
|
// with `vaultik prune` later.
|
||||||
|
log.Warn("Could not remove snapshot metadata from remote storage", "error", err)
|
||||||
|
if v.UI != nil {
|
||||||
|
v.UI.Warning("Could not remove snapshot metadata from remote: %v. Run 'vaultik prune' once the remote is reachable to finish cleanup.", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
result.RemoteRemoved = true
|
result.RemoteRemoved = true
|
||||||
|
blobsDeleted, bytesFreed, pruneErr := v.pruneUnreferencedBlobsAfterRemoval()
|
||||||
|
if pruneErr != nil {
|
||||||
|
log.Warn("Failed to prune unreferenced blobs after snapshot removal", "error", pruneErr)
|
||||||
|
if v.UI != nil {
|
||||||
|
v.UI.Warning("Snapshot metadata removed, but blob cleanup failed: %v. Run 'vaultik prune' to retry.", pruneErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.BlobsDeleted = blobsDeleted
|
||||||
|
result.BytesFreed = bytesFreed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the local rows that just became orphaned (files, chunks,
|
|
||||||
// blob_chunks, blobs no longer referenced by any snapshot). This
|
|
||||||
// used to be a separate `vaultik snapshot prune` step; running it
|
|
||||||
// inline means `snapshot remove` leaves no ghost rows behind.
|
|
||||||
if v.SnapshotManager != nil {
|
if v.SnapshotManager != nil {
|
||||||
if err := v.SnapshotManager.CleanupOrphanedData(v.ctx); err != nil {
|
if err := v.SnapshotManager.CleanupOrphanedData(v.ctx); err != nil {
|
||||||
log.Warn("Failed to clean up orphaned local data after removal", "error", err)
|
log.Warn("Failed to clean up orphaned local data after removal", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output result
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
return result, v.outputRemoveJSON(result)
|
return result, v.outputRemoveJSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print summary
|
|
||||||
v.printfStdout("Removed snapshot '%s' from local database\n", snapshotID)
|
v.printfStdout("Removed snapshot '%s' from local database\n", snapshotID)
|
||||||
if opts.Remote {
|
if !opts.LocalOnly && result.RemoteRemoved {
|
||||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||||
v.printlnStdout("\nNote: Remote blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.")
|
if result.BlobsDeleted > 0 {
|
||||||
|
v.printfStdout("Removed %d unreferenced blob(s) (%s freed)\n",
|
||||||
|
result.BlobsDeleted, humanize.Bytes(uint64(result.BytesFreed)))
|
||||||
|
} else {
|
||||||
|
v.printlnStdout("No blobs unique to this snapshot were found.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pruneUnreferencedBlobsAfterRemoval deletes blobs no longer referenced
|
||||||
|
// by any remaining remote manifest. Used by RemoveSnapshot /
|
||||||
|
// RemoveAllSnapshots after metadata has been stripped from the
|
||||||
|
// destination store; with the just-removed snapshot's manifest gone,
|
||||||
|
// any blobs that were only referenced by it become unreferenced and
|
||||||
|
// are swept here.
|
||||||
|
func (v *Vaultik) pruneUnreferencedBlobsAfterRemoval() (int, int64, error) {
|
||||||
|
referenced, err := v.collectReferencedBlobs()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("collecting referenced blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allBlobs, err := v.listAllRemoteBlobs()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("listing remote blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreferenced, totalSize := v.findUnreferencedBlobs(allBlobs, referenced)
|
||||||
|
if len(unreferenced) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &PruneBlobsResult{BlobsFound: len(unreferenced)}
|
||||||
|
log.Info("Pruning unreferenced blobs after snapshot removal",
|
||||||
|
"count", len(unreferenced),
|
||||||
|
"size", humanize.Bytes(uint64(totalSize)))
|
||||||
|
v.deleteUnreferencedBlobs(unreferenced, allBlobs, result)
|
||||||
|
return result.BlobsDeleted, result.BytesFreed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveAllSnapshots removes every snapshot known to the local
|
// RemoveAllSnapshots removes every snapshot known to the local
|
||||||
// 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
|
||||||
@@ -1176,7 +1226,7 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
result := &RemoveResult{DryRun: true}
|
result := &RemoveResult{DryRun: true}
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, localSnaps...)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, localSnaps...)
|
||||||
if opts.Remote {
|
if !opts.LocalOnly {
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, orphanRemoteKeys...)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, orphanRemoteKeys...)
|
||||||
}
|
}
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
@@ -1184,14 +1234,17 @@ func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, o
|
|||||||
for _, id := range localSnaps {
|
for _, id := range localSnaps {
|
||||||
v.printfStdout(" %s\n", id)
|
v.printfStdout(" %s\n", id)
|
||||||
}
|
}
|
||||||
if opts.Remote && len(orphanRemoteKeys) > 0 {
|
if !opts.LocalOnly {
|
||||||
|
if len(orphanRemoteKeys) > 0 {
|
||||||
v.printfStdout("Would also remove %d orphan remote snapshot key(s):\n", len(orphanRemoteKeys))
|
v.printfStdout("Would also remove %d orphan remote snapshot key(s):\n", len(orphanRemoteKeys))
|
||||||
for _, key := range orphanRemoteKeys {
|
for _, key := range orphanRemoteKeys {
|
||||||
v.printfStdout(" %s\n", key)
|
v.printfStdout(" %s\n", key)
|
||||||
}
|
}
|
||||||
} else if opts.Remote {
|
} else {
|
||||||
v.printlnStdout("Would also remove from remote storage")
|
v.printlnStdout("Would also remove from remote storage")
|
||||||
}
|
}
|
||||||
|
v.printlnStdout("Would then prune all unreferenced blobs from remote storage")
|
||||||
|
}
|
||||||
v.printlnStdout("[Dry run - no changes made]")
|
v.printlnStdout("[Dry run - no changes made]")
|
||||||
}
|
}
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
@@ -1200,11 +1253,11 @@ func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, o
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRemoveAll deletes every local snapshot (and, with --remote,
|
// executeRemoveAll deletes every local snapshot and, unless LocalOnly
|
||||||
// every corresponding remote metadata directory plus any orphan remote
|
// is set, every corresponding remote metadata directory plus any
|
||||||
// keys that don't match a local snapshot).
|
// orphan remote keys, then prunes the resulting set of unreferenced
|
||||||
|
// blobs from the destination store.
|
||||||
func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
// --all requires --force
|
|
||||||
if !opts.Force {
|
if !opts.Force {
|
||||||
return nil, fmt.Errorf("--all requires --force")
|
return nil, fmt.Errorf("--all requires --force")
|
||||||
}
|
}
|
||||||
@@ -1212,6 +1265,7 @@ func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *
|
|||||||
log.Info("Removing all snapshots", "local_count", len(localSnaps), "orphan_remote_count", len(orphanRemoteKeys))
|
log.Info("Removing all snapshots", "local_count", len(localSnaps), "orphan_remote_count", len(orphanRemoteKeys))
|
||||||
|
|
||||||
result := &RemoveResult{}
|
result := &RemoveResult{}
|
||||||
|
remoteErrors := 0
|
||||||
for _, snapshotID := range localSnaps {
|
for _, snapshotID := range localSnaps {
|
||||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
@@ -1220,33 +1274,44 @@ func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Remote {
|
if !opts.LocalOnly {
|
||||||
if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
|
if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
|
||||||
log.Error("Failed to remove from remote", "snapshot_id", snapshotID, "error", err)
|
log.Warn("Failed to remove snapshot metadata from remote", "snapshot_id", snapshotID, "error", err)
|
||||||
continue
|
remoteErrors++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Remote {
|
if !opts.LocalOnly {
|
||||||
for _, key := range orphanRemoteKeys {
|
for _, key := range orphanRemoteKeys {
|
||||||
log.Info("Removing orphan remote snapshot", "remote_key", key)
|
log.Info("Removing orphan remote snapshot", "remote_key", key)
|
||||||
if err := v.deleteRemoteSnapshotByKey(key); err != nil {
|
if err := v.deleteRemoteSnapshotByKey(key); err != nil {
|
||||||
log.Error("Failed to remove orphan from remote", "remote_key", key, "error", err)
|
log.Warn("Failed to remove orphan from remote", "remote_key", key, "error", err)
|
||||||
|
remoteErrors++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, key)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, key)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Remote {
|
if remoteErrors == 0 {
|
||||||
result.RemoteRemoved = true
|
result.RemoteRemoved = true
|
||||||
|
blobsDeleted, bytesFreed, pruneErr := v.pruneUnreferencedBlobsAfterRemoval()
|
||||||
|
if pruneErr != nil {
|
||||||
|
log.Warn("Failed to prune unreferenced blobs after bulk removal", "error", pruneErr)
|
||||||
|
if v.UI != nil {
|
||||||
|
v.UI.Warning("Bulk metadata removal succeeded, but blob cleanup failed: %v. Run 'vaultik prune' to retry.", pruneErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.BlobsDeleted = blobsDeleted
|
||||||
|
result.BytesFreed = bytesFreed
|
||||||
|
}
|
||||||
|
} else if v.UI != nil {
|
||||||
|
v.UI.Warning("Some remote metadata deletions failed; skipping automatic blob prune. Run 'vaultik prune' once the remote is healthy.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up everything that just became orphaned locally so the
|
|
||||||
// index database doesn't carry 39k ghost rows after a wipe.
|
|
||||||
if v.SnapshotManager != nil {
|
if v.SnapshotManager != nil {
|
||||||
if err := v.SnapshotManager.CleanupOrphanedData(v.ctx); err != nil {
|
if err := v.SnapshotManager.CleanupOrphanedData(v.ctx); err != nil {
|
||||||
log.Warn("Failed to clean up orphaned local data after bulk removal", "error", err)
|
log.Warn("Failed to clean up orphaned local data after bulk removal", "error", err)
|
||||||
@@ -1258,9 +1323,14 @@ func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.printfStdout("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
v.printfStdout("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
||||||
if opts.Remote {
|
if !opts.LocalOnly && result.RemoteRemoved {
|
||||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||||
v.printlnStdout("\nNote: Remote blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.")
|
if result.BlobsDeleted > 0 {
|
||||||
|
v.printfStdout("Removed %d unreferenced blob(s) (%s freed)\n",
|
||||||
|
result.BlobsDeleted, humanize.Bytes(uint64(result.BytesFreed)))
|
||||||
|
} else {
|
||||||
|
v.printlnStdout("No unreferenced blobs were found.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user