4 Commits

Author SHA1 Message Date
cafae65f61 Merge refactor/snapshot-restore
All checks were successful
check / check (push) Successful in 2m40s
2026-06-17 06:27:53 +02:00
7a0d5bfd73 Move restore to snapshot restore subcommand
Renames the top-level `restore` command to `vaultik snapshot restore`
for consistency with `vaultik snapshot create`. The factory follows the
sibling pattern (newSnapshotRestoreCommand) and its file is renamed to
snapshot_restore.go to match.
2026-06-17 06:27:44 +02:00
8d1c8982d7 Merge feature/remote-nuke 2026-06-17 06:21:21 +02:00
e75367c594 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.
2026-06-17 06:21:21 +02:00
11 changed files with 152 additions and 36 deletions

View File

@@ -78,7 +78,7 @@ vaultik snapshot verify <snapshot-id>
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id> VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id>
# restore (requires the private key) # restore (requires the private key)
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik restore <snapshot-id> /tmp/restored VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-id> /tmp/restored
# daily cron job: back up, keep a 4-week rolling window of snapshots # daily cron job: back up, keep a 4-week rolling window of snapshots
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w # 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
@@ -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

View File

@@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) {
} }
// Verify all subcommands are registered // Verify all subcommands are registered
expectedCommands := []string{"config", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} expectedCommands := []string{"config", "snapshot", "store", "prune", "info", "version", "remote", "database"}
for _, expected := range expectedCommands { for _, expected := range expectedCommands {
found := false found := false
for _, cmd := range cmd.Commands() { for _, cmd := range cmd.Commands() {
@@ -38,7 +38,7 @@ func TestCLIEntry(t *testing.T) {
t.Errorf("Failed to find snapshot command: %v", err) t.Errorf("Failed to find snapshot command: %v", err)
} else { } else {
// Check snapshot subcommands // Check snapshot subcommands
expectedSubCommands := []string{"create", "list", "purge", "verify", "cleanup"} expectedSubCommands := []string{"create", "list", "purge", "verify", "cleanup", "restore"}
for _, expected := range expectedSubCommands { for _, expected := range expectedSubCommands {
found := false found := false
for _, subcmd := range snapshotCmd.Commands() { for _, subcmd := range snapshotCmd.Commands() {

View File

@@ -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
} }

View File

@@ -90,7 +90,6 @@ on the source system.`,
// Add subcommands // Add subcommands
cmd.AddCommand( cmd.AddCommand(
NewConfigCommand(), NewConfigCommand(),
NewRestoreCommand(),
NewPruneCommand(), NewPruneCommand(),
NewStoreCommand(), NewStoreCommand(),
NewSnapshotCommand(), NewSnapshotCommand(),

View File

@@ -27,6 +27,7 @@ func NewSnapshotCommand() *cobra.Command {
cmd.AddCommand(newSnapshotRemoveCommand()) cmd.AddCommand(newSnapshotRemoveCommand())
cmd.AddCommand(newSnapshotPruneCommand()) cmd.AddCommand(newSnapshotPruneCommand())
cmd.AddCommand(newSnapshotCleanupCommand()) cmd.AddCommand(newSnapshotCleanupCommand())
cmd.AddCommand(newSnapshotRestoreCommand())
return cmd return cmd
} }

View File

@@ -29,13 +29,13 @@ type RestoreApp struct {
Shutdowner fx.Shutdowner Shutdowner fx.Shutdowner
} }
// NewRestoreCommand creates the restore command // newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
func NewRestoreCommand() *cobra.Command { func newSnapshotRestoreCommand() *cobra.Command {
opts := &RestoreOptions{} opts := &RestoreOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "restore <snapshot-id> <target-dir> [paths...]", Use: "restore <snapshot-id> <target-dir> [paths...]",
Short: "Restore files from backup", Short: "Restore files from a snapshot",
Long: `Download and decrypt files from a backup snapshot. Long: `Download and decrypt files from a backup snapshot.
This command will restore files from the specified snapshot to the target directory. This command will restore files from the specified snapshot to the target directory.
@@ -46,16 +46,16 @@ Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with the age
Examples: Examples:
# Restore entire snapshot # Restore entire snapshot
vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore
# Restore specific file # Restore specific file
vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/important.txt vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/important.txt
# Restore specific directory # Restore specific directory
vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/documents/ vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/documents/
# Restore and verify all files # Restore and verify all files
vaultik restore --verify myhost_docs_2025-01-01T12:00:00Z /restore`, vaultik snapshot restore --verify myhost_docs_2025-01-01T12:00:00Z /restore`,
Args: cobra.MinimumNArgs(2), Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runRestore(cmd, args, opts) return runRestore(cmd, args, opts)

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 // 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),

View File

@@ -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.

View File

@@ -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" {

View File

@@ -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"`

View File

@@ -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))
} }