diff --git a/README.md b/README.md index 0b3f406..d55418a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ vaultik [--config ] restore [paths...] [--verif vaultik [--config ] prune [--force] [--json] vaultik [--config ] info vaultik [--config ] remote info [--json] +vaultik [--config ] remote nuke --force vaultik [--config ] store info vaultik [--config ] database purge [--force] vaultik completion @@ -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 diff --git a/internal/cli/remote.go b/internal/cli/remote.go index 9e58c90..9f29819 100644 --- a/internal/cli/remote.go +++ b/internal/cli/remote.go @@ -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 } diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 81df80b..fc93531 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -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), diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f0f55c8..19dbe44 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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 "/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. diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 4eef683..ac9ad73 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -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" { diff --git a/internal/vaultik/prune.go b/internal/vaultik/prune.go index 4bfbb8e..4490364 100644 --- a/internal/vaultik/prune.go +++ b/internal/vaultik/prune.go @@ -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"` diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index fad9d17..6a464a1 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -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)) }