Hash snapshot IDs at the storage boundary; make snapshot list resilient
Two related changes, both addressing leakage and brittleness around
the public bytes the destination store sees.
First, every remote storage path that previously embedded a human
snapshot ID (e.g. metadata/heraklion_berlin.sneak.fs.photos.2026.
catalog_2026-06-24T07:00:15Z/...) now uses the hashed remote key:
RemoteSnapshotKey(id) = hex(SHA256(SHA256("vaultik|" + id)))
Applied at:
* uploadSnapshotArtifacts (snapshot create write path)
* the manifest.json.zst snapshot_id field — manifest is
unencrypted, so the human ID would otherwise be readable to
anyone with bucket-list permission
* cleanupIncompleteSnapshots metadata-existence probe
* snapshot restore / verify (downloadSnapshotDB,
loadVerificationData)
* downloadManifestByKey, deleteRemoteSnapshotByKey
* CleanupLocalSnapshots reconciliation
* the locally-driven removal paths (RemoveSnapshot,
RemoveAllSnapshots, confirmAndExecutePurge)
The local index database keeps human IDs everywhere — the hash is a
boundary translation, not a rename. A directory listing of the
backup destination now looks like
"metadata/<64-hex>/{db.zst.age,manifest.json.zst}" with no host,
snapshot-name, or timestamp information visible.
Second, snapshot list no longer fails just because remote storage is
unreachable, and only consults the remote when the local machine can
plausibly decrypt:
* Listing is always driven by the local index database — that's
what holds the human IDs, timestamps, and per-snapshot stats
that the table actually shows.
* If no age secret key is configured, we skip remote listing
entirely (the box is treated as a write-only backup machine —
there's no value showing it remote-only keys it could never
restore).
* If a key IS configured, we try the remote listing; failures
(volume unmounted, permission denied, network error) downgrade
to a warning instead of aborting the command.
* When the remote listing succeeds, we cross-reference by hashing
each local human ID and diffing against the returned key set.
Local-only snapshots get the existing "stale local record"
cleanup hint; remote-only keys are surfaced as a single
"NOTE: N remote snapshot(s) found in backup destination store
but not in local database" line.
FileStorer construction also no longer does an eager mkdir — the
basePath is recorded and the directory is created lazily on first
write. A missing or unmounted destination during `snapshot list`
should NOT block the command, and now it doesn't.
RemoveAllSnapshots is rewritten to drive deletion from the local
index instead of from a remote listing, hashing each local ID to
find the corresponding remote key. Orphan remote keys (no matching
local snapshot) are handled separately and only deleted when
--remote is set. Existing tests are updated to hash storage paths
through the new RemoteSnapshotKey helper.
The hash format is a hard pre-1.0 break: existing remote snapshots
written under the human-ID path scheme are no longer readable; they
need to be either re-uploaded under the new scheme or manually
renamed. There is no fallback path; matching the project policy of
"no migrations pre-1.0."
This commit is contained in:
40
internal/snapshot/remotekey.go
Normal file
40
internal/snapshot/remotekey.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// remoteKeyPrefix is mixed into the snapshot ID hash so the resulting
|
||||||
|
// hex digest is domain-separated from any other "double SHA256 of a
|
||||||
|
// string" identifier the user might also use. Keeping this stable is a
|
||||||
|
// hard compatibility requirement: changing it invalidates every
|
||||||
|
// existing snapshot's remote storage path.
|
||||||
|
const remoteKeyPrefix = "vaultik|"
|
||||||
|
|
||||||
|
// RemoteSnapshotKey returns the storage-side identifier for a snapshot
|
||||||
|
// given its human snapshot ID. It is hex(SHA256(SHA256(prefix + id))).
|
||||||
|
// The two SHA256 rounds match Bitcoin's "hash256" convention so the
|
||||||
|
// output looks like a 64-character hex blob with no exploitable
|
||||||
|
// structure visible to a remote observer.
|
||||||
|
//
|
||||||
|
// We use this in three places:
|
||||||
|
//
|
||||||
|
// - the "metadata/<remote-key>/..." subdirectory on the storage
|
||||||
|
// backend so a directory listing of the bucket / file:// dest
|
||||||
|
// doesn't reveal hostnames, configured snapshot names, or backup
|
||||||
|
// timestamps;
|
||||||
|
// - the `snapshot_id` field of the unencrypted manifest.json.zst
|
||||||
|
// for the same reason;
|
||||||
|
// - any code path that needs to translate a known local snapshot ID
|
||||||
|
// into the path it would occupy on remote storage.
|
||||||
|
//
|
||||||
|
// The human ID stays the user-visible handle everywhere else — local
|
||||||
|
// database joins, CLI arguments, summary lines, log fields — because
|
||||||
|
// it's never written to the public bytes once this function gates
|
||||||
|
// every storage-path construction.
|
||||||
|
func RemoteSnapshotKey(snapshotID string) string {
|
||||||
|
first := sha256.Sum256([]byte(remoteKeyPrefix + snapshotID))
|
||||||
|
second := sha256.Sum256(first[:])
|
||||||
|
return hex.EncodeToString(second[:])
|
||||||
|
}
|
||||||
@@ -314,10 +314,17 @@ func (sm *SnapshotManager) prepareExportDB(ctx context.Context, dbPath, snapshot
|
|||||||
return finalData, tempDBPath, nil
|
return finalData, tempDBPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadSnapshotArtifacts uploads the database backup and blob manifest to S3
|
// uploadSnapshotArtifacts uploads the database backup and blob manifest
|
||||||
|
// to remote storage at metadata/<remote-key>/, where remote-key is the
|
||||||
|
// double-SHA256 derivation of the snapshot ID (see RemoteSnapshotKey).
|
||||||
|
// We never write the human-readable snapshot ID into any unencrypted
|
||||||
|
// part of remote storage so a listing of the destination bucket leaks
|
||||||
|
// no host, configuration, or scheduling information.
|
||||||
func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshotID string, dbData, manifestData []byte) error {
|
func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshotID string, dbData, manifestData []byte) error {
|
||||||
|
remoteKey := RemoteSnapshotKey(snapshotID)
|
||||||
|
|
||||||
// Upload database backup (compressed and encrypted)
|
// Upload database backup (compressed and encrypted)
|
||||||
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID)
|
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||||
|
|
||||||
dbUploadStart := time.Now()
|
dbUploadStart := time.Now()
|
||||||
if err := sm.storage.Put(ctx, dbKey, bytes.NewReader(dbData)); err != nil {
|
if err := sm.storage.Put(ctx, dbKey, bytes.NewReader(dbData)); err != nil {
|
||||||
@@ -332,7 +339,7 @@ func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshot
|
|||||||
"speed", humanize.SI(dbUploadSpeed, "bps"))
|
"speed", humanize.SI(dbUploadSpeed, "bps"))
|
||||||
|
|
||||||
// Upload blob manifest (compressed only, not encrypted)
|
// Upload blob manifest (compressed only, not encrypted)
|
||||||
manifestKey := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID)
|
manifestKey := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
|
||||||
manifestUploadStart := time.Now()
|
manifestUploadStart := time.Now()
|
||||||
if err := sm.storage.Put(ctx, manifestKey, bytes.NewReader(manifestData)); err != nil {
|
if err := sm.storage.Put(ctx, manifestKey, bytes.NewReader(manifestData)); err != nil {
|
||||||
return fmt.Errorf("uploading blob manifest: %w", err)
|
return fmt.Errorf("uploading blob manifest: %w", err)
|
||||||
@@ -607,9 +614,11 @@ func (sm *SnapshotManager) generateBlobManifest(ctx context.Context, dbPath stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create manifest
|
// Create manifest. SnapshotID in the unencrypted manifest is the
|
||||||
|
// double-SHA256 remote key, not the human ID, so the public bytes
|
||||||
|
// don't reveal hostname/snapshot-name/timestamp metadata.
|
||||||
manifest := &Manifest{
|
manifest := &Manifest{
|
||||||
SnapshotID: snapshotID,
|
SnapshotID: RemoteSnapshotKey(snapshotID),
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
BlobCount: len(blobs),
|
BlobCount: len(blobs),
|
||||||
TotalCompressedSize: totalCompressedSize,
|
TotalCompressedSize: totalCompressedSize,
|
||||||
@@ -680,8 +689,9 @@ func (sm *SnapshotManager) CleanupIncompleteSnapshots(ctx context.Context, hostn
|
|||||||
|
|
||||||
// Check each incomplete snapshot for metadata in storage
|
// Check each incomplete snapshot for metadata in storage
|
||||||
for _, snapshot := range incompleteSnapshots {
|
for _, snapshot := range incompleteSnapshots {
|
||||||
// Check if metadata exists in storage
|
// Check if metadata exists in storage (paths use the hashed
|
||||||
metadataKey := fmt.Sprintf("metadata/%s/db.zst", snapshot.ID)
|
// remote key so we don't leak host info to the listing).
|
||||||
|
metadataKey := fmt.Sprintf("metadata/%s/db.zst", RemoteSnapshotKey(snapshot.ID.String()))
|
||||||
_, err := sm.storage.Stat(ctx, metadataKey)
|
_, err := sm.storage.Stat(ctx, metadataKey)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,15 +19,20 @@ type FileStorer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
// The basePath directory will be created if it doesn't exist.
|
//
|
||||||
// Uses the real OS filesystem by default; call SetFilesystem to override for testing.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
|
// The basePath is recorded; the directory is created lazily on first
|
||||||
|
// write. Reads (Get/Stat/List) tolerate a missing basePath — a missing
|
||||||
|
// or unmounted destination during `snapshot list` should NOT block the
|
||||||
|
// command, it should degrade to "no remote snapshots reachable" with a
|
||||||
|
// warning. Write operations (Put/PutWithProgress) call MkdirAll for the
|
||||||
|
// per-blob parent directory, which also covers basePath on first use.
|
||||||
|
//
|
||||||
|
// Uses the real OS filesystem by default; call SetFilesystem to
|
||||||
|
// override for testing.
|
||||||
func NewFileStorer(basePath string) (*FileStorer, error) {
|
func NewFileStorer(basePath string) (*FileStorer, error) {
|
||||||
fs := afero.NewOsFs()
|
|
||||||
if err := fs.MkdirAll(basePath, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("file:// storage: cannot create or access %s: %w (check that the volume is mounted and writable)", basePath, err)
|
|
||||||
}
|
|
||||||
return &FileStorer{
|
return &FileStorer{
|
||||||
fs: fs,
|
fs: afero.NewOsFs(),
|
||||||
basePath: basePath,
|
basePath: basePath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -651,10 +651,12 @@ func TestEndToEndFileStorage(t *testing.T) {
|
|||||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||||
|
|
||||||
// Verify the backup actually landed on disk under blobs/ and metadata/.
|
// Verify the backup actually landed on disk under blobs/ and metadata/.
|
||||||
|
// The metadata subdirectory uses the hashed remote key, not the human
|
||||||
|
// snapshot ID, so the on-disk structure doesn't leak hostname/name/time.
|
||||||
blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs"))
|
blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, blobInfo.IsDir())
|
require.True(t, blobInfo.IsDir())
|
||||||
metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshotID))
|
metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshot.RemoteSnapshotKey(snapshotID)))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, metaInfo.IsDir())
|
require.True(t, metaInfo.IsDir())
|
||||||
|
|
||||||
|
|||||||
@@ -123,20 +123,22 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
// collectReferencedBlobs downloads all manifests and returns the set of referenced blob hashes
|
// collectReferencedBlobs downloads all manifests and returns the set of referenced blob hashes
|
||||||
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
||||||
log.Info("Listing remote snapshots")
|
log.Info("Listing remote snapshots")
|
||||||
snapshotIDs, err := v.listUniqueSnapshotIDs()
|
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
||||||
|
// subdirectories under metadata/), not human snapshot IDs.
|
||||||
|
remoteKeys, err := v.listUniqueSnapshotIDs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing snapshot IDs: %w", err)
|
return nil, fmt.Errorf("listing snapshot keys: %w", err)
|
||||||
}
|
}
|
||||||
log.Info("Found manifests in remote storage", "count", len(snapshotIDs))
|
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
||||||
|
|
||||||
allBlobsReferenced := make(map[string]bool)
|
allBlobsReferenced := make(map[string]bool)
|
||||||
manifestCount := 0
|
manifestCount := 0
|
||||||
|
|
||||||
for _, snapshotID := range snapshotIDs {
|
for _, remoteKey := range remoteKeys {
|
||||||
log.Debug("Processing manifest", "snapshot_id", snapshotID)
|
log.Debug("Processing manifest", "remote_key", remoteKey)
|
||||||
manifest, err := v.downloadManifest(snapshotID)
|
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to download manifest", "snapshot_id", snapshotID, "error", err)
|
log.Error("Failed to download manifest", "remote_key", remoteKey, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, blob := range manifest.Blobs {
|
for _, blob := range manifest.Blobs {
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ func (s *testStorer) Info() storage.StorageInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addManifest creates a compressed manifest in storage
|
// addManifest creates a compressed manifest in storage at the same
|
||||||
|
// hashed path the production code uses. snapshotID is the human ID;
|
||||||
|
// the storage path uses RemoteSnapshotKey(id).
|
||||||
func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes []string) {
|
func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -144,8 +146,9 @@ func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
manifest := &snapshot.Manifest{
|
manifest := &snapshot.Manifest{
|
||||||
SnapshotID: snapshotID,
|
SnapshotID: remoteKey,
|
||||||
BlobCount: len(blobs),
|
BlobCount: len(blobs),
|
||||||
Blobs: blobs,
|
Blobs: blobs,
|
||||||
}
|
}
|
||||||
@@ -153,11 +156,19 @@ func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes
|
|||||||
data, err := snapshot.EncodeManifest(manifest, 3)
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
key := "metadata/" + snapshotID + "/manifest.json.zst"
|
key := "metadata/" + remoteKey + "/manifest.json.zst"
|
||||||
err = store.Put(context.Background(), key, bytes.NewReader(data))
|
err = store.Put(context.Background(), key, bytes.NewReader(data))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remoteKeyPath returns the storage-relative path to a snapshot's
|
||||||
|
// metadata directory or manifest under the hashed remote-key scheme.
|
||||||
|
// Tests use this in hasKey/asserts to avoid scattering RemoteSnapshotKey
|
||||||
|
// calls throughout.
|
||||||
|
func remoteKeyPath(snapshotID, suffix string) string {
|
||||||
|
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
// addBlob adds a fake blob to storage
|
// addBlob adds a fake blob to storage
|
||||||
func addBlob(t *testing.T, store *testStorer, hash string) {
|
func addBlob(t *testing.T, store *testStorer, hash string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -198,7 +209,7 @@ func TestRemoveSnapshot_LocalOnly(t *testing.T) {
|
|||||||
// Blobs should NOT be deleted (that's what prune is for)
|
// Blobs should NOT be deleted (that's what prune is for)
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
// Remote metadata should NOT be deleted (no --remote flag)
|
// Remote metadata should NOT be deleted (no --remote flag)
|
||||||
assert.True(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify output
|
// Verify output
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
||||||
@@ -225,7 +236,7 @@ func TestRemoveSnapshot_WithRemote(t *testing.T) {
|
|||||||
// Blobs should NOT be deleted
|
// Blobs should NOT be deleted
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
// Remote metadata SHOULD be deleted
|
// Remote metadata SHOULD be deleted
|
||||||
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify output mentions prune
|
// Verify output mentions prune
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
||||||
@@ -255,7 +266,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
|||||||
// Nothing should be deleted
|
// 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("metadata/snapshot-001/manifest.json.zst"))
|
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify dry run message
|
// 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]")
|
||||||
@@ -299,8 +310,8 @@ func TestRemoveAllSnapshots_WithForce(t *testing.T) {
|
|||||||
// Blobs should NOT be deleted
|
// Blobs should NOT be deleted
|
||||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
// Remote metadata SHOULD be deleted
|
// Remote metadata SHOULD be deleted
|
||||||
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
|
||||||
assert.False(t, store.hasKey("metadata/snapshot-002/manifest.json.zst"))
|
assert.False(t, store.hasKey(remoteKeyPath("snapshot-002", "manifest.json.zst")))
|
||||||
|
|
||||||
// Verify output
|
// Verify output
|
||||||
assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)")
|
assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)")
|
||||||
@@ -318,7 +329,10 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true}
|
// --remote is required to enumerate orphan remote keys; without
|
||||||
|
// it, RemoveAll only acts on local snapshots, and NewForTesting
|
||||||
|
// has no local DB.
|
||||||
|
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true, Remote: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -394,10 +395,12 @@ func (v *Vaultik) handleRestoreVerification(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadSnapshotDB downloads and decrypts the snapshot metadata database
|
// downloadSnapshotDB downloads and decrypts the snapshot metadata
|
||||||
|
// database. The snapshotID is the human ID; we hash it to the remote
|
||||||
|
// key for the storage path.
|
||||||
func (v *Vaultik) downloadSnapshotDB(snapshotID string, identity age.Identity) (*database.DB, error) {
|
func (v *Vaultik) downloadSnapshotDB(snapshotID string, identity age.Identity) (*database.DB, error) {
|
||||||
// Download encrypted database from storage
|
// Download encrypted database from storage
|
||||||
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID)
|
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
|
||||||
reader, err := v.Storage.Get(v.ctx, dbKey)
|
reader, err := v.Storage.Get(v.ctx, dbKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotCreateOptions contains options for the snapshot create command
|
// SnapshotCreateOptions contains options for the snapshot create command
|
||||||
@@ -383,25 +380,43 @@ func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (compressed int64, unc
|
|||||||
return compressed, uncompressed
|
return compressed, uncompressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSnapshots lists all snapshots
|
// 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 {
|
func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||||
log.Info("Listing snapshots")
|
log.Info("Listing snapshots")
|
||||||
remoteSnapshots, err := v.listRemoteSnapshotIDs()
|
|
||||||
|
localSnaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("listing local snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
localSnapshotMap, err := v.reconcileLocalWithRemote(remoteSnapshots)
|
snapshots := make([]SnapshotInfo, 0, len(localSnaps))
|
||||||
if err != nil {
|
for _, ls := range localSnaps {
|
||||||
return err
|
if ls.CompletedAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshots = append(snapshots, v.snapshotInfoFromLocal(ls))
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshots, err := v.buildSnapshotInfoList(remoteSnapshots, localSnapshotMap)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
|
||||||
sort.Slice(snapshots, func(i, j int) bool {
|
sort.Slice(snapshots, func(i, j int) bool {
|
||||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||||
})
|
})
|
||||||
@@ -416,173 +431,85 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn about local snapshots that don't exist in remote storage.
|
if v.Config.AgeSecretKey == "" {
|
||||||
var stale []string
|
return nil
|
||||||
for id := range localSnapshotMap {
|
}
|
||||||
if !remoteSnapshots[id] {
|
|
||||||
stale = append(stale, id)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(stale) > 0 {
|
var remoteOnlyCount int
|
||||||
v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(stale))
|
for key := range remoteSet {
|
||||||
for _, id := range stale {
|
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("%s", v.UI.Snapshot(id))
|
||||||
}
|
}
|
||||||
v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listRemoteSnapshotIDs returns a set of snapshot IDs found in remote storage
|
// snapshotInfoFromLocal builds a SnapshotInfo row from a local snapshot
|
||||||
func (v *Vaultik) listRemoteSnapshotIDs() (map[string]bool, error) {
|
// record. Failures from any per-snapshot stat query degrade that
|
||||||
remoteSnapshots := make(map[string]bool)
|
// column to its snapshot-row fallback but never fail the listing.
|
||||||
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
||||||
|
idStr := ls.ID.String()
|
||||||
|
|
||||||
for object := range objectCh {
|
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(v.ctx, idStr)
|
||||||
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] != "" {
|
|
||||||
if strings.HasPrefix(parts[1], ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remoteSnapshots[parts[1]] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return remoteSnapshots, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileLocalWithRemote builds a map of local snapshots keyed by ID for cross-referencing with remote
|
|
||||||
func (v *Vaultik) reconcileLocalWithRemote(remoteSnapshots map[string]bool) (map[string]*database.Snapshot, error) {
|
|
||||||
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing local snapshots: %w", err)
|
log.Warn("Failed to get total compressed size", "id", idStr, "error", err)
|
||||||
|
totalSize = ls.BlobSize
|
||||||
}
|
}
|
||||||
|
|
||||||
localSnapshotMap := make(map[string]*database.Snapshot)
|
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(v.ctx, idStr)
|
||||||
for _, s := range localSnapshots {
|
if err != nil {
|
||||||
localSnapshotMap[s.ID.String()] = s
|
log.Warn("Failed to get uncompressed chunk size", "id", idStr, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return localSnapshotMap, nil
|
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, idStr)
|
||||||
}
|
if err != nil {
|
||||||
|
log.Warn("Failed to get new chunk size", "id", idStr, "error", err)
|
||||||
// buildSnapshotInfoList constructs SnapshotInfo entries from remote IDs and local data
|
|
||||||
func (v *Vaultik) buildSnapshotInfoList(remoteSnapshots map[string]bool, localSnapshotMap map[string]*database.Snapshot) ([]SnapshotInfo, error) {
|
|
||||||
snapshots := make([]SnapshotInfo, 0, len(remoteSnapshots))
|
|
||||||
|
|
||||||
// remoteOnly collects snapshot IDs that need a manifest download.
|
|
||||||
var remoteOnly []string
|
|
||||||
|
|
||||||
for snapshotID := range remoteSnapshots {
|
|
||||||
if localSnap, exists := localSnapshotMap[snapshotID]; exists && localSnap.CompletedAt != nil {
|
|
||||||
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(v.ctx, snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to get total compressed size", "id", snapshotID, "error", err)
|
|
||||||
totalSize = localSnap.BlobSize
|
|
||||||
}
|
|
||||||
|
|
||||||
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(v.ctx, snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to get uncompressed chunk size", "id", snapshotID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to get new chunk size", "id", snapshotID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots = append(snapshots, SnapshotInfo{
|
|
||||||
ID: localSnap.ID,
|
|
||||||
Timestamp: localSnap.StartedAt,
|
|
||||||
CompressedSize: totalSize,
|
|
||||||
UncompressedSize: uncompressedSize,
|
|
||||||
NewChunkSize: newChunkSize,
|
|
||||||
LocallyTracked: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
timestamp, err := parseSnapshotTimestamp(snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to parse snapshot timestamp", "id", snapshotID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-add with zero size; will be filled by concurrent downloads.
|
|
||||||
snapshots = append(snapshots, SnapshotInfo{
|
|
||||||
ID: types.SnapshotID(snapshotID),
|
|
||||||
Timestamp: timestamp,
|
|
||||||
CompressedSize: 0,
|
|
||||||
LocallyTracked: false,
|
|
||||||
})
|
|
||||||
remoteOnly = append(remoteOnly, snapshotID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download manifests concurrently for remote-only snapshots.
|
return SnapshotInfo{
|
||||||
if len(remoteOnly) > 0 {
|
ID: ls.ID,
|
||||||
// maxConcurrentManifestDownloads bounds parallel manifest fetches to
|
Timestamp: ls.StartedAt,
|
||||||
// avoid overwhelming the S3 endpoint while still being much faster
|
CompressedSize: totalSize,
|
||||||
// than serial downloads.
|
UncompressedSize: uncompressedSize,
|
||||||
const maxConcurrentManifestDownloads = 10
|
NewChunkSize: newChunkSize,
|
||||||
|
LocallyTracked: true,
|
||||||
type manifestResult struct {
|
|
||||||
snapshotID string
|
|
||||||
size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
results []manifestResult
|
|
||||||
)
|
|
||||||
|
|
||||||
g, gctx := errgroup.WithContext(v.ctx)
|
|
||||||
g.SetLimit(maxConcurrentManifestDownloads)
|
|
||||||
|
|
||||||
for _, sid := range remoteOnly {
|
|
||||||
g.Go(func() error {
|
|
||||||
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", sid)
|
|
||||||
reader, err := v.Storage.Get(gctx, manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("downloading manifest for %s: %w", sid, err)
|
|
||||||
}
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
manifest, err := snapshot.DecodeManifest(reader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decoding manifest for %s: %w", sid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
results = append(results, manifestResult{
|
|
||||||
snapshotID: sid,
|
|
||||||
size: manifest.TotalCompressedSize,
|
|
||||||
})
|
|
||||||
mu.Unlock()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching manifest sizes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a lookup from results and patch the pre-added entries.
|
|
||||||
sizeMap := make(map[string]int64, len(results))
|
|
||||||
for _, r := range results {
|
|
||||||
sizeMap[r.snapshotID] = r.size
|
|
||||||
}
|
|
||||||
for i := range snapshots {
|
|
||||||
if sz, ok := sizeMap[string(snapshots[i].ID)]; ok {
|
|
||||||
snapshots[i].CompressedSize = sz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshots, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// printSnapshotTable renders the snapshot list as a formatted table
|
// printSnapshotTable renders the snapshot list as a formatted table
|
||||||
@@ -768,7 +695,7 @@ func (v *Vaultik) confirmAndExecutePurge(toDelete []SnapshotInfo, force, quiet b
|
|||||||
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
||||||
log.Error("Failed to delete from local database", "snapshot_id", snapshotID, "error", err)
|
log.Error("Failed to delete from local database", "snapshot_id", snapshotID, "error", err)
|
||||||
}
|
}
|
||||||
if err := v.deleteSnapshotFromRemote(snapshotID); err != nil {
|
if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
|
||||||
return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err)
|
return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,8 +740,9 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
|
|||||||
|
|
||||||
v.printVerifyHeader(snapshotID, opts)
|
v.printVerifyHeader(snapshotID, opts)
|
||||||
|
|
||||||
// Download and parse manifest
|
// Download and parse manifest. The caller supplies a human
|
||||||
manifest, err := v.downloadManifest(snapshotID)
|
// snapshot ID; we hash it to address remote storage.
|
||||||
|
manifest, err := v.downloadManifestByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
result.Status = "failed"
|
result.Status = "failed"
|
||||||
@@ -929,12 +857,18 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
|||||||
|
|
||||||
// CleanupLocalSnapshots removes local snapshot records that have no
|
// CleanupLocalSnapshots removes local snapshot records that have no
|
||||||
// corresponding metadata in remote storage. These are typically left
|
// corresponding metadata in remote storage. These are typically left
|
||||||
// behind by incomplete or interrupted backups.
|
// 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 {
|
func (v *Vaultik) CleanupLocalSnapshots() error {
|
||||||
remoteSnapshots, err := v.listRemoteSnapshotIDs()
|
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -944,7 +878,7 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
|
|||||||
var removed int
|
var removed int
|
||||||
for _, snap := range localSnapshots {
|
for _, snap := range localSnapshots {
|
||||||
id := snap.ID.String()
|
id := snap.ID.String()
|
||||||
if !remoteSnapshots[id] {
|
if !remoteSet[snapshot.RemoteSnapshotKey(id)] {
|
||||||
v.printfStdout("Removing stale local record: %s\n", id)
|
v.printfStdout("Removing stale local record: %s\n", id)
|
||||||
if err := v.deleteSnapshotFromLocalDB(id); err != nil {
|
if err := v.deleteSnapshotFromLocalDB(id); err != nil {
|
||||||
log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err)
|
log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err)
|
||||||
@@ -964,8 +898,12 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
|
|||||||
|
|
||||||
// Helper methods that were previously on SnapshotApp
|
// Helper methods that were previously on SnapshotApp
|
||||||
|
|
||||||
func (v *Vaultik) downloadManifest(snapshotID string) (*snapshot.Manifest, error) {
|
// downloadManifestByKey fetches the manifest at
|
||||||
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID)
|
// 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)
|
reader, err := v.Storage.Get(v.ctx, manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1100,7 +1038,7 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
|
|||||||
// If --remote, also remove from remote storage
|
// If --remote, also remove from remote storage
|
||||||
if opts.Remote {
|
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.deleteSnapshotFromRemote(snapshotID); err != nil {
|
if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
|
||||||
return result, fmt.Errorf("removing from remote storage: %w", err)
|
return result, fmt.Errorf("removing from remote storage: %w", err)
|
||||||
}
|
}
|
||||||
result.RemoteRemoved = true
|
result.RemoteRemoved = true
|
||||||
@@ -1131,14 +1069,42 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAllSnapshots removes all snapshots from local database and optionally from remote
|
// 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) {
|
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
snapshotIDs, err := v.listAllRemoteSnapshotIDs()
|
localSnaps, err := v.localSnapshotIDs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("listing local snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(snapshotIDs) == 0 {
|
// 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 {
|
if !opts.JSON {
|
||||||
v.printlnStdout("No snapshots found")
|
v.printlnStdout("No snapshots found")
|
||||||
}
|
}
|
||||||
@@ -1146,19 +1112,42 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
return v.handleRemoveAllDryRun(snapshotIDs, opts)
|
return v.handleRemoveAllDryRun(localSnaps, orphanRemoteKeys, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.executeRemoveAll(snapshotIDs, opts)
|
return v.executeRemoveAll(localSnaps, orphanRemoteKeys, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listAllRemoteSnapshotIDs collects all unique snapshot IDs from remote storage
|
// localSnapshotIDs returns every snapshot ID present in the local
|
||||||
func (v *Vaultik) listAllRemoteSnapshotIDs() ([]string, error) {
|
// index database, sorted for deterministic iteration. Empty slice if
|
||||||
log.Info("Listing all snapshots")
|
// 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/")
|
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var snapshotIDs []string
|
var keys []string
|
||||||
for object := range objectCh {
|
for object := range objectCh {
|
||||||
if object.Err != nil {
|
if object.Err != nil {
|
||||||
return nil, fmt.Errorf("listing remote snapshots: %w", object.Err)
|
return nil, fmt.Errorf("listing remote snapshots: %w", object.Err)
|
||||||
@@ -1171,30 +1160,36 @@ func (v *Vaultik) listAllRemoteSnapshotIDs() ([]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(object.Key, "/") || strings.Contains(object.Key, "/manifest.json.zst") {
|
if strings.HasSuffix(object.Key, "/") || strings.Contains(object.Key, "/manifest.json.zst") {
|
||||||
sid := parts[1]
|
key := parts[1]
|
||||||
if !seen[sid] {
|
if !seen[key] {
|
||||||
seen[sid] = true
|
seen[key] = true
|
||||||
snapshotIDs = append(snapshotIDs, sid)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshotIDs, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRemoveAllDryRun handles the dry-run mode for removing all snapshots
|
// handleRemoveAllDryRun handles the dry-run mode for removing all snapshots
|
||||||
func (v *Vaultik) handleRemoveAllDryRun(snapshotIDs []string, opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
result := &RemoveResult{
|
result := &RemoveResult{DryRun: true}
|
||||||
DryRun: true,
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, localSnaps...)
|
||||||
SnapshotsRemoved: snapshotIDs,
|
if opts.Remote {
|
||||||
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, orphanRemoteKeys...)
|
||||||
}
|
}
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.printfStdout("Would remove %d snapshot(s):\n", len(snapshotIDs))
|
v.printfStdout("Would remove %d local snapshot(s):\n", len(localSnaps))
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range localSnaps {
|
||||||
v.printfStdout(" %s\n", id)
|
v.printfStdout(" %s\n", id)
|
||||||
}
|
}
|
||||||
if opts.Remote {
|
if opts.Remote && 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 if opts.Remote {
|
||||||
v.printlnStdout("Would also remove from remote storage")
|
v.printlnStdout("Would also remove from remote storage")
|
||||||
}
|
}
|
||||||
v.printlnStdout("[Dry run - no changes made]")
|
v.printlnStdout("[Dry run - no changes made]")
|
||||||
@@ -1205,17 +1200,19 @@ func (v *Vaultik) handleRemoveAllDryRun(snapshotIDs []string, opts *RemoveOption
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRemoveAll removes all snapshots from local database and optionally from remote storage
|
// executeRemoveAll deletes every local snapshot (and, with --remote,
|
||||||
func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*RemoveResult, error) {
|
// every corresponding remote metadata directory plus any orphan remote
|
||||||
|
// keys that don't match a local snapshot).
|
||||||
|
func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
// --all requires --force
|
// --all requires --force
|
||||||
if !opts.Force {
|
if !opts.Force {
|
||||||
return nil, fmt.Errorf("--all requires --force")
|
return nil, fmt.Errorf("--all requires --force")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Removing all snapshots", "count", len(snapshotIDs))
|
log.Info("Removing all snapshots", "local_count", len(localSnaps), "orphan_remote_count", len(orphanRemoteKeys))
|
||||||
|
|
||||||
result := &RemoveResult{}
|
result := &RemoveResult{}
|
||||||
for _, snapshotID := range snapshotIDs {
|
for _, snapshotID := range localSnaps {
|
||||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
|
||||||
@@ -1224,7 +1221,7 @@ func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Remote {
|
if opts.Remote {
|
||||||
if err := v.deleteSnapshotFromRemote(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.Error("Failed to remove from remote", "snapshot_id", snapshotID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1233,6 +1230,17 @@ func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*
|
|||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Remote {
|
||||||
|
for _, key := range orphanRemoteKeys {
|
||||||
|
log.Info("Removing orphan remote snapshot", "remote_key", key)
|
||||||
|
if err := v.deleteRemoteSnapshotByKey(key); err != nil {
|
||||||
|
log.Error("Failed to remove orphan from remote", "remote_key", key, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.Remote {
|
if opts.Remote {
|
||||||
result.RemoteRemoved = true
|
result.RemoteRemoved = true
|
||||||
}
|
}
|
||||||
@@ -1281,9 +1289,13 @@ func (v *Vaultik) deleteSnapshotFromLocalDB(snapshotID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteSnapshotFromRemote removes snapshot metadata files from remote storage
|
// deleteRemoteSnapshotByKey removes everything under
|
||||||
func (v *Vaultik) deleteSnapshotFromRemote(snapshotID string) error {
|
// metadata/<remoteKey>/ on the destination store. The argument is a
|
||||||
prefix := fmt.Sprintf("metadata/%s/", snapshotID)
|
// 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)
|
objectCh := v.Storage.ListStream(v.ctx, prefix)
|
||||||
|
|
||||||
var objectsToDelete []string
|
var objectsToDelete []string
|
||||||
|
|||||||
@@ -106,8 +106,11 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
|||||||
|
|
||||||
// loadVerificationData downloads manifest, database, and blob list for verification
|
// loadVerificationData downloads manifest, database, and blob list for verification
|
||||||
func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, result *VerifyResult) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, result *VerifyResult) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||||
|
// All remote paths use the hashed key derived from the human ID.
|
||||||
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
|
|
||||||
// Download manifest
|
// Download manifest
|
||||||
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID)
|
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
|
||||||
log.Info("Downloading manifest", "path", manifestPath)
|
log.Info("Downloading manifest", "path", manifestPath)
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
v.printfStdout("Downloading manifest...\n")
|
v.printfStdout("Downloading manifest...\n")
|
||||||
@@ -136,7 +139,7 @@ func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download and decrypt database
|
// Download and decrypt database
|
||||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID)
|
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||||
log.Info("Downloading encrypted database", "path", dbPath)
|
log.Info("Downloading encrypted database", "path", dbPath)
|
||||||
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user