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:
@@ -106,6 +106,7 @@ vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...] [--verif
|
|||||||
vaultik [--config <path>] prune [--force] [--json]
|
vaultik [--config <path>] prune [--force] [--json]
|
||||||
vaultik [--config <path>] info
|
vaultik [--config <path>] info
|
||||||
vaultik [--config <path>] remote info [--json]
|
vaultik [--config <path>] remote info [--json]
|
||||||
|
vaultik [--config <path>] remote nuke --force
|
||||||
vaultik [--config <path>] store info
|
vaultik [--config <path>] store info
|
||||||
vaultik [--config <path>] database purge [--force]
|
vaultik [--config <path>] database purge [--force]
|
||||||
vaultik completion <bash|zsh|fish|powershell>
|
vaultik completion <bash|zsh|fish|powershell>
|
||||||
@@ -225,6 +226,11 @@ recipients, and local database statistics.
|
|||||||
metadata sizes, blob counts, and orphaned blob detection.
|
metadata sizes, blob counts, and orphaned blob detection.
|
||||||
* `--json`: Output as JSON
|
* `--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.
|
**`store info`**: Display storage backend type and statistics.
|
||||||
|
|
||||||
**`database purge`**: Delete the local SQLite state database entirely. Remote
|
**`database purge`**: Delete the local SQLite state database entirely. Remote
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -20,6 +21,72 @@ func NewRemoteCommand() *cobra.Command {
|
|||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(newRemoteInfoCommand())
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,13 +224,13 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc
|
|||||||
|
|
||||||
// Phase 2: Process files and create chunks
|
// Phase 2: Process files and create chunks
|
||||||
if len(filesToProcess) > 0 {
|
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)")
|
log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)")
|
||||||
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
|
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
|
||||||
return nil, fmt.Errorf("process phase failed: %w", err)
|
return nil, fmt.Errorf("process phase failed: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)")
|
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,
|
"files_skipped", result.FilesSkipped,
|
||||||
"bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped)))
|
"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.Count(result.FilesScanned),
|
||||||
s.ui.Size(totalSizeToProcess+result.BytesSkipped),
|
s.ui.Size(totalSizeToProcess+result.BytesSkipped),
|
||||||
s.ui.Count(len(filesToProcess)),
|
s.ui.Count(len(filesToProcess)),
|
||||||
@@ -1067,7 +1067,7 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if eta > 0 {
|
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(filesProcessed),
|
||||||
s.ui.Count(totalFiles),
|
s.ui.Count(totalFiles),
|
||||||
s.ui.Percent(pct),
|
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.Time(time.Now().Add(eta)),
|
||||||
s.ui.Duration(eta))
|
s.ui.Duration(eta))
|
||||||
} else {
|
} 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(filesProcessed),
|
||||||
s.ui.Count(totalFiles),
|
s.ui.Count(totalFiles),
|
||||||
s.ui.Percent(pct),
|
s.ui.Percent(pct),
|
||||||
|
|||||||
@@ -203,9 +203,26 @@ func (w *Writer) Size(bytes int64) string {
|
|||||||
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes)))
|
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 {
|
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.
|
// Duration colorizes a time.Duration rounded to the nearest second.
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ func TestValueFormattersPlain(t *testing.T) {
|
|||||||
t.Errorf("Percent: got %q", got)
|
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.
|
// 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)
|
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" {
|
if got := w.Time(today); got != "14:30:45" {
|
||||||
|
|||||||
@@ -15,6 +15,31 @@ type PruneOptions struct {
|
|||||||
JSON bool
|
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
|
// PruneBlobsResult contains the result of a blob prune operation
|
||||||
type PruneBlobsResult struct {
|
type PruneBlobsResult struct {
|
||||||
BlobsFound int `json:"blobs_found"`
|
BlobsFound int `json:"blobs_found"`
|
||||||
|
|||||||
@@ -306,23 +306,13 @@ func (v *Vaultik) finalizeSnapshotMetadata(snapshotID string, stats *snapshotSta
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatUploadSpeed formats bytes uploaded and duration into a human-readable speed string
|
// uploadSpeed returns the average network upload rate as a colorized
|
||||||
func formatUploadSpeed(bytesUploaded int64, duration time.Duration) string {
|
// 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 {
|
if bytesUploaded <= 0 || duration <= 0 {
|
||||||
return "N/A"
|
return v.UI.Speed(0)
|
||||||
}
|
|
||||||
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(float64(bytesUploaded) / duration.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// printSnapshotSummary prints the comprehensive snapshot completion summary
|
// 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))
|
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(stats.totalFiles),
|
||||||
v.UI.Count(totalFilesChanged),
|
v.UI.Count(totalFilesChanged),
|
||||||
v.UI.Count(stats.totalFilesSkipped))
|
v.UI.Count(stats.totalFilesSkipped))
|
||||||
@@ -351,7 +341,7 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
|
|||||||
}
|
}
|
||||||
v.UI.Detail("%s.", filesMsg)
|
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(totalBytesAll),
|
||||||
v.UI.Size(stats.totalBytes))
|
v.UI.Size(stats.totalBytes))
|
||||||
if stats.totalBytesDeleted > 0 {
|
if stats.totalBytesDeleted > 0 {
|
||||||
@@ -368,7 +358,7 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
|
|||||||
stats.totalBlobsUploaded,
|
stats.totalBlobsUploaded,
|
||||||
v.UI.Size(stats.totalBytesUploaded),
|
v.UI.Size(stats.totalBytesUploaded),
|
||||||
v.UI.Duration(stats.uploadDuration),
|
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))
|
v.UI.Detail("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user