All checks were successful
check / check (pull_request) Successful in 2m29s
CleanupLocalSnapshots wrote three prose lines to stdout with no --json awareness, and they covered every branch of the function, so no input avoided them: `vaultik prune --json | jq` failed even after the banner fix. -q never helped either, because printlnStdout and stdoutf write straight to Vaultik.Stdout and never consult Vaultik.UI, which is what SetQuiet affects. The issue offered three fixes and asked for a decision. Taken: thread *PruneOptions into the function and gate each write on !opts.JSON, matching PruneBlobs -- its sibling phase, which already takes the same struct -- along with RemoveSnapshot and remote info, so the package has one pattern rather than two. Rejected: moving the lines to log.Info, because the logger's default level is slog.LevelWarn, so that would not relocate them to stderr, it would delete them from a plain `vaultik prune`, and the removal of rows from the local index is not something to narrate only under --verbose. Also rejected: putting the stale-record count into PruneBlobsResult, whose every field is blob-scoped and which is produced by the later phase; a prune document covering both phases is a reasonable thing to want, but that is a schema design question, not a stream-hygiene fix. The two events are duplicated as log.Info records, which PruneBlobs already does alongside its own prints, so they survive on stderr under --verbose. Also closes #110. `make build` printed "Nothing to be done for 'build'" and exited 0 without producing a binary: build was listed in .PHONY with no build: rule anywhere, and declaring a name phony is exactly what converts make's "No rule to make target" error into a silent success. Fixed with `build: vaultik`, keeping vaultik: as the file rule. Audited all 19 .PHONY names: build was the only one without a rule, and vaultik is correctly absent from .PHONY, being a real file target. Tests, each verified to fail with the fix reverted rather than assumed to. CleanupLocalSnapshots leaves stdout untouched under --json in all three branches (stale records, none, empty index) and still emits every line without it, so the guard cannot be satisfied by deleting the output. prune --json runs end to end through Entry, cobra and fx over the process's real stdout descriptor against a file:// store, asserting exactly one JSON document, in both the stale and non-stale branches. And a parse of the Makefile asserts every .PHONY name has a rule and that build reaches the rule producing the binary, which keeps the audit true for names added later; it is a parse rather than an invocation of make because `make test` is what runs it, so shelling back into `make build` would nest a build inside the test run. The property a parse cannot establish -- that the recipe still fails when the build fails -- was verified by hand against a deliberately broken tree: make build exits 2 and produces nothing. cmd/vaultik gains its first test file, so `make test` now reports 16 packages ok where it reported 15. flagConfig and programName constants are extracted in the CLI tests because the new argument vector pushed "--config" and "vaultik" over goconst's threshold. README's stdout/stderr section described the banner as "the other thing that writes to stdout", which this defect contradicted; it now states the contract that holds, which is that stdout under --json is the document and nothing else, for prune as well as for the other four.
325 lines
8.6 KiB
Go
325 lines
8.6 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)")
|
|
|
|
// 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. 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
v.UI.Completef("Backup destination store is now empty.")
|
|
|
|
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 {
|
|
return v.outputPruneBlobsJSON(result)
|
|
}
|
|
|
|
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
|
result.BlobsDeleted, ubytes(result.BytesFreed))
|
|
|
|
if result.BlobsFailed > 0 {
|
|
v.stdoutf("Failed to delete %d blob(s)\n", result.BlobsFailed)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// collectReferencedBlobs downloads all manifests and returns the set of
|
|
// referenced blob hashes.
|
|
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)
|
|
manifestCount := 0
|
|
|
|
for _, remoteKey := range remoteKeys {
|
|
log.Debug("Processing manifest", "remote_key", remoteKey)
|
|
|
|
manifest, err := v.downloadManifestByKey(remoteKey)
|
|
if err != nil {
|
|
log.Error("Failed to download manifest", "remote_key", remoteKey, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
for _, blob := range manifest.Blobs {
|
|
allBlobsReferenced[blob.Hash] = true
|
|
}
|
|
|
|
manifestCount++
|
|
}
|
|
|
|
log.Info("Processed manifests",
|
|
"count", manifestCount, "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" {
|
|
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)
|
|
}
|