1 Commits
Author SHA1 Message Date
sneak d68f373637 Count a file as deleted only if the previous snapshot of its name had it (closes #304)
check / check (push) Canceled after 0s
A backup counted as deleted every file in the local index under the
source path that the walk did not find. That took in files the
snapshot excludes but another snapshot backs up, and files removed long
ago, which every later backup, and the first backup of another name
over the same path, reported as deleted again.

The backup now counts only the files that the most recent completed
snapshot of the same name lists, the walk did not find, and are gone
from disk. A file that is excluded, or inside an excluded directory, is
not counted, nor is one the walk could not reach under --skip-errors.

The snapshots row stores no deleted figure; only the backup's output
changes.

Model: opus-5-5
2026-10-13 23:04:28 +00:00
19 changed files with 215 additions and 613 deletions

No files matched your search

+2 -4
View File
@@ -712,10 +712,8 @@ the `NO_COLOR` environment variable is unset (https://no-color.org/).
reports and tables (`version`, `info`, `remote info`, `snapshot reports and tables (`version`, `info`, `remote info`, `snapshot
verify`, `snapshot list`) but never the machine-consumed `config get` verify`, `snapshot list`) but never the machine-consumed `config get`
value or the `--json` documents, which a script depends on. The value or the `--json` documents, which a script depends on. The
confirmation prompts of `database delete`, `prune`, `snapshot purge` `database delete` confirmation prompt is also written this way and
and `snapshot remove`, each with what it asks to delete, are also always shown: it is an interactive exchange the operator must see.
written this way and always shown: they are an interactive exchange
the operator must see.
`internal/ui` writes to stdout; it is the output the user asked for. The `internal/ui` writes to stdout; it is the output the user asked for. The
exceptions are the startup banner and the error a failed command ends exceptions are the startup banner and the error a failed command ends
+1 -1
View File
@@ -22,7 +22,6 @@ require (
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.38.0
@@ -257,6 +256,7 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
+10 -6
View File
@@ -8,12 +8,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/dig"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
@@ -146,12 +146,16 @@ func (e *startupError) Error() string {
// could not build arguments for function "Y" (file:line): failed to build U: // could not build arguments for function "Y" (file:line): failed to build U:
// received non-nil error from function "Z" (file:line): <real error> // received non-nil error from function "Z" (file:line): <real error>
// //
// Users care about the real error, not the DI plumbing. dig.RootCause, // Users care about the real error, not the DI plumbing. We strip everything
// from the library fx is built on, unwraps those layers and returns the // up through the last "): " (which is always the close-paren of an fx
// real error whole. Cutting the text at "): " instead would also cut a // function-location annotation followed by the wrapped error).
// real error that contains it, such as the blob_size_limit one.
func cleanStartupError(err error) error { func cleanStartupError(err error) error {
return &startupError{msg: dig.RootCause(err).Error()} msg := err.Error()
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
msg = msg[idx+3:]
}
return &startupError{msg: msg}
} }
// RunApp starts the fx application, blocks until it is asked to stop, and // RunApp starts the fx application, blocks until it is asked to stop, and
+40 -47
View File
@@ -2,65 +2,58 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
import ( import (
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/pidlock" "sneak.berlin/go/vaultik/internal/pidlock"
) )
// TestCleanStartupError starts a real fx app whose config has a
// blob_size_limit below the minimum. fx puts annotations naming the
// functions it was building in front of the config error; only those
// may be removed, although the config error itself contains "): ".
func TestCleanStartupError(t *testing.T) { func TestCleanStartupError(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml") tests := []struct {
name string
err := os.WriteFile(path, []byte("snapshots:\n"+ in string
" test:\n"+ want string
" paths: [/tmp/vaultik-test-source]\n"+ }{
"storage_url: file:///tmp/vaultik-test-storage\n"+ {
"chunk_size: 10MB\n"+ name: "real fx error chain",
"blob_size_limit: 30MB\n"), 0o600) in: `could not build arguments for function ` +
if err != nil { `"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
t.Fatalf("write config: %v", err) `(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
`failed to build *vaultik.Vaultik: ` +
`could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
`failed to build storage.Storer: ` +
`received non-nil error from function ` +
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
},
{
name: "no fx wrapping",
in: "plain error",
want: "plain error",
},
{
name: "single fx wrapping",
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
want: "underlying problem",
},
} }
app := fx.New( for _, tt := range tests {
fx.NopLogger, t.Run(tt.name, func(t *testing.T) {
fx.Supply(config.Path(path)), t.Parallel()
config.Module,
fx.Invoke(func(*config.Config) {}),
)
err = app.Start(t.Context()) //nolint:err113 // test constructs errors from table input
if err == nil { got := cleanStartupError(errors.New(tt.in)).Error()
t.Fatal("app started; want a blob_size_limit error") if got != tt.want {
} t.Errorf("got %q, want %q", got, tt.want)
}
want := "failed to load config: invalid config: blob_size_limit must " + })
"be at least the largest chunk the chunker can emit (chunk_size " +
"times the FastCDC size spread): need at least 40000000 bytes"
got := cleanStartupError(err).Error()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
// shown unchanged.
func TestCleanStartupErrorNotFromFx(t *testing.T) {
t.Parallel()
//nolint:err113 // a one-off error standing in for a startup failure
got := cleanStartupError(errors.New("plain error")).Error()
if got != "plain error" {
t.Errorf("got %q, want %q", got, "plain error")
} }
} }
-123
View File
@@ -1,123 +0,0 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flagCron is used to build the argument vectors below.
const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, so that a prune deletes it. Returns its
// path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
hash := strings.Repeat("b", 64)
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
return path
}
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
// `snapshot create --prune` under --cron or --quiet prints nothing on
// stdout or stderr, whether or not the prune finds a blob to delete.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
plantBlob bool
}{
{
name: "cron, no blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
},
{
name: "cron, blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
plantBlob: true,
},
{
name: "cron with keep-newer-than",
args: []string{
cmdSnapshot, cmdCreate, flagCron, flagPrune,
flagKeepNewerThan, "4w",
},
plantBlob: true,
},
{
name: "quiet",
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
plantBlob: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
var blob string
if testCase.plantBlob {
blob = plantUnreferencedBlob(t, storeDir)
}
code, stdout, stderr := runEntry(t,
append([]string{flagConfig, configPath}, testCase.args...)...)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.plantBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
// under --quiet prints nothing on stdout or stderr, both when there is
// nothing to remove and when it removes a stale local snapshot record
// and an unreferenced blob.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryPruneQuietIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
staleRecordAndBlob bool
}{
{name: "nothing to remove"},
{name: "stale record and blob to remove", staleRecordAndBlob: true},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
var blob string
if testCase.staleRecordAndBlob {
// writeHermeticPruneConfig puts the store beside the config.
blob = plantUnreferencedBlob(t,
filepath.Join(filepath.Dir(configPath), "store"))
}
code, stdout, stderr := runEntry(t,
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.staleRecordAndBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
+3 -3
View File
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
// internally and will fall back to LRU eviction if curBytes exceeds // internally and will fall back to LRU eviction if curBytes exceeds
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives // maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
// eviction itself via Delete() through restoreSweeper, which deletes // eviction itself via Delete() through restoreSweeper, which deletes
// each blob once no file still to be restored in this run needs it. // each blob the moment every file that references its chunks has been
// LRU never fires under that configuration; it is kept as a safety net // written. LRU never fires under that configuration; it is kept as a
// for callers that don't manage eviction themselves. // safety net for callers that don't manage eviction themselves.
// //
// Get/ReadAt/peak-Len counters are debugging instrumentation used by // Get/ReadAt/peak-Len counters are debugging instrumentation used by
// tests to assert that the restore code path uses ReadAt rather than // tests to assert that the restore code path uses ReadAt rather than
+4 -9
View File
@@ -204,7 +204,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return v.outputPruneBlobsJSON(result) return v.outputPruneBlobsJSON(result)
} }
v.UI.Infof("No unreferenced blobs to remove.") v.printlnStdout("No unreferenced blobs to remove.")
return nil return nil
} }
@@ -212,17 +212,12 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
log.Info("Found unreferenced blobs", log.Info("Found unreferenced blobs",
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize)) "count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
if opts.Force && !opts.JSON { if !opts.JSON {
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.", v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
len(unreferencedBlobs), ubytes(totalSize)) len(unreferencedBlobs), ubytes(totalSize))
} }
if !opts.Force && !opts.JSON { if !opts.Force && !opts.JSON {
// The count, the prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
len(unreferencedBlobs), ubytes(totalSize))
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs)) v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string var confirm string
@@ -249,7 +244,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err return err
} }
} else { } else {
v.UI.Infof("Deleted %d blob(s) totaling %s.", v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
result.BlobsDeleted, ubytes(result.BytesFreed)) result.BlobsDeleted, ubytes(result.BytesFreed))
} }
@@ -14,7 +14,6 @@ import (
"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" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -59,16 +58,13 @@ func setupConsistencyTest(
require.NoError(t, err) require.NoError(t, err)
} }
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{ v := &vaultik.Vaultik{
Storage: mockStorage, Storage: mockStorage,
Repositories: repos, Repositories: repos,
DB: db, DB: db,
Stdout: stdout, Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{}, Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{}, Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
} }
v.SetContext(ctx) v.SetContext(ctx)
-2
View File
@@ -14,7 +14,6 @@ import (
"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" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -87,7 +86,6 @@ func setupPurgeTest(
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
Stdin: stdin, Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
} }
v.SetContext(ctx) v.SetContext(ctx)
-148
View File
@@ -1,148 +0,0 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
// --keep-latest --force` under --quiet deletes the older snapshot and
// prints nothing.
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
}))
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
assert.Empty(t, out.String())
}
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
// purge --keep-latest` without --force still lists the snapshots its
// prompt asks to delete under --quiet.
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
}))
assert.Contains(t, out.String(), snapHomeT0)
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
assert.Len(t, listRemainingSnapshots(t, v), 2)
}
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
// --force still states the count and total size of the blobs its prompt
// asks to delete under --quiet.
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.v.UI.SetQuiet(true)
// The store holds no manifest, so nothing references this blob.
addBlob(t, env.store.testStorer, testBlobHashA)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
assert.Contains(t, env.stdout.String(),
"Found 1 unreferenced blob(s) totaling")
assert.Contains(t, env.stdout.String(),
"Delete 1 unreferenced blob(s)? [y/N]")
}
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
// under --quiet prints nothing with --dry-run or with --force, and that
// the --json document still prints.
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.UI.SetQuiet(true)
return tv, store
}
tv, store := newQuietRemover()
_, err := tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{DryRun: true})
require.NoError(t, err)
assert.Empty(t, tv.Stdout.String(),
"--dry-run must print nothing under --quiet")
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String(),
"--force must print nothing under --quiet")
tv, _ = newQuietRemover()
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{JSON: true})
require.NoError(t, err)
var result vaultik.RemoveResult
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
"the --json document must still print under --quiet")
assert.True(t, result.RemoteRemoved)
}
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
// under --quiet prints nothing while it removes a snapshot's metadata
// and its blobs.
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
v.UI.SetQuiet(true)
require.NoError(t, v.NukeRemote(true))
assert.Empty(t, out.String())
}
+2 -2
View File
@@ -350,7 +350,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001"))) assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.") assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
} }
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no // TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
@@ -504,7 +504,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
assert.Equal(t, initialCount, store.keyCount()) assert.Equal(t, initialCount, store.keyCount())
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.") assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
} }
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) { func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
+10 -8
View File
@@ -323,6 +323,12 @@ func (v *Vaultik) restoreAllFiles(
_ = blobCache.Close() _ = blobCache.Close()
}() }()
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob whose remaining file references
// are all already restored.
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos) blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -334,12 +340,6 @@ func (v *Vaultik) restoreAllFiles(
return nil, fmt.Errorf("building restore plan: %w", err) return nil, fmt.Errorf("building restore plan: %w", err)
} }
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob that no pending file in the
// plan references.
sweeper := newRestoreSweeper(plan, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
// Ranging over files rather than the map reports them in a stable order. // Ranging over files rather than the map reports them in a stable order.
for _, file := range files { for _, file := range files {
if planErr, ok := unplanned[file.ID]; ok { if planErr, ok := unplanned[file.ID]; ok {
@@ -427,8 +427,10 @@ func (v *Vaultik) runRestoreLoop(
continue continue
} }
// Drop the file from the plan's indexes so future picks ignore // Record the file as restored so the sweeper can free blobs
// it and the sweeper can free blobs no pending file needs. // once all referencing files are done, and drop it from the
// plan's indexes so future picks ignore it.
session.sweeper.fileRestored(fileID.String())
plan.finishFile(fileID) plan.finishFile(fileID)
processed++ processed++
+1 -2
View File
@@ -74,8 +74,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
buildLocalityFixture(t, fs, dataDir) buildLocalityFixture(t, fs, dataDir)
cfg, storer, snapshotID := setupLocalityBackup( cfg, storer, snapshotID := setupLocalityBackup(
context.Background(), t, fs, dataDir, storeDir, dbPath, context.Background(), t, fs, dataDir, storeDir, dbPath)
localitySourceBytes)
// Point the "" temp paths (the blob cache directory and the // Point the "" temp paths (the blob cache directory and the
// snapshot-database directory) at a private directory so the test can // snapshot-database directory) at a private directory so the test can
+11 -15
View File
@@ -63,10 +63,6 @@ type localityCopy struct {
data []byte data []byte
} }
// localitySourceBytes is the size of each source file of the locality
// fixture.
const localitySourceBytes = 1024 * 1024
// buildLocalityFixture writes the adversarial source layout described // buildLocalityFixture writes the adversarial source layout described
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the // in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies // backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
@@ -77,6 +73,7 @@ func buildLocalityFixture(
t.Helper() t.Helper()
const ( const (
srcBytes = 1024 * 1024
srcCount = 15 srcCount = 15
blobsCount = 3 blobsCount = 3
perBlob = srcCount / blobsCount perBlob = srcCount / blobsCount
@@ -86,7 +83,7 @@ func buildLocalityFixture(
for i := range srcCount { for i := range srcCount {
s := &localitySource{ s := &localitySource{
path: fmt.Sprintf("src-%03d.bin", i+1), path: fmt.Sprintf("src-%03d.bin", i+1),
data: randomBytes(t, localitySourceBytes), data: randomBytes(t, srcBytes),
} }
sources[i] = s sources[i] = s
require.NoError(t, afero.WriteFile( require.NoError(t, afero.WriteFile(
@@ -118,20 +115,19 @@ func buildLocalityFixture(
// setupLocalityBackup runs the backup half of the locality test: a // setupLocalityBackup runs the backup half of the locality test: a
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk // snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
// files of srcBytes pack into 3 blobs. The index database is closed // files pack into 3 blobs. The index database is closed before returning
// before returning so the restore half runs from remote bytes only. // so the restore half runs from remote bytes only.
func setupLocalityBackup( func setupLocalityBackup(
ctx context.Context, t *testing.T, fs afero.Fs, ctx context.Context, t *testing.T, fs afero.Fs,
dataDir, storeDir, dbPath string, srcBytes int64, dataDir, storeDir, dbPath string,
) (*config.Config, *storage.FileStorer, string) { ) (*config.Config, *storage.FileStorer, string) {
t.Helper() t.Helper()
// An average chunk size of four times srcBytes makes the minimum // chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
// chunk srcBytes, so each source file becomes one chunk. A blob limit // becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3 // per blob, yielding 3 blobs from 15 source files.
// blobs from 15 source files. chunkSize := int64(4 * 1024 * 1024)
chunkSize := 4 * srcBytes maxBlobSize := int64(5 * 1024 * 1024)
maxBlobSize := 5 * srcBytes
storer, err := storage.NewFileStorer(storeDir) storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err) require.NoError(t, err)
@@ -212,7 +208,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cfg, storer, snapshotID := setupLocalityBackup( cfg, storer, snapshotID := setupLocalityBackup(
ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes) ctx, t, fs, dataDir, storeDir, dbPath)
// Wrap the storer so we can count downloads per blob key. // Wrap the storer so we can count downloads per blob key.
counter := newCountingStorer(storer) counter := newCountingStorer(storer)
+1 -8
View File
@@ -31,8 +31,7 @@ var (
// with no further downloads. // with no further downloads.
// - blobFiles: for each blob, the set of pending files referencing // - blobFiles: for each blob, the set of pending files referencing
// it. Used to short-circuit "when this blob lands, which files // it. Used to short-circuit "when this blob lands, which files
// become ready" without a global scan, and by the sweeper to tell // become ready" without a global scan.
// whether a cached blob is still needed.
type restorePlan struct { type restorePlan struct {
fileBlobs map[types.FileID]map[string]struct{} fileBlobs map[types.FileID]map[string]struct{}
blobFiles map[string]map[types.FileID]struct{} blobFiles map[string]map[types.FileID]struct{}
@@ -238,12 +237,6 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
return out return out
} }
// pendingFileUsesBlob reports whether any pending file references the
// named blob, whether or not the blob is cached.
func (p *restorePlan) pendingFileUsesBlob(blobHash string) bool {
return len(p.blobFiles[blobHash]) > 0
}
// hasPending reports whether any unfinished files remain. // hasPending reports whether any unfinished files remain.
func (p *restorePlan) hasPending() bool { func (p *restorePlan) hasPending() bool {
return len(p.fileBlobs) > 0 return len(p.fileBlobs) > 0
+9 -9
View File
@@ -63,6 +63,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
t.Cleanup(func() { _ = cache.Close() }) t.Cleanup(func() { _ = cache.Close() })
v := &Vaultik{ctx: ctx}
session := &restoreSession{
v: v,
ctx: ctx,
repos: repos,
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
result: &RestoreResult{},
}
// A file that is still pending but whose uncached-blob set is empty // A file that is still pending but whose uncached-blob set is empty
// and which was never queued as ready: the loop can neither restore // and which was never queued as ready: the loop can neither restore
// nor download it. This is the abandonment the guard must catch. // nor download it. This is the abandonment the guard must catch.
@@ -74,15 +83,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
cached: map[string]struct{}{}, cached: map[string]struct{}{},
} }
v := &Vaultik{ctx: ctx}
session := &restoreSession{
v: v,
ctx: ctx,
repos: repos,
sweeper: newRestoreSweeper(plan, cache, 1),
result: &RestoreResult{},
}
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0) err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
require.ErrorIs(t, err, errRestoreIncomplete) require.ErrorIs(t, err, errRestoreIncomplete)
} }
+88 -14
View File
@@ -1,30 +1,46 @@
package vaultik package vaultik
// restoreSweeper frees cached blobs once no file still to be restored in import (
// this run needs them. It works as follows: "context"
"fmt"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
)
// restoreSweeper frees cached blobs once all files that reference any of
// their chunks have been restored. It works as follows:
// //
// 1. After each chunk is restored, chunkRestored accumulates a running // 1. Callers add a file's ID to an in-memory restored set via
// fileRestored once the file is fully written to disk.
// 2. After each chunk is restored, chunkRestored accumulates a running
// byte count. // byte count.
// 2. When the accumulator crosses a threshold (one hundredth of the // 3. When the accumulator crosses a threshold (one hundredth of the
// configured blob size — so a sweep runs about a hundred times per // configured blob size — so a sweep runs about a hundred times per
// blob's worth of restored bytes), the sweeper iterates every key in // blob's worth of restored bytes), the sweeper iterates every key in
// the cache and deletes each blob that the restore plan no longer // the cache. For each cached blob it asks the DB which files
// needs. // reference any chunk in that blob, then compares that list against
// the in-memory restored set. If any referencing file is missing
// from the set the blob is kept; otherwise the cache entry is
// deleted.
// //
// The plan drops a file once it is restored or has failed, and holds // All DB reads happen against the snapshot's temporary metadata DB,
// only the files this run restores, so files outside the requested paths // which is local, indexed, and not under contention — the queries are
// never keep a blob. // cheap and run at most once per blob per sweep interval.
type restoreSweeper struct { type restoreSweeper struct {
plan *restorePlan ctx context.Context //nolint:containedctx // ctx bound at construction by design
repos *database.Repositories
cache *blobDiskCache cache *blobDiskCache
threshold int64 threshold int64
bytesAccum int64 bytesAccum int64
restored map[string]struct{}
} }
// newRestoreSweeper returns a sweeper that triggers eviction every // newRestoreSweeper returns a sweeper that triggers eviction every
// `threshold` bytes restored. Callers should pass blob_size_limit/100. // `threshold` bytes restored. Callers should pass blob_size_limit/100.
func newRestoreSweeper( func newRestoreSweeper(
plan *restorePlan, ctx context.Context,
repos *database.Repositories,
cache *blobDiskCache, cache *blobDiskCache,
threshold int64, threshold int64,
) *restoreSweeper { ) *restoreSweeper {
@@ -33,12 +49,21 @@ func newRestoreSweeper(
} }
return &restoreSweeper{ return &restoreSweeper{
plan: plan, ctx: ctx,
repos: repos,
cache: cache, cache: cache,
threshold: threshold, threshold: threshold,
restored: make(map[string]struct{}),
} }
} }
// fileRestored records a file as fully restored. After this call, any
// blob whose only remaining references come from files in the restored
// set may be evicted on the next sweep.
func (s *restoreSweeper) fileRestored(fileID string) {
s.restored[fileID] = struct{}{}
}
// chunkRestored accounts n bytes against the sweep threshold and runs a // chunkRestored accounts n bytes against the sweep threshold and runs a
// sweep if the threshold has been crossed since the last sweep. // sweep if the threshold has been crossed since the last sweep.
func (s *restoreSweeper) chunkRestored(n int64) { func (s *restoreSweeper) chunkRestored(n int64) {
@@ -51,11 +76,60 @@ func (s *restoreSweeper) chunkRestored(n int64) {
s.sweep() s.sweep()
} }
// sweep deletes every cached blob that no pending file references. // sweep deletes any cached blob whose chunks are no longer referenced
// by an unrestored file. Per-blob DB failures are logged and the blob
// is kept — we'd rather hold a blob longer than risk a re-download.
func (s *restoreSweeper) sweep() { func (s *restoreSweeper) sweep() {
for _, blobHash := range s.cache.Keys() { for _, blobHash := range s.cache.Keys() {
if !s.plan.pendingFileUsesBlob(blobHash) { needed, err := s.blobStillNeeded(blobHash)
if err != nil {
log.Debug("sweeper referencing-files query failed",
"blob_hash", blobHash[:16], "error", err)
continue
}
if !needed {
s.cache.Delete(blobHash) s.cache.Delete(blobHash)
} }
} }
} }
// blobStillNeeded returns true if any file that references a chunk in
// this blob has not yet been restored. On any error the function
// returns true — keeping the blob is always the safe answer because we
// can't prove we're done with it.
func (s *restoreSweeper) blobStillNeeded(blobHash string) (bool, error) {
rows, err := s.repos.DB().Conn().QueryContext(s.ctx, `
SELECT DISTINCT fc.file_id
FROM file_chunks fc
JOIN blob_chunks bc ON bc.chunk_hash = fc.chunk_hash
JOIN blobs b ON b.id = bc.blob_id
WHERE b.blob_hash = ?
`, blobHash)
if err != nil {
return true, fmt.Errorf("querying referencing files: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var fileID string
err := rows.Scan(&fileID)
if err != nil {
return true, fmt.Errorf("scanning file_id: %w", err)
}
if _, ok := s.restored[fileID]; !ok {
return true, nil
}
}
err = rows.Err()
if err != nil {
return true, err
}
return false, nil
}
-163
View File
@@ -1,163 +0,0 @@
package vaultik //nolint:testpackage // reads the restore blob cache's peak size
import (
"context"
"fmt"
"io"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/ui"
)
// TestRestoreFreesBlobsNoPendingFileNeeds asserts that the restore cache
// frees a blob once no file still to be restored in this run needs it.
// Every original file has a copy with the same content, so every blob
// holds chunks of copies: a restore that leaves the copies out of its
// paths, or fails on them, must not keep the blobs for them.
func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
// Small files keep the backup and both restores quick.
const fileBytes = 16 * 1024
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
originalsDir := filepath.Join(dataDir, "originals")
copiesDir := filepath.Join(dataDir, "copies")
storeDir := filepath.Join(tempDir, "remote")
dbPath := filepath.Join(tempDir, "index.sqlite")
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
ctx := context.Background()
cfg, storer, snapshotID := setupLocalityBackup(
ctx, t, fs, dataDir, storeDir, dbPath, fileBytes)
t.Run("restore by path", func(t *testing.T) {
t.Parallel()
restoreDir := filepath.Join(tempDir, "by-path")
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
&RestoreOptions{
SnapshotID: snapshotID,
TargetDir: restoreDir,
Paths: []string{originalsDir},
})
require.NoError(t, err)
requireRestoredFiles(t, fs, restoreDir, originals)
assert.LessOrEqualf(t, peak, 1,
"peak cached blobs was %d; blobs were kept for unselected files",
peak)
})
t.Run("restore with failed files", func(t *testing.T) {
t.Parallel()
restoreDir := filepath.Join(tempDir, "with-failures")
// A regular file where the copies directory belongs makes every
// copy fail to restore.
blocker := filepath.Join(restoreDir, copiesDir)
require.NoError(t, fs.MkdirAll(filepath.Dir(blocker), 0o755))
require.NoError(t, afero.WriteFile(fs, blocker, nil, 0o644))
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
&RestoreOptions{
SnapshotID: snapshotID,
TargetDir: restoreDir,
SkipErrors: true,
})
require.ErrorIs(t, err, errFilesFailedRestore)
requireRestoredFiles(t, fs, restoreDir, originals)
assert.LessOrEqualf(t, peak, 1,
"peak cached blobs was %d; blobs were kept for failed files", peak)
})
}
// writeFilesWithCopies writes 15 random files of fileBytes into
// originalsDir and a copy of each into copiesDir. With
// setupLocalityBackup given the same size, each file is one chunk and the
// 15 chunks fill 3 blobs. Returns the originals' content by path.
func writeFilesWithCopies(
t *testing.T, fs afero.Fs, originalsDir, copiesDir string, fileBytes int,
) map[string][]byte {
t.Helper()
const fileCount = 15
require.NoError(t, fs.MkdirAll(originalsDir, 0o755))
require.NoError(t, fs.MkdirAll(copiesDir, 0o755))
originals := make(map[string][]byte, fileCount)
for i := range fileCount {
name := fmt.Sprintf("file-%02d.bin", i)
data := randomBytes(t, fileBytes)
original := filepath.Join(originalsDir, name)
originals[original] = data
require.NoError(t, afero.WriteFile(fs, original, data, 0o644))
require.NoError(t, afero.WriteFile(
fs, filepath.Join(copiesDir, name), data, 0o644))
}
return originals
}
// requireRestoredFiles requires every file in want to have been restored
// under restoreDir with its content intact.
func requireRestoredFiles(
t *testing.T, fs afero.Fs, restoreDir string, want map[string][]byte,
) {
t.Helper()
for path, data := range want {
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, path))
require.NoErrorf(t, err, "file missing after restore: %s", path)
require.Equalf(t, data, got, "byte mismatch for %s", path)
}
}
// restorePeakCachedBlobs runs a restore and returns the most blobs its
// cache held at once, together with the restore's error.
func restorePeakCachedBlobs(
ctx context.Context, t *testing.T, fs afero.Fs,
cfg *config.Config, storer storage.Storer, opts *RestoreOptions,
) (int, error) {
t.Helper()
var cache *blobDiskCache
v := &Vaultik{
Config: cfg,
Storage: storer,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
restoreCacheObserver: func(c *blobDiskCache) {
cache = c
},
}
v.SetContext(ctx)
err := v.Restore(opts)
require.NotNil(t, cache, "restoreCacheObserver must fire during restore")
return cache.PeakLen(), err
}
+32 -44
View File
@@ -598,7 +598,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
if len(toDelete) == 0 { if len(toDelete) == 0 {
if !opts.Quiet { if !opts.Quiet {
v.UI.Infof("No snapshots to delete.") v.printlnStdout("No snapshots to delete")
} }
return nil return nil
@@ -655,19 +655,19 @@ func selectSnapshotsToPurge(
func (v *Vaultik) confirmAndExecutePurge( func (v *Vaultik) confirmAndExecutePurge(
toDelete []SnapshotInfo, force, quiet bool, toDelete []SnapshotInfo, force, quiet bool,
) error { ) error {
// Confirm unless --force is used. The list, the prompt and its answer if !quiet {
// bypass the UI: they are an interactive exchange the operator must
// see, so --quiet does not silence them.
if !force {
v.stdoutf("The following snapshots will be deleted:\n\n") v.stdoutf("The following snapshots will be deleted:\n\n")
for _, snap := range toDelete { for _, snap := range toDelete {
v.stdoutf(" %s (%s, %s)\n", v.stdoutf(" %s (%s, %s)\n",
snap.ID, snap.ID,
snap.Timestamp.Format(time.DateTime), snap.Timestamp.Format("2006-01-02 15:04:05"),
ubytes(snap.CompressedSize)) ubytes(snap.CompressedSize))
} }
}
// Confirm unless --force is used
if !force {
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete)) v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
var confirm string var confirm string
@@ -685,16 +685,7 @@ func (v *Vaultik) confirmAndExecutePurge(
return nil return nil
} }
} else if !quiet { } else if !quiet {
v.UI.Infof("The following snapshots will be deleted:") v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
for _, snap := range toDelete {
v.UI.Detailf("%s (%s, %s)",
v.UI.Snapshot(snap.ID.String()),
snap.Timestamp.Format(time.DateTime),
ubytes(snap.CompressedSize))
}
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
} }
// Delete snapshots (both local and remote) // Delete snapshots (both local and remote)
@@ -727,9 +718,9 @@ func (v *Vaultik) confirmAndExecutePurge(
} }
if !quiet { if !quiet {
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete)) v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.", v.printlnStdout(
pruneCommandHint) "\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
} }
return nil return nil
@@ -1102,7 +1093,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
log.Info("Removing stale local snapshot record", "snapshot_id", id) log.Info("Removing stale local snapshot record", "snapshot_id", id)
if !opts.JSON { if !opts.JSON {
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id)) v.stdoutf("Removing stale local record: %s\n", id)
} }
err = v.deleteSnapshotFromLocalDB(id) err = v.deleteSnapshotFromLocalDB(id)
@@ -1125,9 +1116,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
} }
if removed == 0 { if removed == 0 {
v.UI.Infof("No stale local snapshots found.") v.printlnStdout("No stale local snapshots found.")
} else { } else {
v.UI.Infof("Removed %d stale local snapshot record(s).", removed) v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
} }
return nil return nil
@@ -1306,15 +1297,14 @@ func (v *Vaultik) RemoveSnapshot(
} }
if inLocalIndex { if inLocalIndex {
v.UI.Infof("Removed snapshot '%s' from local database.", v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
v.UI.Snapshot(snapshotID))
} }
if result.RemoteRemoved { if result.RemoteRemoved {
v.UI.Infof("Removed snapshot metadata from remote storage.") v.printlnStdout("Removed snapshot metadata from remote storage")
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+ v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
"Run '%s' to delete any blobs no longer referenced by any "+ "Run '%s' to delete any blobs no longer referenced by any "+
"remaining remote snapshot.", pruneCommandHint) "remaining remote snapshot.\n", pruneCommandHint)
} }
return result, nil return result, nil
@@ -1370,23 +1360,21 @@ func (v *Vaultik) removeSnapshotDryRun(
return result, v.outputRemoveJSON(result) return result, v.outputRemoveJSON(result)
} }
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID)) v.stdoutf("Would remove snapshot: %s\n", snapshotID)
if !opts.LocalOnly { if !opts.LocalOnly {
v.UI.Infof("Would also remove snapshot metadata from " + v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).") "remote storage (blobs untouched)")
} }
v.UI.Noticef("Dry run: no changes made.") v.printlnStdout("[Dry run - no changes made]")
return result, nil return result, nil
} }
// confirmRemoveSnapshot prompts for confirmation before removing a // confirmRemoveSnapshot prompts for confirmation before removing a
// snapshot. Returns false (treating EOF/read errors as "no") unless the // snapshot. Returns false (treating EOF/read errors as "no") unless the
// user answers "y". The prompt and its answer bypass the UI: they are an // user answers "y".
// interactive exchange the operator must see, so --quiet does not
// silence them.
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool { func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
if opts.LocalOnly { if opts.LocalOnly {
v.stdoutf("Remove snapshot '%s' from local database "+ v.stdoutf("Remove snapshot '%s' from local database "+
@@ -1492,7 +1480,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 { if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
if !opts.JSON { if !opts.JSON {
v.UI.Infof("No snapshots found.") v.printlnStdout("No snapshots found")
} }
return &RemoveResult{}, nil return &RemoveResult{}, nil
@@ -1597,27 +1585,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
} }
if !opts.JSON { if !opts.JSON {
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps)) v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
for _, id := range localSnaps { for _, id := range localSnaps {
v.UI.Detailf("%s", v.UI.Snapshot(id)) v.stdoutf(" %s\n", id)
} }
if !opts.LocalOnly { if !opts.LocalOnly {
if len(orphanRemoteKeys) > 0 { if len(orphanRemoteKeys) > 0 {
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):", v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
len(orphanRemoteKeys)) len(orphanRemoteKeys))
for _, key := range orphanRemoteKeys { for _, key := range orphanRemoteKeys {
v.UI.Detailf("%s", key) v.stdoutf(" %s\n", key)
} }
} else { } else {
v.UI.Infof("Would also remove snapshot metadata from " + v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).") "remote storage (blobs untouched)")
} }
} }
v.UI.Noticef("Dry run: no changes made.") v.printlnStdout("[Dry run - no changes made]")
} }
if opts.JSON { if opts.JSON {
@@ -1666,10 +1654,10 @@ func (v *Vaultik) executeRemoveAll(
return result, v.outputRemoveJSON(result) return result, v.outputRemoveJSON(result)
} }
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved)) v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
if !opts.LocalOnly && result.RemoteRemoved { if !opts.LocalOnly && result.RemoteRemoved {
v.UI.Infof("Removed snapshot metadata from remote storage.") v.printlnStdout("Removed snapshot metadata from remote storage")
} }
return result, nil return result, nil