1622 lines
48 KiB
Go
1622 lines
48 KiB
Go
package vaultik
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
)
|
|
|
|
// SnapshotCreateOptions contains options for the snapshot create command
|
|
type SnapshotCreateOptions struct {
|
|
Cron bool
|
|
Prune bool
|
|
KeepNewerThan string // With --prune: keep snapshots newer than this duration (e.g. "4w"); default: keep only latest
|
|
SkipErrors bool // Skip file read errors (log them loudly but continue)
|
|
Snapshots []string // Optional list of snapshot names to process (empty = all)
|
|
}
|
|
|
|
// CreateSnapshot executes the snapshot creation operation
|
|
func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|
overallStartTime := time.Now()
|
|
|
|
log.Info("Starting snapshot creation",
|
|
"version", v.Globals.Version,
|
|
"commit", v.Globals.Commit,
|
|
"index_path", v.Config.IndexPath,
|
|
)
|
|
|
|
err := v.EnsureStorageBinding()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up incomplete snapshots FIRST, before any scanning
|
|
// This is critical for data safety - see CleanupIncompleteSnapshots for details
|
|
hostname := v.Config.Hostname
|
|
if hostname == "" {
|
|
hostname, _ = os.Hostname()
|
|
}
|
|
|
|
// CRITICAL: This MUST succeed. If we fail to clean up incomplete snapshots,
|
|
// the deduplication logic will think files from the incomplete snapshot were
|
|
// already backed up and skip them, resulting in data loss.
|
|
//
|
|
// Prune the database before starting: delete incomplete snapshots and orphaned data.
|
|
// This ensures the database is consistent before we start a new snapshot.
|
|
// Since we use locking, only one vaultik instance accesses the DB at a time.
|
|
if _, err := v.PruneDatabase(); err != nil {
|
|
return fmt.Errorf("prune database: %w", err)
|
|
}
|
|
|
|
// Determine which snapshots to process
|
|
snapshotNames := opts.Snapshots
|
|
if len(snapshotNames) == 0 {
|
|
snapshotNames = v.Config.SnapshotNames()
|
|
} else {
|
|
// Validate requested snapshot names exist
|
|
for _, name := range snapshotNames {
|
|
if _, ok := v.Config.Snapshots[name]; !ok {
|
|
return fmt.Errorf("snapshot %q not found in config", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(snapshotNames) == 0 {
|
|
return errors.New("no snapshots configured")
|
|
}
|
|
|
|
// Process each named snapshot
|
|
for snapIdx, snapName := range snapshotNames {
|
|
err := v.createNamedSnapshot(opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Print overall summary if multiple snapshots
|
|
if len(snapshotNames) > 1 {
|
|
v.UI.Complete("All %d snapshots completed in %s.", len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
|
}
|
|
|
|
if opts.Prune {
|
|
err := v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
|
if err != nil {
|
|
return fmt.Errorf("post-backup prune: %w", err)
|
|
}
|
|
}
|
|
|
|
// Terminus must obey the --cron invariant: silent on total
|
|
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
|
// the success path), but if any warnings fired during the run we
|
|
// emit the summary via UI.Warning so cron actually delivers
|
|
// something for the user to look at.
|
|
if v.UI.WarningCount() > 0 {
|
|
v.UI.Warning("Finished with %d warning(s) — review the output above.", v.UI.WarningCount())
|
|
} else {
|
|
v.UI.Complete("Finished successfully.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runPostBackupPrune drops older snapshots of the given names and removes
|
|
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
|
// snapshots newer than that duration are kept. Otherwise only the latest
|
|
// snapshot of each name is kept.
|
|
func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error {
|
|
log.Info("Running post-backup prune", "snapshots", snapshotNames, "keep_newer_than", keepNewerThan)
|
|
v.UI.Begin("Running post-backup prune.")
|
|
|
|
purgeOpts := &SnapshotPurgeOptions{
|
|
Force: true,
|
|
Names: snapshotNames,
|
|
Quiet: true,
|
|
}
|
|
|
|
if keepNewerThan != "" {
|
|
purgeOpts.OlderThan = keepNewerThan
|
|
} else {
|
|
purgeOpts.KeepLatest = true
|
|
}
|
|
|
|
err := v.PurgeSnapshotsWithOptions(purgeOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("purging old snapshots: %w", err)
|
|
}
|
|
|
|
err = v.PruneBlobs(&PruneOptions{Force: true})
|
|
if err != nil {
|
|
return fmt.Errorf("pruning orphaned blobs: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// snapshotStats tracks aggregate statistics across directory scans
|
|
type snapshotStats struct {
|
|
totalFiles int
|
|
totalBytes int64
|
|
totalChunks int
|
|
totalBlobs int
|
|
totalBytesSkipped int64
|
|
totalFilesSkipped int
|
|
totalFilesDeleted int
|
|
totalBytesDeleted int64
|
|
totalBytesUploaded int64
|
|
totalBlobsUploaded int
|
|
uploadDuration time.Duration
|
|
}
|
|
|
|
// createNamedSnapshot creates a single named snapshot
|
|
func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, snapName string, idx, total int) error {
|
|
snapshotStartTime := time.Now()
|
|
|
|
if total > 1 {
|
|
v.UI.Info("Snapshot %d/%d: %s.", idx, total, snapName)
|
|
}
|
|
|
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
|
EnableProgress: !opts.Cron,
|
|
UI: v.UI,
|
|
Fs: v.Fs,
|
|
Exclude: v.Config.GetExcludes(snapName),
|
|
SkipErrors: opts.SkipErrors,
|
|
})
|
|
|
|
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
|
if err != nil {
|
|
return fmt.Errorf("creating snapshot: %w", err)
|
|
}
|
|
|
|
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
|
v.UI.Begin("Creating snapshot %s.", v.UI.Snapshot(snapshotID))
|
|
|
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
v.collectUploadStats(scanner, stats)
|
|
|
|
if err := v.finalizeSnapshotMetadata(snapshotID, stats); err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Info("Snapshot complete",
|
|
"snapshot_id", snapshotID,
|
|
"name", snapName,
|
|
"files", stats.totalFiles,
|
|
"blobs_uploaded", stats.totalBlobsUploaded,
|
|
"bytes_uploaded", stats.totalBytesUploaded,
|
|
"duration", time.Since(snapshotStartTime))
|
|
|
|
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveSnapshotPaths resolves source directories to absolute paths with symlink resolution
|
|
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
|
snapConfig := v.Config.Snapshots[snapName]
|
|
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
|
|
|
for _, dir := range snapConfig.Paths {
|
|
absPath, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", dir, err)
|
|
}
|
|
|
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
resolvedPath = absPath
|
|
} else {
|
|
return nil, fmt.Errorf("failed to resolve symlinks for %s: %w", absPath, err)
|
|
}
|
|
}
|
|
|
|
resolvedDirs = append(resolvedDirs, resolvedPath)
|
|
}
|
|
|
|
return resolvedDirs, nil
|
|
}
|
|
|
|
// scanAllDirectories runs the scanner on each resolved directory and accumulates stats
|
|
func (v *Vaultik) scanAllDirectories(scanner *snapshot.Scanner, resolvedDirs []string, snapshotID string) (*snapshotStats, error) {
|
|
stats := &snapshotStats{}
|
|
|
|
for i, dir := range resolvedDirs {
|
|
select {
|
|
case <-v.ctx.Done():
|
|
log.Info("Snapshot creation cancelled")
|
|
|
|
return nil, v.ctx.Err()
|
|
default:
|
|
}
|
|
|
|
log.Info("Scanning directory", "path", dir)
|
|
v.UI.Begin("Enumerating snapshot source files in %s (%d of %d).", v.UI.Path(dir), i+1, len(resolvedDirs))
|
|
|
|
result, err := scanner.Scan(v.ctx, dir, snapshotID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan %s: %w", dir, err)
|
|
}
|
|
|
|
stats.totalFiles += result.FilesScanned
|
|
stats.totalBytes += result.BytesScanned
|
|
stats.totalChunks += result.ChunksCreated
|
|
stats.totalBlobs += result.BlobsCreated
|
|
stats.totalFilesSkipped += result.FilesSkipped
|
|
stats.totalBytesSkipped += result.BytesSkipped
|
|
stats.totalFilesDeleted += result.FilesDeleted
|
|
stats.totalBytesDeleted += result.BytesDeleted
|
|
|
|
log.Info("Directory scan complete",
|
|
"path", dir,
|
|
"files", result.FilesScanned,
|
|
"files_skipped", result.FilesSkipped,
|
|
"bytes", result.BytesScanned,
|
|
"bytes_skipped", result.BytesSkipped,
|
|
"chunks", result.ChunksCreated,
|
|
"blobs", result.BlobsCreated,
|
|
"duration", result.EndTime.Sub(result.StartTime))
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// collectUploadStats gathers upload statistics from the scanner's progress reporter
|
|
func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotStats) {
|
|
if s := scanner.GetProgress(); s != nil {
|
|
progressStats := s.GetStats()
|
|
stats.totalBytesUploaded = progressStats.BytesUploaded.Load()
|
|
stats.totalBlobsUploaded = int(progressStats.BlobsUploaded.Load())
|
|
stats.uploadDuration = time.Duration(progressStats.UploadDurationMs.Load()) * time.Millisecond
|
|
}
|
|
}
|
|
|
|
// finalizeSnapshotMetadata updates stats, marks complete, and exports metadata
|
|
func (v *Vaultik) finalizeSnapshotMetadata(snapshotID string, stats *snapshotStats) error {
|
|
extStats := snapshot.ExtendedBackupStats{
|
|
BackupStats: snapshot.BackupStats{
|
|
FilesScanned: stats.totalFiles,
|
|
BytesScanned: stats.totalBytes,
|
|
ChunksCreated: stats.totalChunks,
|
|
BlobsCreated: stats.totalBlobs,
|
|
BytesUploaded: stats.totalBytesUploaded,
|
|
},
|
|
BlobUncompressedSize: 0,
|
|
CompressionLevel: v.Config.CompressionLevel,
|
|
UploadDurationMs: stats.uploadDuration.Milliseconds(),
|
|
}
|
|
|
|
err := v.SnapshotManager.UpdateSnapshotStatsExtended(v.ctx, snapshotID, extStats)
|
|
if err != nil {
|
|
return fmt.Errorf("updating snapshot stats: %w", err)
|
|
}
|
|
|
|
err = v.SnapshotManager.CompleteSnapshot(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("completing snapshot: %w", err)
|
|
}
|
|
|
|
err = v.SnapshotManager.ExportSnapshotMetadata(v.ctx, v.Config.IndexPath, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("exporting snapshot metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// uploadSpeed returns the average network upload rate as a colorized
|
|
// bits/sec string, or "N/A" when there's no usable data.
|
|
func (v *Vaultik) uploadSpeed(bytesUploaded int64, duration time.Duration) string {
|
|
if bytesUploaded <= 0 || duration <= 0 {
|
|
return v.UI.Speed(0)
|
|
}
|
|
|
|
return v.UI.Speed(float64(bytesUploaded) / duration.Seconds())
|
|
}
|
|
|
|
// printSnapshotSummary prints the comprehensive snapshot completion summary
|
|
func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, stats *snapshotStats) {
|
|
snapshotDuration := time.Since(startTime)
|
|
totalFilesChanged := stats.totalFiles - stats.totalFilesSkipped
|
|
totalBytesAll := stats.totalBytes + stats.totalBytesSkipped
|
|
|
|
// Get total blob sizes from database
|
|
totalBlobSizeCompressed, totalBlobSizeUncompressed := v.getSnapshotBlobSizes(snapshotID)
|
|
|
|
var compressionRatio float64
|
|
if totalBlobSizeUncompressed > 0 {
|
|
compressionRatio = float64(totalBlobSizeCompressed) / float64(totalBlobSizeUncompressed)
|
|
} else {
|
|
compressionRatio = 1.0
|
|
}
|
|
|
|
v.UI.Complete("Created snapshot %s.", v.UI.Snapshot(snapshotID))
|
|
|
|
filesMsg := fmt.Sprintf("Files: %s examined, %s backed up, %s unchanged",
|
|
v.UI.Count(stats.totalFiles),
|
|
v.UI.Count(totalFilesChanged),
|
|
v.UI.Count(stats.totalFilesSkipped))
|
|
if stats.totalFilesDeleted > 0 {
|
|
filesMsg += fmt.Sprintf(", %s deleted", v.UI.Count(stats.totalFilesDeleted))
|
|
}
|
|
|
|
v.UI.Detail("%s.", filesMsg)
|
|
|
|
dataMsg := fmt.Sprintf("Data: %s total (%s backed up)",
|
|
v.UI.Size(totalBytesAll),
|
|
v.UI.Size(stats.totalBytes))
|
|
if stats.totalBytesDeleted > 0 {
|
|
dataMsg += fmt.Sprintf(", %s deleted", v.UI.Size(stats.totalBytesDeleted))
|
|
}
|
|
|
|
v.UI.Detail("%s.", dataMsg)
|
|
|
|
if stats.totalBlobsUploaded > 0 {
|
|
v.UI.Detail("Storage: %s compressed from %s (%.2fx ratio).",
|
|
v.UI.Size(totalBlobSizeCompressed),
|
|
v.UI.Size(totalBlobSizeUncompressed),
|
|
compressionRatio)
|
|
v.UI.Detail("Upload: %d blobs, %s in %s (%s).",
|
|
stats.totalBlobsUploaded,
|
|
v.UI.Size(stats.totalBytesUploaded),
|
|
v.UI.Duration(stats.uploadDuration),
|
|
v.uploadSpeed(stats.totalBytesUploaded, stats.uploadDuration))
|
|
}
|
|
|
|
v.UI.Detail("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
|
}
|
|
|
|
// getSnapshotBlobSizes returns total compressed and uncompressed blob sizes for a snapshot
|
|
func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (compressed int64, uncompressed int64) {
|
|
blobHashes, err := v.Repositories.Snapshots.GetBlobHashes(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
|
|
for _, hash := range blobHashes {
|
|
if blob, err := v.Repositories.Blobs.GetByHash(v.ctx, hash); err == nil && blob != nil {
|
|
compressed += blob.CompressedSize
|
|
uncompressed += blob.UncompressedSize
|
|
}
|
|
}
|
|
|
|
return compressed, uncompressed
|
|
}
|
|
|
|
// ListSnapshots prints the table of snapshots, plus any reconciliation
|
|
// warnings/notes between the local index and the backup destination
|
|
// store.
|
|
//
|
|
// The local index database is always the primary source for the
|
|
// table — it has the human snapshot IDs, timestamps, and per-snapshot
|
|
// stats.
|
|
//
|
|
// If an age secret key is configured AND remote listing succeeds, we
|
|
// cross-reference: any local snapshot whose hashed key isn't visible
|
|
// remotely gets a "local-only" cleanup hint, and any remote key that
|
|
// doesn't correspond to a known local snapshot gets reported in a
|
|
// NOTE.
|
|
//
|
|
// If no age key is set the local machine is assumed write-only
|
|
// (backup-only), so we skip remote listing entirely — there's no
|
|
// value showing keys the user couldn't restore anyway.
|
|
//
|
|
// If remote listing fails (unmounted volume, permission denied,
|
|
// network), we degrade to local-only with a warning. List never
|
|
// fails just because the destination is unreachable.
|
|
func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|
log.Info("Listing snapshots")
|
|
|
|
localSnaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
|
if err != nil {
|
|
return fmt.Errorf("listing local snapshots: %w", err)
|
|
}
|
|
|
|
snapshots := make([]SnapshotInfo, 0, len(localSnaps))
|
|
for _, ls := range localSnaps {
|
|
if ls.CompletedAt == nil {
|
|
continue
|
|
}
|
|
|
|
snapshots = append(snapshots, v.snapshotInfoFromLocal(ls))
|
|
}
|
|
|
|
sort.Slice(snapshots, func(i, j int) bool {
|
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
|
})
|
|
|
|
if jsonOutput {
|
|
encoder := json.NewEncoder(v.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
|
|
return encoder.Encode(snapshots)
|
|
}
|
|
|
|
if err := v.printSnapshotTable(snapshots); err != nil {
|
|
return err
|
|
}
|
|
|
|
if v.Config.AgeSecretKey == "" {
|
|
return nil
|
|
}
|
|
|
|
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
|
if err != nil {
|
|
v.UI.Warning("Could not list backup destination store: %v.", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
localKeys := make(map[string]string, len(localSnaps))
|
|
for _, ls := range localSnaps {
|
|
if ls.CompletedAt == nil {
|
|
continue
|
|
}
|
|
|
|
localKeys[snapshot.RemoteSnapshotKey(ls.ID.String())] = ls.ID.String()
|
|
}
|
|
|
|
remoteSet := make(map[string]bool, len(remoteKeys))
|
|
for _, k := range remoteKeys {
|
|
remoteSet[k] = true
|
|
}
|
|
|
|
var localOnly []string
|
|
|
|
for key, humanID := range localKeys {
|
|
if !remoteSet[key] {
|
|
localOnly = append(localOnly, humanID)
|
|
}
|
|
}
|
|
|
|
var remoteOnlyCount int
|
|
|
|
for key := range remoteSet {
|
|
if _, ok := localKeys[key]; !ok {
|
|
remoteOnlyCount++
|
|
}
|
|
}
|
|
|
|
if len(localOnly) > 0 {
|
|
v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(localOnly))
|
|
|
|
for _, id := range localOnly {
|
|
v.UI.Info("%s", v.UI.Snapshot(id))
|
|
}
|
|
|
|
v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.")
|
|
}
|
|
|
|
if remoteOnlyCount > 0 {
|
|
v.UI.Notice("NOTE: %d remote snapshot(s) found in backup destination store but not in local database.", remoteOnlyCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// snapshotInfoFromLocal builds a SnapshotInfo row from a local snapshot
|
|
// record. Failures from any per-snapshot stat query degrade that
|
|
// column to its snapshot-row fallback but never fail the listing.
|
|
func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|
idStr := ls.ID.String()
|
|
|
|
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(v.ctx, idStr)
|
|
if err != nil {
|
|
log.Warn("Failed to get total compressed size", "id", idStr, "error", err)
|
|
|
|
totalSize = ls.BlobSize
|
|
}
|
|
|
|
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(v.ctx, idStr)
|
|
if err != nil {
|
|
log.Warn("Failed to get uncompressed chunk size", "id", idStr, "error", err)
|
|
}
|
|
|
|
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, idStr)
|
|
if err != nil {
|
|
log.Warn("Failed to get new chunk size", "id", idStr, "error", err)
|
|
}
|
|
|
|
return SnapshotInfo{
|
|
ID: ls.ID,
|
|
Timestamp: ls.StartedAt,
|
|
CompressedSize: totalSize,
|
|
UncompressedSize: uncompressedSize,
|
|
NewChunkSize: newChunkSize,
|
|
LocallyTracked: true,
|
|
}
|
|
}
|
|
|
|
// printSnapshotTable renders the snapshot list as a formatted table
|
|
func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
|
w := tabwriter.NewWriter(v.Stdout, 0, 0, 3, ' ', 0)
|
|
|
|
if _, err := fmt.Fprintln(w, "CONFIGURED SNAPSHOTS:"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w, "NAME\tPATHS"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w, "────\t─────"); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, name := range v.Config.SnapshotNames() {
|
|
snap := v.Config.Snapshots[name]
|
|
|
|
paths := strings.Join(snap.Paths, ", ")
|
|
if _, err := fmt.Fprintf(w, "%s\t%s\n", name, paths); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w, "REMOTE SNAPSHOTS:"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w, "SNAPSHOT ID\tTIMESTAMP\tCOMPRESSED SIZE\tUNCOMPRESSED SIZE\tNEW CHUNK SIZE"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(w, "───────────\t─────────\t───────────────\t─────────────────\t──────────────"); err != nil {
|
|
return err
|
|
}
|
|
|
|
const remoteOnlyCell = "<remote only>"
|
|
for _, snap := range snapshots {
|
|
uncompressed := remoteOnlyCell
|
|
newChunks := remoteOnlyCell
|
|
|
|
if snap.LocallyTracked {
|
|
uncompressed = formatBytes(snap.UncompressedSize)
|
|
newChunks = formatBytes(snap.NewChunkSize)
|
|
}
|
|
|
|
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
|
snap.ID,
|
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
formatBytes(snap.CompressedSize),
|
|
uncompressed,
|
|
newChunks); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return w.Flush()
|
|
}
|
|
|
|
// SnapshotPurgeOptions contains options for the snapshot purge command.
|
|
type SnapshotPurgeOptions struct {
|
|
KeepLatest bool // Keep only the most recent snapshot per name
|
|
OlderThan string // Drop snapshots older than this duration (e.g. "30d", "6m", "1y")
|
|
Force bool // Skip confirmation prompt
|
|
Names []string // If non-empty, only operate on snapshots with one of these names
|
|
Quiet bool // Suppress informational output (used by --prune flag)
|
|
}
|
|
|
|
// PurgeSnapshotsWithOptions removes old snapshots based on criteria.
|
|
// Retention is per-snapshot-name: KeepLatest keeps the latest of EACH configured
|
|
// snapshot name, not the latest globally. This prevents `home` and `system`
|
|
// snapshots from cannibalizing each other.
|
|
func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
|
if err := v.EnsureStorageBinding(); err != nil {
|
|
return err
|
|
}
|
|
// Sync with remote first
|
|
if err := v.syncWithRemote(); err != nil {
|
|
return fmt.Errorf("syncing with remote: %w", err)
|
|
}
|
|
|
|
// Get snapshots from local database
|
|
dbSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
|
if err != nil {
|
|
return fmt.Errorf("listing snapshots: %w", err)
|
|
}
|
|
|
|
// Build name filter set if --snapshot was specified.
|
|
nameFilter := make(map[string]struct{}, len(opts.Names))
|
|
for _, n := range opts.Names {
|
|
nameFilter[n] = struct{}{}
|
|
}
|
|
|
|
// Collect completed snapshots, applying the name filter.
|
|
snapshots := make([]SnapshotInfo, 0, len(dbSnapshots))
|
|
for _, s := range dbSnapshots {
|
|
if s.CompletedAt == nil {
|
|
continue
|
|
}
|
|
|
|
if len(nameFilter) > 0 {
|
|
if _, ok := nameFilter[parseSnapshotName(s.ID.String())]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
snapshots = append(snapshots, SnapshotInfo{
|
|
ID: s.ID,
|
|
Timestamp: s.StartedAt,
|
|
CompressedSize: s.BlobSize,
|
|
})
|
|
}
|
|
|
|
// Sort by timestamp (newest first)
|
|
sort.Slice(snapshots, func(i, j int) bool {
|
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
|
})
|
|
|
|
var toDelete []SnapshotInfo
|
|
|
|
if opts.KeepLatest {
|
|
// Keep the latest snapshot per snapshot name. Snapshots are sorted
|
|
// newest-first, so the first occurrence of each name is kept.
|
|
seen := make(map[string]bool)
|
|
|
|
for _, snap := range snapshots {
|
|
name := parseSnapshotName(snap.ID.String())
|
|
if seen[name] {
|
|
toDelete = append(toDelete, snap)
|
|
|
|
continue
|
|
}
|
|
|
|
seen[name] = true
|
|
}
|
|
} else if opts.OlderThan != "" {
|
|
duration, err := parseDuration(opts.OlderThan)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration: %w", err)
|
|
}
|
|
|
|
cutoff := time.Now().UTC().Add(-duration)
|
|
for _, snap := range snapshots {
|
|
if snap.Timestamp.Before(cutoff) {
|
|
toDelete = append(toDelete, snap)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(toDelete) == 0 {
|
|
if !opts.Quiet {
|
|
v.printlnStdout("No snapshots to delete")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return v.confirmAndExecutePurge(toDelete, opts.Force, opts.Quiet)
|
|
}
|
|
|
|
// confirmAndExecutePurge shows deletion candidates, confirms with user, and deletes snapshots
|
|
func (v *Vaultik) confirmAndExecutePurge(toDelete []SnapshotInfo, force, quiet bool) error {
|
|
if !quiet {
|
|
v.printfStdout("The following snapshots will be deleted:\n\n")
|
|
|
|
for _, snap := range toDelete {
|
|
v.printfStdout(" %s (%s, %s)\n",
|
|
snap.ID,
|
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
formatBytes(snap.CompressedSize))
|
|
}
|
|
}
|
|
|
|
// Confirm unless --force is used
|
|
if !force {
|
|
v.printfStdout("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
|
|
|
var confirm string
|
|
if _, err := v.scanStdin(&confirm); err != nil {
|
|
// Treat EOF or error as "no"
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return nil
|
|
}
|
|
|
|
if strings.ToLower(confirm) != "y" {
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return nil
|
|
}
|
|
} else if !quiet {
|
|
v.printfStdout("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
|
}
|
|
|
|
// Delete snapshots (both local and remote)
|
|
for _, snap := range toDelete {
|
|
snapshotID := snap.ID.String()
|
|
log.Info("Deleting snapshot", "id", snapshotID)
|
|
|
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
|
if err != nil {
|
|
log.Error("Failed to delete from local database", "snapshot_id", snapshotID, "error", err)
|
|
}
|
|
|
|
err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
|
if err != nil {
|
|
return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err)
|
|
}
|
|
}
|
|
|
|
// Tidy up local DB orphans now so users don't have to run a
|
|
// separate command after a purge. Guarded against nil for tests
|
|
// that don't wire up a SnapshotManager.
|
|
if v.SnapshotManager != nil {
|
|
err := v.SnapshotManager.CleanupOrphanedData(v.ctx)
|
|
if err != nil {
|
|
log.Warn("Failed to clean up orphaned local data after purge", "error", err)
|
|
}
|
|
}
|
|
|
|
if !quiet {
|
|
v.printfStdout("Deleted %d snapshot(s)\n", len(toDelete))
|
|
v.printlnStdout("\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifySnapshot checks snapshot integrity
|
|
func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
|
|
opts := &VerifyOptions{Deep: deep}
|
|
if deep {
|
|
return v.RunDeepVerify(snapshotID, opts)
|
|
}
|
|
|
|
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
}
|
|
|
|
// VerifySnapshotWithOptions checks snapshot integrity with full options.
|
|
// Deep verification is delegated to RunDeepVerify so this function only
|
|
// implements the shallow (existence-only) path.
|
|
func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptions) error {
|
|
if opts.Deep {
|
|
return v.RunDeepVerify(snapshotID, opts)
|
|
}
|
|
|
|
result := &VerifyResult{
|
|
SnapshotID: snapshotID,
|
|
Mode: "shallow",
|
|
}
|
|
|
|
v.printVerifyHeader(snapshotID, opts)
|
|
|
|
// Download and parse manifest. The caller supplies a human
|
|
// snapshot ID; we hash it to address remote storage.
|
|
manifest, err := v.downloadManifestByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
|
if err != nil {
|
|
if opts.JSON {
|
|
result.Status = "failed"
|
|
result.ErrorMessage = fmt.Sprintf("downloading manifest: %v", err)
|
|
|
|
return v.outputVerifyJSON(result)
|
|
}
|
|
|
|
return fmt.Errorf("downloading manifest: %w", err)
|
|
}
|
|
|
|
result.BlobCount = manifest.BlobCount
|
|
result.TotalSize = manifest.TotalCompressedSize
|
|
|
|
if !opts.JSON {
|
|
v.printfStdout("Snapshot information:\n")
|
|
v.printfStdout(" Blob count: %d\n", manifest.BlobCount)
|
|
v.printfStdout(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
|
|
|
|
if manifest.Timestamp != "" {
|
|
if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil {
|
|
v.printfStdout(" Created: %s\n", t.Format("2006-01-02 15:04:05 MST"))
|
|
}
|
|
}
|
|
|
|
v.printlnStdout()
|
|
|
|
// Check each blob exists
|
|
v.printfStdout("Checking blob existence...\n")
|
|
}
|
|
|
|
result.Verified, result.Missing, result.MissingSize = v.verifyManifestBlobsExist(manifest, opts)
|
|
|
|
return v.formatVerifyResult(result, manifest, opts)
|
|
}
|
|
|
|
// printVerifyHeader prints the snapshot ID and parsed timestamp for verification output.
|
|
// Snapshot ID format: hostname[_name]_<RFC3339>
|
|
func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
|
|
var snapshotTime time.Time
|
|
if t, err := parseSnapshotTimestamp(snapshotID); err == nil {
|
|
snapshotTime = t
|
|
}
|
|
|
|
if !opts.JSON {
|
|
v.printfStdout("Verifying snapshot %s\n", snapshotID)
|
|
|
|
if !snapshotTime.IsZero() {
|
|
v.printfStdout("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST"))
|
|
}
|
|
|
|
v.printlnStdout()
|
|
}
|
|
}
|
|
|
|
// verifyManifestBlobsExist checks that each blob in the manifest exists in storage
|
|
func (v *Vaultik) verifyManifestBlobsExist(manifest *snapshot.Manifest, opts *VerifyOptions) (verified, missing int, missingSize int64) {
|
|
for _, blob := range manifest.Blobs {
|
|
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
|
|
|
// Shallow: just check existence (deep verification is handled by RunDeepVerify)
|
|
_, err := v.Storage.Stat(v.ctx, blobPath)
|
|
if err != nil {
|
|
if !opts.JSON {
|
|
v.printfStdout(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize)))
|
|
}
|
|
|
|
missing++
|
|
missingSize += blob.CompressedSize
|
|
} else {
|
|
verified++
|
|
}
|
|
}
|
|
|
|
return verified, missing, missingSize
|
|
}
|
|
|
|
// formatVerifyResult outputs the final verification results as JSON or human-readable text
|
|
func (v *Vaultik) formatVerifyResult(result *VerifyResult, manifest *snapshot.Manifest, opts *VerifyOptions) error {
|
|
if opts.JSON {
|
|
if result.Missing > 0 {
|
|
result.Status = "failed"
|
|
result.ErrorMessage = fmt.Sprintf("%d blobs are missing", result.Missing)
|
|
} else {
|
|
result.Status = "ok"
|
|
}
|
|
|
|
return v.outputVerifyJSON(result)
|
|
}
|
|
|
|
v.printfStdout("\nVerification complete:\n")
|
|
v.printfStdout(" Verified: %d blobs (%s)\n", result.Verified,
|
|
humanize.Bytes(uint64(manifest.TotalCompressedSize-result.MissingSize)))
|
|
|
|
if result.Missing > 0 {
|
|
v.printfStdout(" Missing: %d blobs (%s)\n", result.Missing, humanize.Bytes(uint64(result.MissingSize)))
|
|
} else {
|
|
v.printfStdout(" Missing: 0 blobs\n")
|
|
}
|
|
|
|
v.printfStdout(" Status: ")
|
|
|
|
if result.Missing > 0 {
|
|
v.printfStdout("FAILED - %d blobs are missing\n", result.Missing)
|
|
|
|
return fmt.Errorf("%d blobs are missing", result.Missing)
|
|
}
|
|
|
|
v.printfStdout("OK - All blobs verified\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
// outputVerifyJSON outputs the verification result as JSON
|
|
func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
|
encoder := json.NewEncoder(v.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
|
|
err := encoder.Encode(result)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding JSON: %w", err)
|
|
}
|
|
|
|
if result.Status == "failed" {
|
|
return fmt.Errorf("verification failed: %s", result.ErrorMessage)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupLocalSnapshots removes local snapshot records that have no
|
|
// corresponding metadata in remote storage. These are typically left
|
|
// behind by incomplete or interrupted backups. Each local snapshot's
|
|
// human ID is hashed via RemoteSnapshotKey and compared against the
|
|
// remote listing.
|
|
func (v *Vaultik) CleanupLocalSnapshots() error {
|
|
if err := v.EnsureStorageBinding(); err != nil {
|
|
return err
|
|
}
|
|
|
|
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
remoteSet := make(map[string]bool, len(remoteKeys))
|
|
for _, k := range remoteKeys {
|
|
remoteSet[k] = true
|
|
}
|
|
|
|
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
|
if err != nil {
|
|
return fmt.Errorf("listing local snapshots: %w", err)
|
|
}
|
|
|
|
var removed int
|
|
|
|
for _, snap := range localSnapshots {
|
|
id := snap.ID.String()
|
|
if !remoteSet[snapshot.RemoteSnapshotKey(id)] {
|
|
v.printfStdout("Removing stale local record: %s\n", id)
|
|
|
|
err := v.deleteSnapshotFromLocalDB(id)
|
|
if err != nil {
|
|
log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
removed++
|
|
}
|
|
}
|
|
|
|
if removed == 0 {
|
|
v.printlnStdout("No stale local snapshots found.")
|
|
} else {
|
|
v.printfStdout("Removed %d stale local snapshot record(s).\n", removed)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper methods that were previously on SnapshotApp
|
|
|
|
// downloadManifestByKey fetches the manifest at
|
|
// metadata/<remoteKey>/manifest.json.zst. The remoteKey is the double-
|
|
// SHA256 derivation produced by snapshot.RemoteSnapshotKey, not the
|
|
// human snapshot ID. Callers that have a human ID must hash first.
|
|
func (v *Vaultik) downloadManifestByKey(remoteKey string) (*snapshot.Manifest, error) {
|
|
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
|
|
|
|
reader, err := v.Storage.Get(v.ctx, manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
manifest, err := snapshot.DecodeManifest(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding manifest: %w", err)
|
|
}
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
func (v *Vaultik) syncWithRemote() error {
|
|
log.Info("Syncing with remote snapshots")
|
|
|
|
// Get all remote snapshot IDs
|
|
remoteSnapshots := make(map[string]bool)
|
|
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
|
|
|
for object := range objectCh {
|
|
if object.Err != nil {
|
|
return fmt.Errorf("listing remote snapshots: %w", object.Err)
|
|
}
|
|
|
|
// Extract snapshot ID from paths like metadata/hostname-20240115-143052Z/
|
|
parts := strings.Split(object.Key, "/")
|
|
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
|
|
// Skip macOS resource fork files (._*) and other hidden files
|
|
if strings.HasPrefix(parts[1], ".") {
|
|
continue
|
|
}
|
|
|
|
remoteSnapshots[parts[1]] = true
|
|
}
|
|
}
|
|
|
|
log.Debug("Found remote snapshots", "count", len(remoteSnapshots))
|
|
|
|
// Get all local snapshots (use a high limit to get all)
|
|
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
|
if err != nil {
|
|
return fmt.Errorf("listing local snapshots: %w", err)
|
|
}
|
|
|
|
// Remove local snapshots that don't exist remotely
|
|
removedCount := 0
|
|
|
|
for _, snapshot := range localSnapshots {
|
|
snapshotIDStr := snapshot.ID.String()
|
|
if !remoteSnapshots[snapshotIDStr] {
|
|
log.Info("Removing local snapshot not found in remote", "snapshot_id", snapshot.ID)
|
|
|
|
err := v.deleteSnapshotFromLocalDB(snapshotIDStr)
|
|
if err != nil {
|
|
log.Error("Failed to delete local snapshot", "snapshot_id", snapshot.ID, "error", err)
|
|
} else {
|
|
removedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if removedCount > 0 {
|
|
log.Info("Removed local snapshots not found in remote", "count", removedCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveOptions contains options for the snapshot remove command
|
|
type RemoveOptions struct {
|
|
Force bool
|
|
DryRun bool
|
|
JSON bool
|
|
LocalOnly bool // Skip remote cleanup; only touch the local index
|
|
}
|
|
|
|
// RemoveResult contains the result of a snapshot removal
|
|
type RemoveResult struct {
|
|
SnapshotID string `json:"snapshot_id,omitempty"`
|
|
SnapshotsRemoved []string `json:"snapshots_removed,omitempty"`
|
|
RemoteRemoved bool `json:"remote_removed,omitempty"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
}
|
|
|
|
// pruneCommandHint is the exact command suggested after a remove
|
|
// completes so the user knows how to clean up the blobs that the
|
|
// just-removed snapshot left behind on the destination store.
|
|
const pruneCommandHint = "vaultik prune"
|
|
|
|
// RemoveSnapshot removes a snapshot from the local index database and,
|
|
// unless LocalOnly is set, also strips the snapshot's metadata from the
|
|
// destination store. Blobs are NOT touched: removing a snapshot's
|
|
// blobs requires reading every remaining remote manifest (the remote
|
|
// may hold snapshots this host doesn't know about), which is what
|
|
// `vaultik prune` is for — the command prints the prune invocation to
|
|
// run as a follow-up. When the remote is unreachable the command still
|
|
// completes the local-DB removal and warns.
|
|
func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*RemoveResult, error) {
|
|
result := &RemoveResult{
|
|
SnapshotID: snapshotID,
|
|
}
|
|
|
|
err := v.EnsureStorageBinding()
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
if opts.DryRun {
|
|
result.DryRun = true
|
|
|
|
if !opts.JSON {
|
|
v.printfStdout("Would remove snapshot: %s\n", snapshotID)
|
|
|
|
if !opts.LocalOnly {
|
|
v.printlnStdout("Would also remove snapshot metadata from remote storage (blobs untouched)")
|
|
}
|
|
|
|
v.printlnStdout("[Dry run - no changes made]")
|
|
}
|
|
|
|
if opts.JSON {
|
|
return result, v.outputRemoveJSON(result)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
if !opts.Force && !opts.JSON {
|
|
if opts.LocalOnly {
|
|
v.printfStdout("Remove snapshot '%s' from local database (remote untouched)? [y/N] ", snapshotID)
|
|
} else {
|
|
v.printfStdout("Remove snapshot '%s' from local database AND its metadata from remote storage? [y/N] ", snapshotID)
|
|
}
|
|
|
|
var confirm string
|
|
if _, err := v.scanStdin(&confirm); err != nil {
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return result, nil
|
|
}
|
|
|
|
if strings.ToLower(confirm) != "y" {
|
|
v.printlnStdout("Cancelled")
|
|
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
|
|
|
err = v.deleteSnapshotFromLocalDB(snapshotID)
|
|
if err != nil {
|
|
return result, fmt.Errorf("removing from local database: %w", err)
|
|
}
|
|
|
|
if !opts.LocalOnly {
|
|
log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID)
|
|
|
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
|
|
|
err := v.deleteRemoteSnapshotByKey(remoteKey)
|
|
if err != nil {
|
|
// Warn-and-proceed: the local-DB removal has already
|
|
// happened, so let the user know the remote half didn't
|
|
// finish and they can retry with `vaultik prune` once the
|
|
// destination store is reachable.
|
|
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 '%s' once the remote is reachable to finish cleanup.", err, pruneCommandHint)
|
|
}
|
|
} else {
|
|
result.RemoteRemoved = true
|
|
}
|
|
}
|
|
|
|
if v.SnapshotManager != nil {
|
|
err := v.SnapshotManager.CleanupOrphanedData(v.ctx)
|
|
if err != nil {
|
|
log.Warn("Failed to clean up orphaned local data after removal", "error", err)
|
|
}
|
|
}
|
|
|
|
if opts.JSON {
|
|
return result, v.outputRemoveJSON(result)
|
|
}
|
|
|
|
v.printfStdout("Removed snapshot '%s' from local database\n", snapshotID)
|
|
|
|
if !opts.LocalOnly && result.RemoteRemoved {
|
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
|
v.printfStdout("\nNote: The removed snapshot's blobs remain on the remote. Run '%s' to delete any blobs no longer referenced by any remaining remote snapshot.\n", pruneCommandHint)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// RemoveAllSnapshots removes every snapshot known to the local
|
|
// database from the local index, and (with --remote) every snapshot
|
|
// metadata directory in remote storage. Both sides are processed so a
|
|
// "remove --all" leaves nothing behind, even when the local DB and
|
|
// remote storage have diverged.
|
|
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
|
if err := v.EnsureStorageBinding(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
localSnaps, err := v.localSnapshotIDs()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing local snapshots: %w", err)
|
|
}
|
|
|
|
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
|
// destination store; failures are downgraded to a warning so a
|
|
// permission-denied or unreachable remote can't block a local-only
|
|
// remove.
|
|
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
|
if remoteErr != nil {
|
|
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
|
v.UI.Warning("Could not list remote snapshots: %v.", remoteErr)
|
|
}
|
|
|
|
// Anything visible on the remote that doesn't correspond to a
|
|
// known local human ID is treated as an orphan key — handled only
|
|
// when --remote is in effect.
|
|
knownLocalKeys := make(map[string]string, len(localSnaps))
|
|
for _, id := range localSnaps {
|
|
knownLocalKeys[snapshot.RemoteSnapshotKey(id)] = id
|
|
}
|
|
|
|
var orphanRemoteKeys []string
|
|
|
|
for _, key := range remoteKeys {
|
|
if _, known := knownLocalKeys[key]; !known {
|
|
orphanRemoteKeys = append(orphanRemoteKeys, key)
|
|
}
|
|
}
|
|
|
|
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
|
if !opts.JSON {
|
|
v.printlnStdout("No snapshots found")
|
|
}
|
|
|
|
return &RemoveResult{}, nil
|
|
}
|
|
|
|
if opts.DryRun {
|
|
return v.handleRemoveAllDryRun(localSnaps, orphanRemoteKeys, opts)
|
|
}
|
|
|
|
return v.executeRemoveAll(localSnaps, orphanRemoteKeys, opts)
|
|
}
|
|
|
|
// localSnapshotIDs returns every snapshot ID present in the local
|
|
// index database, sorted for deterministic iteration. Empty slice if
|
|
// the database has no Repositories (e.g. tests).
|
|
func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|
if v.Repositories == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
snaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, 100000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ids := make([]string, 0, len(snaps))
|
|
for _, s := range snaps {
|
|
ids = append(ids, s.ID.String())
|
|
}
|
|
|
|
sort.Strings(ids)
|
|
|
|
return ids, nil
|
|
}
|
|
|
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
|
// (subdirectories under metadata/) currently present in the
|
|
// destination store. Returns (nil, err) when the store cannot be
|
|
// listed; callers must treat that as "no remote info available," not
|
|
// fatal.
|
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|
log.Info("Listing all remote snapshots")
|
|
|
|
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
|
|
|
seen := make(map[string]bool)
|
|
|
|
var keys []string
|
|
|
|
for object := range objectCh {
|
|
if object.Err != nil {
|
|
return nil, fmt.Errorf("listing remote snapshots: %w", object.Err)
|
|
}
|
|
|
|
parts := strings.Split(object.Key, "/")
|
|
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
|
|
// Skip macOS resource fork files (._*) and other hidden files
|
|
if strings.HasPrefix(parts[1], ".") {
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(object.Key, "/") || strings.Contains(object.Key, "/manifest.json.zst") {
|
|
key := parts[1]
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// handleRemoveAllDryRun handles the dry-run mode for removing all snapshots
|
|
func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
|
result := &RemoveResult{DryRun: true}
|
|
|
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, localSnaps...)
|
|
if !opts.LocalOnly {
|
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, orphanRemoteKeys...)
|
|
}
|
|
|
|
if !opts.JSON {
|
|
v.printfStdout("Would remove %d local snapshot(s):\n", len(localSnaps))
|
|
|
|
for _, id := range localSnaps {
|
|
v.printfStdout(" %s\n", id)
|
|
}
|
|
|
|
if !opts.LocalOnly {
|
|
if len(orphanRemoteKeys) > 0 {
|
|
v.printfStdout("Would also remove %d orphan remote snapshot key(s):\n", len(orphanRemoteKeys))
|
|
|
|
for _, key := range orphanRemoteKeys {
|
|
v.printfStdout(" %s\n", key)
|
|
}
|
|
} else {
|
|
v.printlnStdout("Would also remove snapshot metadata from remote storage (blobs untouched)")
|
|
}
|
|
}
|
|
|
|
v.printlnStdout("[Dry run - no changes made]")
|
|
}
|
|
|
|
if opts.JSON {
|
|
return result, v.outputRemoveJSON(result)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// executeRemoveAll deletes every local snapshot and, unless LocalOnly
|
|
// is set, every corresponding remote metadata directory plus any
|
|
// orphan remote keys. Blobs are not touched; the printed prune-command
|
|
// hint is the next step.
|
|
func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
|
if !opts.Force {
|
|
return nil, errors.New("--all requires --force")
|
|
}
|
|
|
|
log.Info("Removing all snapshots", "local_count", len(localSnaps), "orphan_remote_count", len(orphanRemoteKeys))
|
|
|
|
result := &RemoveResult{}
|
|
remoteErrors := 0
|
|
|
|
for _, snapshotID := range localSnaps {
|
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
|
|
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
|
if err != nil {
|
|
log.Error("Failed to remove from local database", "snapshot_id", snapshotID, "error", err)
|
|
|
|
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)
|
|
|
|
remoteErrors++
|
|
}
|
|
}
|
|
|
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
|
}
|
|
|
|
if !opts.LocalOnly {
|
|
for _, key := range orphanRemoteKeys {
|
|
log.Info("Removing orphan remote snapshot", "remote_key", key)
|
|
|
|
err := v.deleteRemoteSnapshotByKey(key)
|
|
if err != nil {
|
|
log.Warn("Failed to remove orphan from remote", "remote_key", key, "error", err)
|
|
|
|
remoteErrors++
|
|
|
|
continue
|
|
}
|
|
|
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, key)
|
|
}
|
|
|
|
if remoteErrors == 0 {
|
|
result.RemoteRemoved = true
|
|
} else if v.UI != nil {
|
|
v.UI.Warning("Some remote metadata deletions failed. Run '%s' once the remote is healthy to clean up unreferenced blobs.", pruneCommandHint)
|
|
}
|
|
}
|
|
|
|
if v.SnapshotManager != nil {
|
|
err := v.SnapshotManager.CleanupOrphanedData(v.ctx)
|
|
if err != nil {
|
|
log.Warn("Failed to clean up orphaned local data after bulk removal", "error", err)
|
|
}
|
|
}
|
|
|
|
if opts.JSON {
|
|
return result, v.outputRemoveJSON(result)
|
|
}
|
|
|
|
v.printfStdout("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
|
|
|
if !opts.LocalOnly && result.RemoteRemoved {
|
|
v.printlnStdout("Removed snapshot metadata from remote storage")
|
|
v.printfStdout("\nNote: Removed snapshots' blobs remain on the remote. Run '%s' to delete any blobs no longer referenced by any remaining remote snapshot.\n", pruneCommandHint)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// deleteSnapshotFromLocalDB removes a snapshot from the local database only
|
|
func (v *Vaultik) deleteSnapshotFromLocalDB(snapshotID string) error {
|
|
if v.Repositories == nil {
|
|
return nil // No local database
|
|
}
|
|
|
|
// Delete related records first to avoid foreign key constraints
|
|
err := v.Repositories.Snapshots.DeleteSnapshotFiles(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting snapshot files for %s: %w", snapshotID, err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.DeleteSnapshotBlobs(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting snapshot blobs for %s: %w", snapshotID, err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.DeleteSnapshotUploads(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting snapshot uploads for %s: %w", snapshotID, err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.Delete(v.ctx, snapshotID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting snapshot record %s: %w", snapshotID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deleteRemoteSnapshotByKey removes everything under
|
|
// metadata/<remoteKey>/ on the destination store. The argument is a
|
|
// remote key (double-SHA256 derivation), not a human snapshot ID;
|
|
// callers that have a human ID must hash via snapshot.RemoteSnapshotKey
|
|
// first.
|
|
func (v *Vaultik) deleteRemoteSnapshotByKey(remoteKey string) error {
|
|
prefix := fmt.Sprintf("metadata/%s/", remoteKey)
|
|
objectCh := v.Storage.ListStream(v.ctx, prefix)
|
|
|
|
var objectsToDelete []string
|
|
|
|
for object := range objectCh {
|
|
if object.Err != nil {
|
|
return fmt.Errorf("listing objects: %w", object.Err)
|
|
}
|
|
|
|
objectsToDelete = append(objectsToDelete, object.Key)
|
|
}
|
|
|
|
for _, key := range objectsToDelete {
|
|
err := v.Storage.Delete(v.ctx, key)
|
|
if err != nil {
|
|
return fmt.Errorf("removing %s: %w", key, err)
|
|
}
|
|
|
|
log.Debug("Deleted remote object", "key", key)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// outputRemoveJSON outputs the removal result as JSON
|
|
func (v *Vaultik) outputRemoveJSON(result *RemoveResult) error {
|
|
encoder := json.NewEncoder(v.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
// PruneResult contains statistics about the prune operation
|
|
type PruneResult struct {
|
|
SnapshotsDeleted int64
|
|
FilesDeleted int64
|
|
ChunksDeleted int64
|
|
BlobsDeleted int64
|
|
}
|
|
|
|
// PruneDatabase removes incomplete snapshots and orphaned files, chunks,
|
|
// and blobs from the local database. This ensures database consistency
|
|
// before starting a new backup or on-demand via the prune command.
|
|
func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|
log.Info("Pruning local database: removing incomplete snapshots and orphaned data")
|
|
v.UI.Begin("Pruning local index database (removing incomplete snapshots and orphaned data).")
|
|
|
|
result := &PruneResult{}
|
|
|
|
// Snapshot counts before deletion of incompletes.
|
|
snapshotCountBefore, _ := v.getTableCount("snapshots")
|
|
|
|
// First, delete any incomplete snapshots
|
|
incompleteSnapshots, err := v.Repositories.Snapshots.GetIncompleteSnapshots(v.ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting incomplete snapshots: %w", err)
|
|
}
|
|
|
|
for _, snapshot := range incompleteSnapshots {
|
|
snapshotIDStr := snapshot.ID.String()
|
|
log.Info("Deleting incomplete snapshot", "snapshot_id", snapshot.ID)
|
|
// Delete related records first
|
|
err := v.Repositories.Snapshots.DeleteSnapshotFiles(v.ctx, snapshotIDStr)
|
|
if err != nil {
|
|
log.Error("Failed to delete snapshot files", "snapshot_id", snapshot.ID, "error", err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.DeleteSnapshotBlobs(v.ctx, snapshotIDStr)
|
|
if err != nil {
|
|
log.Error("Failed to delete snapshot blobs", "snapshot_id", snapshot.ID, "error", err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.DeleteSnapshotUploads(v.ctx, snapshotIDStr)
|
|
if err != nil {
|
|
log.Error("Failed to delete snapshot uploads", "snapshot_id", snapshot.ID, "error", err)
|
|
}
|
|
|
|
err = v.Repositories.Snapshots.Delete(v.ctx, snapshotIDStr)
|
|
if err != nil {
|
|
log.Error("Failed to delete snapshot", "snapshot_id", snapshot.ID, "error", err)
|
|
} else {
|
|
result.SnapshotsDeleted++
|
|
}
|
|
}
|
|
|
|
// Get counts before cleanup for reporting
|
|
fileCountBefore, _ := v.getTableCount("files")
|
|
chunkCountBefore, _ := v.getTableCount("chunks")
|
|
blobCountBefore, _ := v.getTableCount("blobs")
|
|
|
|
// Run the cleanup
|
|
if err := v.SnapshotManager.CleanupOrphanedData(v.ctx); err != nil {
|
|
return nil, fmt.Errorf("cleanup orphaned data: %w", err)
|
|
}
|
|
|
|
// Get counts after cleanup
|
|
fileCountAfter, _ := v.getTableCount("files")
|
|
chunkCountAfter, _ := v.getTableCount("chunks")
|
|
blobCountAfter, _ := v.getTableCount("blobs")
|
|
|
|
result.FilesDeleted = fileCountBefore - fileCountAfter
|
|
result.ChunksDeleted = chunkCountBefore - chunkCountAfter
|
|
result.BlobsDeleted = blobCountBefore - blobCountAfter
|
|
|
|
log.Info("Local database prune complete",
|
|
"incomplete_snapshots", result.SnapshotsDeleted,
|
|
"orphaned_files", result.FilesDeleted,
|
|
"orphaned_chunks", result.ChunksDeleted,
|
|
"orphaned_blobs", result.BlobsDeleted,
|
|
)
|
|
|
|
snapshotCountAfter := snapshotCountBefore - result.SnapshotsDeleted
|
|
|
|
v.UI.Complete("Pruned local index database.")
|
|
v.UI.Detail("Incomplete snapshots: %d removed (%d remain).", result.SnapshotsDeleted, snapshotCountAfter)
|
|
v.UI.Detail("Orphaned files: %d removed (%d remain).", result.FilesDeleted, fileCountAfter)
|
|
v.UI.Detail("Orphaned chunks: %d removed (%d remain).", result.ChunksDeleted, chunkCountAfter)
|
|
v.UI.Detail("Orphaned blobs: %d removed (%d remain).", result.BlobsDeleted, blobCountAfter)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// validTableNameRe matches table names containing only lowercase alphanumeric characters and underscores.
|
|
var validTableNameRe = regexp.MustCompile(`^[a-z0-9_]+$`)
|
|
|
|
// getTableCount returns the count of rows in a table.
|
|
// The tableName is sanitized to only allow [a-z0-9_] characters to prevent SQL injection.
|
|
func (v *Vaultik) getTableCount(tableName string) (int64, error) {
|
|
if v.DB == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
if !validTableNameRe.MatchString(tableName) {
|
|
return 0, fmt.Errorf("invalid table name: %q", tableName)
|
|
}
|
|
|
|
var count int64
|
|
|
|
query := "SELECT COUNT(*) FROM " + tableName
|
|
|
|
err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&count)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return count, nil
|
|
}
|