Merge branch 'refactor/cli-verb-consolidation'
All checks were successful
check / check (push) Successful in 1m59s
All checks were successful
check / check (push) Successful in 1m59s
This commit is contained in:
53
README.md
53
README.md
@@ -99,15 +99,13 @@ vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune]
|
|||||||
vaultik [--config <path>] snapshot list [--json]
|
vaultik [--config <path>] snapshot list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id|--all> [--dry-run] [--force] [--local-only] [--json]
|
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json]
|
||||||
vaultik [--config <path>] snapshot cleanup
|
|
||||||
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
||||||
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>] remote nuke --force
|
||||||
vaultik [--config <path>] store info
|
vaultik [--config <path>] database delete [--force]
|
||||||
vaultik [--config <path>] database purge [--force]
|
|
||||||
vaultik completion <bash|zsh|fish|powershell>
|
vaultik completion <bash|zsh|fish|powershell>
|
||||||
vaultik version
|
vaultik version
|
||||||
```
|
```
|
||||||
@@ -198,7 +196,7 @@ latest globally).
|
|||||||
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
**`snapshot remove`**: Remove a snapshot. By default this removes the
|
**`snapshot remove`**: Remove one snapshot. By default this removes the
|
||||||
snapshot from the local index and strips the snapshot's metadata from
|
snapshot from the local index and strips the snapshot's metadata from
|
||||||
the backup destination store. Blobs are NOT touched — deleting blobs
|
the backup destination store. Blobs are NOT touched — deleting blobs
|
||||||
requires reading every remaining remote manifest (the destination store
|
requires reading every remaining remote manifest (the destination store
|
||||||
@@ -208,17 +206,13 @@ prune` invocation to run as a follow-up. Local row cleanup (files,
|
|||||||
chunks, blobs the snapshot was the last referrer for) runs
|
chunks, blobs the snapshot was the last referrer for) runs
|
||||||
automatically. If the destination store is unreachable, the local-DB
|
automatically. If the destination store is unreachable, the local-DB
|
||||||
removal still completes and a warning is emitted; rerun `vaultik prune`
|
removal still completes and a warning is emitted; rerun `vaultik prune`
|
||||||
once the store is reachable to finish remote cleanup.
|
once the store is reachable to finish remote cleanup. To wipe everything
|
||||||
|
on the destination in one go, use `vaultik remote nuke --force`.
|
||||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--all`: Remove all snapshots (requires `--force`)
|
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output result as JSON
|
* `--json`: Output result as JSON
|
||||||
|
|
||||||
**`snapshot cleanup`**: Remove stale local snapshot records that have no
|
|
||||||
corresponding metadata in remote storage. These are typically left behind
|
|
||||||
by incomplete or interrupted backups. Does not touch remote storage.
|
|
||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
@@ -226,31 +220,38 @@ by incomplete or interrupted backups. Does not touch remote storage.
|
|||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
* `--verify`: After restoring, verify every file's chunk hashes match
|
* `--verify`: After restoring, verify every file's chunk hashes match
|
||||||
|
|
||||||
**`prune`**: Tidy up everything that isn't needed. Removes orphaned local
|
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
|
||||||
database rows (files, chunks, blobs no longer referenced by any completed
|
(1) reconcile the local index against the destination store — any
|
||||||
snapshot) AND deletes unreferenced blobs from remote storage. `snapshot
|
local snapshot whose remote metadata is missing is dropped from the
|
||||||
create --prune`, `snapshot remove`, and `snapshot purge` run the same
|
local index; (2) delete orphaned local rows (files, chunks, blobs no
|
||||||
cleanup automatically; this is the manual entry point for the same work.
|
longer referenced by any completed snapshot); (3) list every remote
|
||||||
|
manifest on the destination store to compute the still-referenced blob
|
||||||
|
set and delete any blob not in that set. Step (3) reads all remote
|
||||||
|
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
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON
|
* `--json`: Output stats as JSON
|
||||||
|
|
||||||
**`info`**: Display system configuration, storage settings, encryption
|
**`info`**: Display system configuration, storage settings, encryption
|
||||||
recipients, and local database statistics.
|
recipients, and local database statistics.
|
||||||
|
|
||||||
**`remote info`**: Show detailed remote storage information including per-snapshot
|
**`remote info`**: Show storage backend type and location plus detailed
|
||||||
metadata sizes, blob counts, and orphaned blob detection.
|
remote storage inventory: per-snapshot 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
|
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
|
||||||
backup destination store, leaving the bucket prefix empty. Destructive and
|
backup destination store, leaving the bucket prefix empty. Destructive and
|
||||||
irreversible.
|
irreversible. This is the single supported way to wipe the entire
|
||||||
|
destination store.
|
||||||
* `--force`: Required to confirm destruction.
|
* `--force`: Required to confirm destruction.
|
||||||
|
|
||||||
**`store info`**: Display storage backend type and statistics.
|
**`database delete`**: Delete the local SQLite state database file
|
||||||
|
entirely. Remote storage is unaffected; the next backup will do a full
|
||||||
**`database purge`**: Delete the local SQLite state database entirely. Remote
|
scan and re-deduplicate against existing remote blobs, and the local
|
||||||
storage is unaffected; the next backup will do a full scan and re-deduplicate
|
index will re-bind to the currently configured storage destination.
|
||||||
against existing remote blobs.
|
Use this after changing `storage_url` to a different destination.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -387,7 +388,7 @@ Key fields:
|
|||||||
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
||||||
regular files, directories, and symlinks are backed up.
|
regular files, directories, and symlinks are backed up.
|
||||||
* **No database migrations.** If the local SQLite schema changes between
|
* **No database migrations.** If the local SQLite schema changes between
|
||||||
versions, delete the local database (`vaultik database purge`) and run
|
versions, delete the local database (`vaultik database delete`) and run
|
||||||
a full backup. Remote storage is unaffected.
|
a full backup. Remote storage is unaffected.
|
||||||
* **Files that change during backup may be inconsistent.** There is no
|
* **Files that change during backup may be inconsistent.** There is no
|
||||||
filesystem snapshot or freeze. If a file is modified between the scan
|
filesystem snapshot or freeze. If a file is modified between the scan
|
||||||
@@ -459,7 +460,7 @@ priority.
|
|||||||
first-class operation in this README. Worth a dedicated section
|
first-class operation in this README. Worth a dedicated section
|
||||||
once it's settled.
|
once it's settled.
|
||||||
* **Schema migrations.** Currently nonexistent — pre-1.0 schema
|
* **Schema migrations.** Currently nonexistent — pre-1.0 schema
|
||||||
changes are handled by `vaultik database purge` plus a full
|
changes are handled by `vaultik database delete` plus a full
|
||||||
re-scan. Post-1.0 we'll need a migration story to keep existing
|
re-scan. Post-1.0 we'll need a migration story to keep existing
|
||||||
index databases usable across upgrades.
|
index databases usable across upgrades.
|
||||||
* **Storage backend coverage tests.** S3, file://, and rclone://
|
* **Storage backend coverage tests.** S3, file://, and rclone://
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Vaultik uses a local SQLite database to track file metadata, chunk mappings, and
|
|||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- **No Migration Support (pre-1.0)**: Vaultik does not support database schema
|
- **No Migration Support (pre-1.0)**: Vaultik does not support database schema
|
||||||
migrations. The local index is treated as disposable — if the schema changes,
|
migrations. The local index is treated as disposable — if the schema changes,
|
||||||
delete the local SQLite database (`vaultik database purge`) and run a full
|
delete the local SQLite database (`vaultik database delete`) and run a full
|
||||||
backup. The remote storage is unaffected; the new index will re-deduplicate
|
backup. The remote storage is unaffected; the new index will re-deduplicate
|
||||||
against existing remote blobs.
|
against existing remote blobs.
|
||||||
- **Version Compatibility**: In rare cases, you may need to use the same version
|
- **Version Compatibility**: In rare cases, you may need to use the same version
|
||||||
|
|||||||
@@ -18,28 +18,33 @@ func NewDatabaseCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newDatabasePurgeCommand(),
|
newDatabaseDeleteCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDatabasePurgeCommand creates the database purge command
|
// newDatabaseDeleteCommand creates the database delete command.
|
||||||
func newDatabasePurgeCommand() *cobra.Command {
|
// (Renamed from "purge"; the operation removes the SQLite file
|
||||||
|
// entirely, which is a delete, not a purge of content.)
|
||||||
|
func newDatabaseDeleteCommand() *cobra.Command {
|
||||||
var force bool
|
var force bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "purge",
|
Use: "delete",
|
||||||
Short: "Delete the local state database",
|
Short: "Delete the local state database file",
|
||||||
Long: `Completely removes the local SQLite state database.
|
Long: `Completely removes the local SQLite state database.
|
||||||
|
|
||||||
This will erase all local tracking of:
|
This will erase all local tracking of:
|
||||||
- File metadata and change detection state
|
- File metadata and change detection state
|
||||||
- Chunk and blob mappings
|
- Chunk and blob mappings
|
||||||
- Local snapshot records
|
- Local snapshot records
|
||||||
|
- The storage-binding record
|
||||||
|
|
||||||
The remote storage is NOT affected. After purging, the next backup will
|
The remote storage is NOT affected. After deletion, the next backup
|
||||||
perform a full scan and re-deduplicate against existing remote blobs.
|
will perform a full scan and re-deduplicate against existing remote
|
||||||
|
blobs, and the local index will re-bind to the currently configured
|
||||||
|
storage destination on that run.
|
||||||
|
|
||||||
Use --force to skip the confirmation prompt.`,
|
Use --force to skip the confirmation prompt.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
@@ -88,10 +93,10 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
if !rootFlags.Quiet {
|
if !rootFlags.Quiet {
|
||||||
fmt.Printf("Database purged: %s\n", dbPath)
|
fmt.Printf("Database deleted: %s\n", dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Local state database purged", "path", dbPath)
|
log.Info("Local state database deleted", "path", dbPath)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all subcommands are registered
|
// Verify all subcommands are registered
|
||||||
expectedCommands := []string{"config", "snapshot", "store", "prune", "info", "version", "remote", "database"}
|
expectedCommands := []string{"config", "snapshot", "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", "restore"}
|
expectedSubCommands := []string{"create", "list", "purge", "verify", "remove", "restore"}
|
||||||
for _, expected := range expectedSubCommands {
|
for _, expected := range expectedSubCommands {
|
||||||
found := false
|
found := false
|
||||||
for _, subcmd := range snapshotCmd.Commands() {
|
for _, subcmd := range snapshotCmd.Commands() {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ on the source system.`,
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewConfigCommand(),
|
NewConfigCommand(),
|
||||||
NewPruneCommand(),
|
NewPruneCommand(),
|
||||||
NewStoreCommand(),
|
|
||||||
NewSnapshotCommand(),
|
NewSnapshotCommand(),
|
||||||
NewInfoCommand(),
|
NewInfoCommand(),
|
||||||
NewVersionCommand(),
|
NewVersionCommand(),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(newSnapshotPurgeCommand())
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
||||||
cmd.AddCommand(newSnapshotVerifyCommand())
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||||
cmd.AddCommand(newSnapshotRemoveCommand())
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||||
cmd.AddCommand(newSnapshotCleanupCommand())
|
|
||||||
cmd.AddCommand(newSnapshotRestoreCommand())
|
cmd.AddCommand(newSnapshotRestoreCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -322,7 +321,7 @@ func newSnapshotRemoveCommand() *cobra.Command {
|
|||||||
opts := &vaultik.RemoveOptions{}
|
opts := &vaultik.RemoveOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove [snapshot-id]",
|
Use: "remove <snapshot-id>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a snapshot from local index and remote metadata",
|
Short: "Remove a snapshot from local index and remote metadata",
|
||||||
Long: `Removes a snapshot.
|
Long: `Removes a snapshot.
|
||||||
@@ -341,20 +340,13 @@ If the remote is unreachable, the local-database removal still completes
|
|||||||
and a warning is emitted; rerun 'vaultik prune' once the destination store
|
and a warning is emitted; rerun 'vaultik prune' once the destination store
|
||||||
is reachable to finish remote cleanup.
|
is reachable to finish remote cleanup.
|
||||||
|
|
||||||
Use --all --force to remove all snapshots.`,
|
To wipe the entire destination store and start over, use 'vaultik remote
|
||||||
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
all, _ := cmd.Flags().GetBool("all")
|
|
||||||
if all {
|
|
||||||
if len(args) > 0 {
|
|
||||||
_ = cmd.Help()
|
|
||||||
return fmt.Errorf("--all cannot be used with a snapshot ID")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
_ = cmd.Help()
|
_ = cmd.Help()
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("snapshot ID required (or use --all --force)")
|
return fmt.Errorf("snapshot ID required")
|
||||||
}
|
}
|
||||||
return fmt.Errorf("expected 1 argument, got %d", len(args))
|
return fmt.Errorf("expected 1 argument, got %d", len(args))
|
||||||
}
|
}
|
||||||
@@ -381,12 +373,7 @@ Use --all --force to remove all snapshots.`,
|
|||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
_, err := v.RemoveSnapshot(args[0], opts)
|
||||||
if opts.All {
|
|
||||||
_, err = v.RemoveAllSnapshots(opts)
|
|
||||||
} else {
|
|
||||||
_, err = v.RemoveSnapshot(args[0], opts)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
@@ -417,65 +404,6 @@ Use --all --force to remove all snapshots.`,
|
|||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be removed without removing")
|
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")
|
||||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false, "Skip remote cleanup; only touch the local index")
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false, "Skip remote cleanup; only touch the local index")
|
||||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Remove all snapshots (requires --force)")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSnapshotCleanupCommand creates the 'snapshot cleanup' subcommand
|
|
||||||
func newSnapshotCleanupCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "cleanup",
|
|
||||||
Short: "Remove stale local snapshot records not found in remote storage",
|
|
||||||
Long: `Removes local database records for snapshots whose metadata no longer
|
|
||||||
exists in remote storage. These are typically left behind by incomplete
|
|
||||||
or interrupted backups.
|
|
||||||
|
|
||||||
This command does not delete anything from remote storage.`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
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.CleanupLocalSnapshots(); err != nil {
|
|
||||||
if err != context.Canceled {
|
|
||||||
log.Error("Cleanup failed", "error", err)
|
|
||||||
ReportError("Cleanup failed: %v", 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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreApp contains dependencies for store commands
|
|
||||||
type StoreApp struct {
|
|
||||||
Storage storage.Storer
|
|
||||||
Shutdowner fx.Shutdowner
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStoreCommand creates the store command and subcommands
|
|
||||||
func NewStoreCommand() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "store",
|
|
||||||
Short: "Storage information commands",
|
|
||||||
Long: "Commands for viewing information about the storage backend",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add subcommands
|
|
||||||
cmd.AddCommand(newStoreInfoCommand())
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// newStoreInfoCommand creates the 'store info' subcommand
|
|
||||||
func newStoreInfoCommand() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "info",
|
|
||||||
Short: "Display storage information",
|
|
||||||
Long: "Shows storage configuration and statistics including snapshots and blobs",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runWithApp(cmd.Context(), func(app *StoreApp) error {
|
|
||||||
return app.Info(cmd.Context())
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info displays storage information
|
|
||||||
func (app *StoreApp) Info(ctx context.Context) error {
|
|
||||||
// Get storage info
|
|
||||||
storageInfo := app.Storage.Info()
|
|
||||||
|
|
||||||
fmt.Printf("Storage Information\n")
|
|
||||||
fmt.Printf("==================\n\n")
|
|
||||||
fmt.Printf("Storage Configuration:\n")
|
|
||||||
fmt.Printf(" Type: %s\n", storageInfo.Type)
|
|
||||||
fmt.Printf(" Location: %s\n\n", storageInfo.Location)
|
|
||||||
|
|
||||||
// Count snapshots by listing metadata/ prefix
|
|
||||||
snapshotCount := 0
|
|
||||||
snapshotCh := app.Storage.ListStream(ctx, "metadata/")
|
|
||||||
snapshotDirs := make(map[string]bool)
|
|
||||||
|
|
||||||
for object := range snapshotCh {
|
|
||||||
if object.Err != nil {
|
|
||||||
return fmt.Errorf("listing snapshots: %w", object.Err)
|
|
||||||
}
|
|
||||||
// Extract snapshot ID from path like metadata/2024-01-15-143052-hostname/
|
|
||||||
parts := strings.Split(object.Key, "/")
|
|
||||||
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
|
|
||||||
snapshotDirs[parts[1]] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
snapshotCount = len(snapshotDirs)
|
|
||||||
|
|
||||||
// Count blobs and calculate total size by listing blobs/ prefix
|
|
||||||
blobCount := 0
|
|
||||||
var totalSize int64
|
|
||||||
|
|
||||||
blobCh := app.Storage.ListStream(ctx, "blobs/")
|
|
||||||
for object := range blobCh {
|
|
||||||
if object.Err != nil {
|
|
||||||
return fmt.Errorf("listing blobs: %w", object.Err)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(object.Key, "/") { // Skip directories
|
|
||||||
blobCount++
|
|
||||||
totalSize += object.Size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Storage Statistics:\n")
|
|
||||||
fmt.Printf(" Snapshots: %d\n", snapshotCount)
|
|
||||||
fmt.Printf(" Blobs: %d\n", blobCount)
|
|
||||||
fmt.Printf(" Total Size: %s\n", formatBytes(totalSize))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes formats bytes into human-readable format
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if bytes < unit {
|
|
||||||
return fmt.Sprintf("%d B", bytes)
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
|
|
||||||
// runWithApp creates the FX app and runs the given function
|
|
||||||
func runWithApp(ctx context.Context, fn func(*StoreApp) error) error {
|
|
||||||
var result error
|
|
||||||
rootFlags := GetRootFlags()
|
|
||||||
|
|
||||||
// Use unified config resolution
|
|
||||||
configPath, err := ResolveConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = RunWithApp(ctx, AppOptions{
|
|
||||||
ConfigPath: configPath,
|
|
||||||
LogOptions: log.LogOptions{
|
|
||||||
Verbose: rootFlags.Verbose,
|
|
||||||
Debug: rootFlags.Debug,
|
|
||||||
Quiet: rootFlags.Quiet,
|
|
||||||
},
|
|
||||||
Modules: []fx.Option{
|
|
||||||
fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp {
|
|
||||||
return &StoreApp{
|
|
||||||
Storage: storer,
|
|
||||||
Shutdowner: shutdowner,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
Invokes: []fx.Option{
|
|
||||||
fx.Invoke(func(app *StoreApp, shutdowner fx.Shutdowner) {
|
|
||||||
result = fn(app)
|
|
||||||
// Shutdown after command completes
|
|
||||||
go func() {
|
|
||||||
time.Sleep(100 * time.Millisecond) // Brief delay to ensure clean shutdown
|
|
||||||
if err := shutdowner.Shutdown(); err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -58,6 +58,14 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
|
|||||||
if err := v.EnsureStorageBinding(); err != nil {
|
if err := v.EnsureStorageBinding(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// First reconcile local snapshot records against remote metadata:
|
||||||
|
// any local snapshot whose manifest is missing from the destination
|
||||||
|
// store is treated as gone. This used to be the separate 'snapshot
|
||||||
|
// cleanup' command and is now folded in so a single 'vaultik prune'
|
||||||
|
// gets the local index fully back in sync with the destination.
|
||||||
|
if err := v.CleanupLocalSnapshots(); err != nil {
|
||||||
|
return fmt.Errorf("reconciling local snapshots with remote: %w", err)
|
||||||
|
}
|
||||||
if _, err := v.PruneDatabase(); err != nil {
|
if _, err := v.PruneDatabase(); err != nil {
|
||||||
return fmt.Errorf("pruning local database: %w", err)
|
return fmt.Errorf("pruning local database: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{All: true} // No Force
|
opts := &vaultik.RemoveOptions{} // No Force
|
||||||
_, err := tv.RemoveAllSnapshots(opts)
|
_, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -310,7 +310,7 @@ func TestRemoveAllSnapshots_WithForce(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true}
|
opts := &vaultik.RemoveOptions{Force: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -342,7 +342,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
|||||||
// Default (no LocalOnly) enumerates the orphan remote keys, which
|
// Default (no LocalOnly) enumerates the orphan remote keys, which
|
||||||
// matches what NewForTesting has — local DB is empty, so the two
|
// matches what NewForTesting has — local DB is empty, so the two
|
||||||
// addManifest calls land as orphan remote keys.
|
// addManifest calls land as orphan remote keys.
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true}
|
opts := &vaultik.RemoveOptions{Force: true, DryRun: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -362,7 +362,7 @@ func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
|||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
opts := &vaultik.RemoveOptions{All: true, Force: true}
|
opts := &vaultik.RemoveOptions{Force: true}
|
||||||
result, err := tv.RemoveAllSnapshots(opts)
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -987,7 +987,6 @@ type RemoveOptions struct {
|
|||||||
DryRun bool
|
DryRun bool
|
||||||
JSON bool
|
JSON bool
|
||||||
LocalOnly bool // Skip remote cleanup; only touch the local index
|
LocalOnly bool // Skip remote cleanup; only touch the local index
|
||||||
All bool // Remove all snapshots (requires Force)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveResult contains the result of a snapshot removal
|
// RemoveResult contains the result of a snapshot removal
|
||||||
|
|||||||
@@ -86,6 +86,6 @@ func buildBindingMismatchMessage(stored, configured string) string {
|
|||||||
"\n" +
|
"\n" +
|
||||||
"To proceed, either:\n" +
|
"To proceed, either:\n" +
|
||||||
" - revert storage_url in your config to the bound destination, or\n" +
|
" - revert storage_url in your config to the bound destination, or\n" +
|
||||||
" - run 'vaultik database purge' to discard the local index and rebuild it\n" +
|
" - run 'vaultik database delete' to discard the local index and rebuild it\n" +
|
||||||
" from a fresh full backup against the new destination"
|
" from a fresh full backup against the new destination"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,5 +68,5 @@ func TestEnsureStorageBinding_MismatchRefuses(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "file:///mnt/backups/old")
|
assert.Contains(t, err.Error(), "file:///mnt/backups/old")
|
||||||
assert.Contains(t, err.Error(), "file:///mnt/backups/new")
|
assert.Contains(t, err.Error(), "file:///mnt/backups/new")
|
||||||
assert.Contains(t, err.Error(), "vaultik database purge")
|
assert.Contains(t, err.Error(), "vaultik database delete")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user