Add 'vaultik remote nuke', rename Processing→Backing up, bits/sec rates

remote nuke: new subcommand that deletes every snapshot's metadata and
every blob from remote storage, leaving the bucket prefix empty.
Requires --force.

User-facing 'Processing' is now 'Backing up' everywhere it referred to
the chunking/upload phase. Files summary line says 'backed up' instead
of 'processed'.

ui.Speed now formats bytes/sec input as bits/sec output (bit/s, Kbit/s,
Mbit/s, Gbit/s). Network transfer rates are conventionally expressed
in bits — the per-blob heartbeat now matches the per-snapshot summary
line which has always been bits/sec.
This commit is contained in:
2026-06-17 06:21:21 +02:00
parent 64c69cd8e3
commit e75367c594
7 changed files with 141 additions and 25 deletions

View File

@@ -106,6 +106,7 @@ vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...] [--verif
vaultik [--config <path>] prune [--force] [--json]
vaultik [--config <path>] info
vaultik [--config <path>] remote info [--json]
vaultik [--config <path>] remote nuke --force
vaultik [--config <path>] store info
vaultik [--config <path>] database purge [--force]
vaultik completion <bash|zsh|fish|powershell>
@@ -225,6 +226,11 @@ recipients, and local database statistics.
metadata sizes, blob counts, and orphaned blob detection.
* `--json`: Output as JSON
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
backup destination store, leaving the bucket prefix empty. Destructive and
irreversible.
* `--force`: Required to confirm destruction.
**`store info`**: Display storage backend type and statistics.
**`database purge`**: Delete the local SQLite state database entirely. Remote

View File

@@ -2,6 +2,7 @@ package cli
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
@@ -20,6 +21,72 @@ func NewRemoteCommand() *cobra.Command {
// Add subcommands
cmd.AddCommand(newRemoteInfoCommand())
cmd.AddCommand(newRemoteNukeCommand())
return cmd
}
// newRemoteNukeCommand creates the 'remote nuke' subcommand.
func newRemoteNukeCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "nuke",
Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
Long: `Removes every snapshot's metadata and every blob from remote
storage. After this command completes successfully the bucket prefix is
empty and the next backup starts from scratch.
This is destructive and irreversible. Requires --force.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if !force {
return fmt.Errorf("remote nuke requires --force (this deletes ALL remote snapshots and blobs)")
}
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
if err := v.NukeRemote(true); err != nil {
if err != context.Canceled {
log.Error("Remote nuke failed", "error", err)
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
v.Cancel()
return nil
},
})
}),
},
})
},
}
cmd.Flags().BoolVar(&force, "force", false, "Required: confirm destruction of ALL remote data")
return cmd
}

View File

@@ -224,13 +224,13 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc
// Phase 2: Process files and create chunks
if len(filesToProcess) > 0 {
s.ui.Begin("Processing %s snapshot source files (chunking, compressing, encrypting, uploading).", s.ui.Count(len(filesToProcess)))
s.ui.Begin("Backing up %s snapshot source files (chunking, compressing, encrypting, uploading).", s.ui.Count(len(filesToProcess)))
log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)")
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
return nil, fmt.Errorf("process phase failed: %w", err)
}
} else {
s.ui.Info("Snapshot file processing skipped: no changed files (creating metadata-only snapshot).")
s.ui.Info("Snapshot file backup skipped: no changed files (creating metadata-only snapshot).")
log.Info("Phase 2/3: Skipping (no files need processing, metadata-only snapshot)")
}
@@ -278,7 +278,7 @@ func (s *Scanner) summarizeScanPhase(result *ScanResult, filesToProcess []*FileT
"files_skipped", result.FilesSkipped,
"bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped)))
msg := fmt.Sprintf("Enumerated %s snapshot source files (%s total), %s to process (%s)",
msg := fmt.Sprintf("Enumerated %s snapshot source files (%s total), %s to back up (%s)",
s.ui.Count(result.FilesScanned),
s.ui.Size(totalSizeToProcess+result.BytesSkipped),
s.ui.Count(len(filesToProcess)),
@@ -1067,7 +1067,7 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP
}
if eta > 0 {
s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed: %s, processing ETA: %s (est remain %s).",
s.ui.Progress("Snapshot backup: %s/%s files (%s), %s/%s, %s, %.0f files/sec, backup elapsed: %s, backup ETA: %s (est remain %s).",
s.ui.Count(filesProcessed),
s.ui.Count(totalFiles),
s.ui.Percent(pct),
@@ -1079,7 +1079,7 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP
s.ui.Time(time.Now().Add(eta)),
s.ui.Duration(eta))
} else {
s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed: %s.",
s.ui.Progress("Snapshot backup: %s/%s files (%s), %s/%s, %s, %.0f files/sec, backup elapsed: %s.",
s.ui.Count(filesProcessed),
s.ui.Count(totalFiles),
s.ui.Percent(pct),

View File

@@ -203,9 +203,26 @@ func (w *Writer) Size(bytes int64) string {
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes)))
}
// Speed colorizes a byte-per-second value as "<size>/sec".
// Speed colorizes a network transfer rate. Input is bytes/sec; output is
// bits/sec with an appropriate SI unit (bit/s, Kbit/s, Mbit/s, Gbit/s) —
// network transfer rates are conventionally expressed in bits.
func (w *Writer) Speed(bytesPerSec float64) string {
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytesPerSec))+"/sec")
if bytesPerSec <= 0 {
return w.paint(ansiMagenta, "N/A")
}
bitsPerSec := bytesPerSec * 8
var s string
switch {
case bitsPerSec >= 1e9:
s = fmt.Sprintf("%.1f Gbit/sec", bitsPerSec/1e9)
case bitsPerSec >= 1e6:
s = fmt.Sprintf("%.0f Mbit/sec", bitsPerSec/1e6)
case bitsPerSec >= 1e3:
s = fmt.Sprintf("%.0f Kbit/sec", bitsPerSec/1e3)
default:
s = fmt.Sprintf("%.0f bit/sec", bitsPerSec)
}
return w.paint(ansiMagenta, s)
}
// Duration colorizes a time.Duration rounded to the nearest second.

View File

@@ -100,6 +100,17 @@ func TestValueFormattersPlain(t *testing.T) {
t.Errorf("Percent: got %q", got)
}
// Speed: input is bytes/sec, output is bits/sec.
if got := w.Speed(0); got != "N/A" {
t.Errorf("Speed(0): got %q, want N/A", got)
}
if got := w.Speed(125_000_000); got != "1.0 Gbit/sec" { // 1 Gbit/s = 125 MB/s
t.Errorf("Speed(125e6): got %q", got)
}
if got := w.Speed(125_000); got != "1 Mbit/sec" {
t.Errorf("Speed(125e3): got %q", got)
}
// Time format: today → HH:MM:SS, other day → YYYY-MM-DD HH:MM:SS.
today := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 14, 30, 45, 0, time.Local)
if got := w.Time(today); got != "14:30:45" {

View File

@@ -15,6 +15,31 @@ type PruneOptions struct {
JSON bool
}
// NukeRemote deletes every snapshot's metadata and every blob from remote
// storage. After this returns successfully the bucket prefix is empty and
// the next backup starts from scratch.
//
// Refuses to run unless force is true. The caller is responsible for
// confirming with the user.
func (v *Vaultik) NukeRemote(force bool) error {
if !force {
return fmt.Errorf("nuke requires --force (this deletes ALL remote snapshots and blobs)")
}
v.UI.Begin("Removing all snapshot metadata from backup destination store.")
if _, err := v.RemoveAllSnapshots(&RemoveOptions{Force: true, Remote: true}); err != nil {
return fmt.Errorf("removing all snapshots: %w", err)
}
v.UI.Begin("Removing all blobs from backup destination store.")
if err := v.PruneBlobs(&PruneOptions{Force: true}); err != nil {
return fmt.Errorf("pruning blobs: %w", err)
}
v.UI.Complete("Backup destination store is now empty.")
return nil
}
// PruneBlobsResult contains the result of a blob prune operation
type PruneBlobsResult struct {
BlobsFound int `json:"blobs_found"`

View File

@@ -306,23 +306,13 @@ func (v *Vaultik) finalizeSnapshotMetadata(snapshotID string, stats *snapshotSta
return nil
}
// formatUploadSpeed formats bytes uploaded and duration into a human-readable speed string
func formatUploadSpeed(bytesUploaded int64, duration time.Duration) string {
// uploadSpeed returns the average network upload rate as a colorized
// bits/sec string, or "N/A" when there's no usable data.
func (v *Vaultik) uploadSpeed(bytesUploaded int64, duration time.Duration) string {
if bytesUploaded <= 0 || duration <= 0 {
return "N/A"
}
bytesPerSec := float64(bytesUploaded) / duration.Seconds()
bitsPerSec := bytesPerSec * 8
switch {
case bitsPerSec >= 1e9:
return fmt.Sprintf("%.1f Gbit/s", bitsPerSec/1e9)
case bitsPerSec >= 1e6:
return fmt.Sprintf("%.0f Mbit/s", bitsPerSec/1e6)
case bitsPerSec >= 1e3:
return fmt.Sprintf("%.0f Kbit/s", bitsPerSec/1e3)
default:
return fmt.Sprintf("%.0f bit/s", bitsPerSec)
return v.UI.Speed(0)
}
return v.UI.Speed(float64(bytesUploaded) / duration.Seconds())
}
// printSnapshotSummary prints the comprehensive snapshot completion summary
@@ -342,7 +332,7 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
}
v.UI.Complete("Created snapshot %s.", v.UI.Snapshot(snapshotID))
filesMsg := fmt.Sprintf("Files: %s examined, %s processed, %s unchanged",
filesMsg := fmt.Sprintf("Files: %s examined, %s backed up, %s unchanged",
v.UI.Count(stats.totalFiles),
v.UI.Count(totalFilesChanged),
v.UI.Count(stats.totalFilesSkipped))
@@ -351,7 +341,7 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
}
v.UI.Detail("%s.", filesMsg)
dataMsg := fmt.Sprintf("Data: %s total (%s processed)",
dataMsg := fmt.Sprintf("Data: %s total (%s backed up)",
v.UI.Size(totalBytesAll),
v.UI.Size(stats.totalBytes))
if stats.totalBytesDeleted > 0 {
@@ -368,7 +358,7 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
stats.totalBlobsUploaded,
v.UI.Size(stats.totalBytesUploaded),
v.UI.Duration(stats.uploadDuration),
formatUploadSpeed(stats.totalBytesUploaded, stats.uploadDuration))
v.uploadSpeed(stats.totalBytesUploaded, stats.uploadDuration))
}
v.UI.Detail("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
}