diff --git a/.gitignore b/.gitignore index 20dfd06..9f1f49f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binary -vaultik +/vaultik # Test artifacts *.out diff --git a/README.md b/README.md index 3d6ead1..dcdaa21 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ vaultik [--config ] snapshot verify [--deep] [--json] vaultik [--config ] snapshot purge [--keep-latest | --older-than ] [--snapshot ...] [--force] vaultik [--config ] snapshot remove [--dry-run] [--force] [--remote] [--json] vaultik [--config ] snapshot prune +vaultik [--config ] snapshot cleanup vaultik [--config ] restore [paths...] [--verify] vaultik [--config ] prune [--force] [--json] vaultik [--config ] info @@ -181,6 +182,10 @@ latest globally). **snapshot prune**: Clean orphaned data from the local database (files, chunks, blobs not referenced by any snapshot). +**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. + **restore**: Restore files from a backup snapshot. * Requires `VAULTIK_AGE_SECRET_KEY` environment variable * Optional path arguments to restore specific files/directories (default: all) diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 86f92ae..041c1b7 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -38,7 +38,7 @@ func TestCLIEntry(t *testing.T) { t.Errorf("Failed to find snapshot command: %v", err) } else { // Check snapshot subcommands - expectedSubCommands := []string{"create", "list", "purge", "verify"} + expectedSubCommands := []string{"create", "list", "purge", "verify", "cleanup"} for _, expected := range expectedSubCommands { found := false for _, subcmd := range snapshotCmd.Commands() { diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 1347ec6..f9592b1 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "os" "git.eeqj.de/sneak/vaultik/internal/log" @@ -26,6 +27,7 @@ func NewSnapshotCommand() *cobra.Command { cmd.AddCommand(newSnapshotVerifyCommand()) cmd.AddCommand(newSnapshotRemoveCommand()) cmd.AddCommand(newSnapshotPruneCommand()) + cmd.AddCommand(newSnapshotCleanupCommand()) return cmd } @@ -71,7 +73,9 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, OnStart: func(ctx context.Context) error { // Start the snapshot creation in a goroutine go func() { - // Run the snapshot creation + if opts.Cron { + v.Stdout = io.Discard + } if err := v.CreateSnapshot(opts); err != nil { if err != context.Canceled { log.Error("Snapshot creation failed", "error", err) @@ -463,3 +467,60 @@ accumulate from incomplete backups or deleted snapshots.`, 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) + 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 +} diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 34dccd7..c0f1612 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -409,7 +409,26 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error { return encoder.Encode(snapshots) } - return v.printSnapshotTable(snapshots) + if err := v.printSnapshotTable(snapshots); err != nil { + return err + } + + // Warn about local snapshots that don't exist in remote storage. + var stale []string + for id := range localSnapshotMap { + if !remoteSnapshots[id] { + stale = append(stale, id) + } + } + if len(stale) > 0 { + v.printfStdout("\nWarning: %d local snapshot(s) not found in remote storage:\n", len(stale)) + for _, id := range stale { + v.printfStdout(" %s\n", id) + } + v.printlnStdout("Run 'vaultik snapshot cleanup' to remove stale local records.") + } + + return nil } // listRemoteSnapshotIDs returns a set of snapshot IDs found in remote storage @@ -873,6 +892,41 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error { return nil } +// CleanupLocalSnapshots removes local snapshot records that have no +// corresponding metadata in remote storage. These are typically left +// behind by incomplete or interrupted backups. +func (v *Vaultik) CleanupLocalSnapshots() error { + remoteSnapshots, err := v.listRemoteSnapshotIDs() + if err != nil { + return err + } + + localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000) + if err != nil { + return fmt.Errorf("listing local snapshots: %w", err) + } + + var removed int + for _, snap := range localSnapshots { + id := snap.ID.String() + if !remoteSnapshots[id] { + v.printfStdout("Removing stale local record: %s\n", id) + if err := v.deleteSnapshotFromLocalDB(id); err != nil { + log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err) + continue + } + removed++ + } + } + + if removed == 0 { + v.printlnStdout("No stale local snapshots found.") + } else { + v.printfStdout("Removed %d stale local snapshot record(s).\n", removed) + } + return nil +} + // Helper methods that were previously on SnapshotApp func (v *Vaultik) downloadManifest(snapshotID string) (*snapshot.Manifest, error) {