Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f008a21a |
@@ -359,7 +359,8 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||
* `--dry-run`: Show what would be deleted without deleting
|
||||
* `--force`: Skip confirmation prompt
|
||||
* `--json`: Output result as JSON
|
||||
* `--json`: Output result as JSON. Also skips the confirmation prompt, as
|
||||
`--force` does.
|
||||
|
||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||
@@ -383,7 +384,8 @@ manifests — network cost scales with the number of snapshots. `snapshot
|
||||
create --prune` runs the same cleanup automatically; this is the
|
||||
manual entry point for the same work.
|
||||
* `--force`: Skip confirmation prompt
|
||||
* `--json`: Output stats as JSON
|
||||
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||
`--force` does.
|
||||
|
||||
**`info`**: Display system configuration, storage settings, encryption
|
||||
recipients, and local database statistics.
|
||||
|
||||
@@ -22,15 +22,6 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-08: Kept the progress line of a snapshot with more than one
|
||||
path within 100%
|
||||
([issue #271](https://git.eeqj.de/sneak/vaultik/issues/271)). The
|
||||
bytes and files processed counted every path, but the totals they
|
||||
were divided by held only the current path's, so a second path
|
||||
smaller than the first showed more than 100% and an ETA of `unknown`.
|
||||
The totals now add up over the paths scanned so far, and the rate is
|
||||
measured from when the first path's processing started.
|
||||
|
||||
- 2026-10-08: Made a second `snapshot create` of one name succeed when
|
||||
it starts in the same second as the first
|
||||
([issue #270](https://git.eeqj.de/sneak/vaultik/issues/270)). The
|
||||
@@ -39,6 +30,14 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
snapshots.id`. When the local index already has a snapshot with the
|
||||
ID, the create now waits a second and takes a new timestamp.
|
||||
|
||||
- 2026-10-07: Documented that `--json` skips the confirmation prompt of
|
||||
`snapshot remove` and `prune`
|
||||
([issue #268](https://git.eeqj.de/sneak/vaultik/issues/268)). Both
|
||||
commands delete without asking under `--json`, since a prompt on stdout
|
||||
would break the JSON document, but the help and the README described
|
||||
only `--force` as skipping it. The `--json` help of both commands and
|
||||
their README entries now say so.
|
||||
|
||||
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
||||
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
||||
left out of the snapshot with only a debug log line, even without
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||
// prompt. Both delete without asking under --json, since a prompt on
|
||||
// stdout would break the JSON document.
|
||||
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newSnapshotRemoveCommand(),
|
||||
NewPruneCommand(),
|
||||
} {
|
||||
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||
"skips the confirmation prompt", cmd.Name())
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@ referenced.`,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -280,7 +280,8 @@ nuke --force' — it is the single supported entry point for that.`,
|
||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||
"Show what would be removed without removing")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||
"Skip remote cleanup; only touch the local index")
|
||||
|
||||
|
||||
@@ -67,9 +67,9 @@ type ProgressStats struct {
|
||||
BlobsUploaded atomic.Int64
|
||||
BytesUploaded atomic.Int64
|
||||
CurrentFile atomic.Value // stores string
|
||||
TotalSize atomic.Int64 // Size to process in the paths scanned so far
|
||||
TotalFiles atomic.Int64 // Files to process in the paths scanned so far
|
||||
ProcessStartTime atomic.Value // stores time.Time; set by the first path
|
||||
TotalSize atomic.Int64 // Total size to process (set after scan phase)
|
||||
TotalFiles atomic.Int64 // Total files to process in phase 2
|
||||
ProcessStartTime atomic.Value // stores time.Time when processing starts
|
||||
StartTime time.Time
|
||||
mu sync.RWMutex
|
||||
lastDetailTime time.Time
|
||||
@@ -148,17 +148,10 @@ func (pr *ProgressReporter) GetStats() *ProgressStats {
|
||||
return pr.stats
|
||||
}
|
||||
|
||||
// AddTotalSize adds the size one path of the snapshot has to process to
|
||||
// the total, once that path's scan phase is done. The processed counts
|
||||
// run across every path, so the total does too, and the processing start
|
||||
// time, which the rate is measured from, is the first path's.
|
||||
func (pr *ProgressReporter) AddTotalSize(size int64) {
|
||||
pr.stats.TotalSize.Add(size)
|
||||
|
||||
_, started := pr.stats.ProcessStartTime.Load().(time.Time)
|
||||
if !started {
|
||||
pr.stats.ProcessStartTime.Store(time.Now().UTC())
|
||||
}
|
||||
// SetTotalSize sets the total size to process (after scan phase)
|
||||
func (pr *ProgressReporter) SetTotalSize(size int64) {
|
||||
pr.stats.TotalSize.Store(size)
|
||||
pr.stats.ProcessStartTime.Store(time.Now().UTC())
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// TestProgressPercentWithSecondPathSmaller backs up two paths with one
|
||||
// scanner, as a snapshot with two paths does, the second path smaller
|
||||
// than the first. The progress line divides the bytes processed by the
|
||||
// total size, so both must count both paths to stay within 100%.
|
||||
func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string]string{
|
||||
"/large/one.txt": strings.Repeat("1", 4000),
|
||||
"/large/two.txt": strings.Repeat("2", 4000),
|
||||
"/small/three.txt": strings.Repeat("3", 1000),
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
err := fs.MkdirAll(filepath.Dir(path), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cerr := db.Close()
|
||||
if cerr != nil {
|
||||
t.Errorf("close db: %v", cerr)
|
||||
}
|
||||
})
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: int64(1024 * 16),
|
||||
Repositories: repos,
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
EnableProgress: true,
|
||||
})
|
||||
|
||||
// Never started, but Stop releases its tickers and signal handler.
|
||||
progress := scanner.GetProgress()
|
||||
defer progress.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
snapshotID := "test-snapshot-progress"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
for _, path := range []string{"/large", "/small"} {
|
||||
_, err := scanner.Scan(ctx, path, snapshotID)
|
||||
if err != nil {
|
||||
t.Fatalf("scanning %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
stats := progress.GetStats()
|
||||
|
||||
// Directories count toward the total size but produce no chunks, so
|
||||
// the percentage ends just under 100%.
|
||||
percent := float64(stats.BytesProcessed.Load()) /
|
||||
float64(stats.TotalSize.Load()) * 100
|
||||
if percent > 100 {
|
||||
t.Errorf("progress after both paths is %.1f%%, want at most 100%%",
|
||||
percent)
|
||||
}
|
||||
|
||||
if stats.FilesProcessed.Load() != stats.TotalFiles.Load() {
|
||||
t.Errorf("progress after both paths is %d of %d files, want all",
|
||||
stats.FilesProcessed.Load(), stats.TotalFiles.Load())
|
||||
}
|
||||
}
|
||||
@@ -407,8 +407,8 @@ func (s *Scanner) summarizeScanPhase(
|
||||
}
|
||||
|
||||
if s.progress != nil {
|
||||
s.progress.AddTotalSize(totalSizeToProcess)
|
||||
s.progress.GetStats().TotalFiles.Add(int64(len(filesToProcess)))
|
||||
s.progress.SetTotalSize(totalSizeToProcess)
|
||||
s.progress.GetStats().TotalFiles.Store(int64(len(filesToProcess)))
|
||||
}
|
||||
|
||||
log.Info("Phase 1 complete",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestPruneBlobs_JSONDeletesWithoutAsking checks that prune with --json
|
||||
// and without --force deletes an unreferenced blob without the
|
||||
// confirmation prompt. Stdin is empty, so a prompt would read no answer
|
||||
// and cancel, and its text would come before the JSON document.
|
||||
func TestPruneBlobs_JSONDeletesWithoutAsking(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
// The store holds no manifest, so nothing references this blob.
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||
"/" + testBlobHashA
|
||||
assert.False(t, env.store.hasKey(blobKey),
|
||||
"the unreferenced blob must be deleted")
|
||||
|
||||
var result vaultik.PruneBlobsResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||
"stdout must hold only the JSON document, got:\n%s",
|
||||
env.stdout.String())
|
||||
assert.Equal(t, 1, result.BlobsDeleted)
|
||||
}
|
||||
Reference in New Issue
Block a user