check / check (push) Successful in 17m58s
prune, and the prune step of snapshot create --prune, counted a blob it could not delete and still exited 0. It now prints its summary or --json document and then returns an error, which replaces the "Failed to delete N blob(s)" line. remote nuke only warned when a snapshot's metadata could not be deleted, advised vaultik prune, which never removes metadata, and then reported the store empty. It now stops with an error, does not report the store empty, and says to run vaultik remote nuke --force again. A successful nuke no longer advises prune for blobs it deletes itself. The faultstore test double gains an OnDelete hook. Judgement call: a snapshot nuke cannot remove from the local index also fails the run, since its metadata is then left on the store. Model: opus-5-5
374 lines
10 KiB
Go
374 lines
10 KiB
Go
package vaultik
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
)
|
|
|
|
// PruneOptions contains options for the prune command
|
|
type PruneOptions struct {
|
|
Force bool
|
|
JSON bool
|
|
}
|
|
|
|
// errNukeRequiresForce guards the destructive remote nuke operation.
|
|
var errNukeRequiresForce = errors.New(
|
|
"nuke requires --force (this deletes ALL remote snapshots and blobs)")
|
|
|
|
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
|
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
|
|
|
// nukeCommandHint is the command suggested when a remote nuke could not
|
|
// delete everything: running it again deletes what is left, which
|
|
// `vaultik prune` does not do for snapshot metadata.
|
|
const nukeCommandHint = "vaultik remote nuke --force"
|
|
|
|
// metadataDirName is the top-level remote directory holding snapshot
|
|
// metadata.
|
|
const metadataDirName = "metadata"
|
|
|
|
// NukeRemote deletes every snapshot's metadata and every blob from remote
|
|
// storage, along with any object an upload cut off part-way left under a
|
|
// temporary `.partial` name. After this returns successfully the bucket
|
|
// prefix is empty and the next backup starts from scratch.
|
|
//
|
|
// Refuses to run unless force is true. The caller is responsible for
|
|
// confirming with the user.
|
|
func (v *Vaultik) NukeRemote(force bool) error {
|
|
if !force {
|
|
return errNukeRequiresForce
|
|
}
|
|
|
|
err := v.emptyDestinationStore()
|
|
if err != nil {
|
|
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
|
"backup destination store.", nukeCommandHint)
|
|
|
|
return err
|
|
}
|
|
|
|
v.UI.Completef("Backup destination store is now empty.")
|
|
|
|
return nil
|
|
}
|
|
|
|
// emptyDestinationStore does the deleting for NukeRemote and stops at
|
|
// the first step that fails.
|
|
func (v *Vaultik) emptyDestinationStore() error {
|
|
v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
|
|
|
|
_, err := v.RemoveAllSnapshots(&RemoveOptions{Force: true})
|
|
if err != nil {
|
|
return fmt.Errorf("removing all snapshots: %w", err)
|
|
}
|
|
|
|
v.UI.Beginf("Removing any blobs still present in backup destination store.")
|
|
|
|
err = v.PruneBlobs(&PruneOptions{Force: true})
|
|
if err != nil {
|
|
return fmt.Errorf("pruning blobs: %w", err)
|
|
}
|
|
|
|
// The file and rclone listings skip `.partial` objects, so the two
|
|
// steps above never delete them.
|
|
for _, prefix := range []string{"metadata/", "blobs/"} {
|
|
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting partial uploads: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PruneBlobsResult contains the result of a blob prune operation
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established JSON output format
|
|
type PruneBlobsResult struct {
|
|
BlobsFound int `json:"blobs_found"`
|
|
BlobsDeleted int `json:"blobs_deleted"`
|
|
BlobsFailed int `json:"blobs_failed,omitempty"`
|
|
BytesFreed int64 `json:"bytes_freed"`
|
|
}
|
|
|
|
// Prune removes orphaned data from the local index database AND
|
|
// unreferenced blobs from the backup destination store. This is the
|
|
// single user-facing prune entry point — the split between local and
|
|
// remote cleanup is an implementation detail. Calling code should
|
|
// prefer this method over PruneDatabase or PruneBlobs individually
|
|
// unless it specifically wants one half.
|
|
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
|
err := v.EnsureStorageBinding()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// First reconcile local snapshot records against remote metadata:
|
|
// any local snapshot whose manifest is missing from the destination
|
|
// store is treated as gone. This used to be the separate 'snapshot
|
|
// cleanup' command and is now folded in so a single 'vaultik prune'
|
|
// gets the local index fully back in sync with the destination.
|
|
err = v.CleanupLocalSnapshots(opts)
|
|
if err != nil {
|
|
return fmt.Errorf("reconciling local snapshots with remote: %w", err)
|
|
}
|
|
|
|
_, err = v.PruneDatabase()
|
|
if err != nil {
|
|
return fmt.Errorf("pruning local database: %w", err)
|
|
}
|
|
|
|
return v.PruneBlobs(opts)
|
|
}
|
|
|
|
// PruneBlobs removes unreferenced blobs from storage
|
|
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|
log.Info("Starting prune operation")
|
|
|
|
allBlobsReferenced, err := v.collectReferencedBlobs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
allBlobs, err := v.listAllRemoteBlobs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
|
|
|
|
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
|
|
|
|
if len(unreferencedBlobs) == 0 {
|
|
log.Info("No unreferenced blobs found")
|
|
|
|
if opts.JSON {
|
|
return v.outputPruneBlobsJSON(result)
|
|
}
|
|
|
|
v.printlnStdout("No unreferenced blobs to remove.")
|
|
|
|
return nil
|
|
}
|
|
|
|
log.Info("Found unreferenced blobs",
|
|
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
|
|
|
if !opts.JSON {
|
|
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
|
|
len(unreferencedBlobs), ubytes(totalSize))
|
|
}
|
|
|
|
if !opts.Force && !opts.JSON {
|
|
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
|
|
|
|
var confirm string
|
|
|
|
_, err = v.scanStdin(&confirm)
|
|
if err != nil {
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return nil //nolint:nilerr // read failure means no confirmation
|
|
}
|
|
|
|
if strings.ToLower(confirm) != "y" {
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
|
|
|
if opts.JSON {
|
|
err = v.outputPruneBlobsJSON(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
|
result.BlobsDeleted, ubytes(result.BytesFreed))
|
|
}
|
|
|
|
if result.BlobsFailed > 0 {
|
|
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// collectReferencedBlobs downloads all manifests and returns the set of
|
|
// referenced blob hashes.
|
|
//
|
|
// Every manifest must be read successfully. A manifest that cannot be
|
|
// downloaded or decoded means its snapshot's blobs are unknown, so
|
|
// treating them as unreferenced would let prune delete data a snapshot
|
|
// still needs. Rather than risk that silent loss, any failure returns an
|
|
// error naming the remote key and prune deletes nothing.
|
|
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
|
log.Info("Listing remote snapshots")
|
|
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
|
// subdirectories under metadata/), not human snapshot IDs.
|
|
remoteKeys, err := v.listUniqueSnapshotIDs()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing snapshot keys: %w", err)
|
|
}
|
|
|
|
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
|
|
|
allBlobsReferenced := make(map[string]bool)
|
|
|
|
for _, remoteKey := range remoteKeys {
|
|
log.Debug("Processing manifest", "remote_key", remoteKey)
|
|
|
|
manifest, err := v.downloadManifestByKey(remoteKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading manifest %s: %w", remoteKey, err)
|
|
}
|
|
|
|
for _, blob := range manifest.Blobs {
|
|
allBlobsReferenced[blob.Hash] = true
|
|
}
|
|
}
|
|
|
|
log.Info("Processed manifests",
|
|
"count", len(remoteKeys), "unique_blobs_referenced", len(allBlobsReferenced))
|
|
|
|
return allBlobsReferenced, nil
|
|
}
|
|
|
|
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata
|
|
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
|
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
|
seen := make(map[string]bool)
|
|
|
|
var snapshotIDs []string
|
|
|
|
for object := range objectCh {
|
|
if object.Err != nil {
|
|
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
|
}
|
|
|
|
parts := strings.Split(object.Key, "/")
|
|
if len(parts) >= minSnapshotIDParts &&
|
|
parts[0] == metadataDirName && parts[1] != "" {
|
|
if strings.HasSuffix(object.Key, "/") ||
|
|
strings.Contains(object.Key, "/manifest.json.zst") {
|
|
snapshotID := parts[1]
|
|
if !seen[snapshotID] {
|
|
seen[snapshotID] = true
|
|
snapshotIDs = append(snapshotIDs, snapshotID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return snapshotIDs, nil
|
|
}
|
|
|
|
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
|
|
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
|
log.Info("Listing all blobs in storage")
|
|
|
|
allBlobs := make(map[string]int64)
|
|
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
|
|
|
for object := range blobObjectCh {
|
|
if object.Err != nil {
|
|
return nil, fmt.Errorf("listing blobs: %w", object.Err)
|
|
}
|
|
|
|
parts := strings.Split(object.Key, "/")
|
|
if len(parts) != blobKeyParts || parts[0] != "blobs" {
|
|
continue
|
|
}
|
|
|
|
// The object name is read from the destination store and is not
|
|
// trusted. A name that is not a blob hash (e.g. a short or
|
|
// non-hex string) would panic the later hash[:2]/hash[2:4] path
|
|
// build, so skip it with a warning rather than delete it.
|
|
if !isBlobHash(parts[3]) {
|
|
log.Warn("Skipping non-conforming object under blobs/",
|
|
"key", object.Key)
|
|
|
|
continue
|
|
}
|
|
|
|
allBlobs[parts[3]] = object.Size
|
|
}
|
|
|
|
log.Info("Found blobs in storage", "count", len(allBlobs))
|
|
|
|
return allBlobs, nil
|
|
}
|
|
|
|
// findUnreferencedBlobs returns blob hashes not referenced by any
|
|
// manifest and their total size.
|
|
func (v *Vaultik) findUnreferencedBlobs(
|
|
allBlobs map[string]int64, referenced map[string]bool,
|
|
) ([]string, int64) {
|
|
var (
|
|
unreferenced []string
|
|
totalSize int64
|
|
)
|
|
|
|
for hash, size := range allBlobs {
|
|
if !referenced[hash] {
|
|
unreferenced = append(unreferenced, hash)
|
|
totalSize += size
|
|
}
|
|
}
|
|
|
|
return unreferenced, totalSize
|
|
}
|
|
|
|
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
|
// populates the result.
|
|
func (v *Vaultik) deleteUnreferencedBlobs(
|
|
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
|
|
) {
|
|
log.Info("Deleting unreferenced blobs")
|
|
|
|
for i, hash := range unreferencedBlobs {
|
|
blobPath := fmt.Sprintf("blobs/%s/%s/%s", hash[:2], hash[2:4], hash)
|
|
|
|
err := v.Storage.Delete(v.ctx, blobPath)
|
|
if err != nil {
|
|
log.Error("Failed to delete blob", "hash", hash, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
result.BlobsDeleted++
|
|
result.BytesFreed += allBlobs[hash]
|
|
|
|
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
|
log.Info("Deletion progress",
|
|
"deleted", i+1,
|
|
"total", len(unreferencedBlobs),
|
|
"percent", fmt.Sprintf("%.1f%%",
|
|
float64(i+1)/float64(len(unreferencedBlobs))*percentScale),
|
|
)
|
|
}
|
|
}
|
|
|
|
result.BlobsFailed = len(unreferencedBlobs) - result.BlobsDeleted
|
|
|
|
log.Info("Prune complete",
|
|
"deleted_count", result.BlobsDeleted,
|
|
"deleted_size", ubytes(result.BytesFreed),
|
|
"failed", result.BlobsFailed,
|
|
)
|
|
}
|
|
|
|
// outputPruneBlobsJSON outputs the prune result as JSON
|
|
func (v *Vaultik) outputPruneBlobsJSON(result *PruneBlobsResult) error {
|
|
encoder := json.NewEncoder(v.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
|
|
return encoder.Encode(result)
|
|
}
|