All checks were successful
check / check (pull_request) Successful in 4m28s
PurgeSnapshots now applies --keep-latest retention per snapshot name instead of globally across all names. Previously, --keep-latest would keep only the single most recent snapshot regardless of name, deleting the latest snapshots of other names (e.g. keeping only the newest 'system' snapshot while deleting all 'home' snapshots). Changes: - Add parseSnapshotName() to extract snapshot name from snapshot IDs - Add SnapshotPurgeOptions struct with Name field for --name filtering - Add PurgeSnapshotsWithOptions() method accepting full options - Modify --keep-latest to group snapshots by name and keep the latest per group (backward compatible: PurgeSnapshots() wrapper preserved) - Add --name flag to both 'vaultik purge' and 'vaultik snapshot purge' CLI commands to filter purge operations to a specific snapshot name - Add comprehensive tests for per-name purge behavior including: multi-name retention, name filtering, legacy/mixed format support, older-than with name filter, and edge cases closes #9
473 lines
14 KiB
Go
473 lines
14 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
|
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// NewSnapshotCommand creates the snapshot command and subcommands
|
|
func NewSnapshotCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "snapshot",
|
|
Short: "Snapshot management commands",
|
|
Long: "Commands for creating, listing, and managing snapshots",
|
|
}
|
|
|
|
// Add subcommands
|
|
cmd.AddCommand(newSnapshotCreateCommand())
|
|
cmd.AddCommand(newSnapshotListCommand())
|
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
|
cmd.AddCommand(newSnapshotPruneCommand())
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotCreateCommand creates the 'snapshot create' subcommand
|
|
func newSnapshotCreateCommand() *cobra.Command {
|
|
opts := &vaultik.SnapshotCreateOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "create [snapshot-names...]",
|
|
Short: "Create new snapshots",
|
|
Long: `Creates new snapshots of the configured directories.
|
|
|
|
If snapshot names are provided, only those snapshots are created.
|
|
If no names are provided, all configured snapshots are created.
|
|
|
|
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
|
|
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|
Args: cobra.ArbitraryArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Pass snapshot names from args
|
|
opts.Snapshots = args
|
|
// Use unified config resolution
|
|
configPath, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use the backup functionality from cli package
|
|
rootFlags := GetRootFlags()
|
|
return RunWithApp(cmd.Context(), AppOptions{
|
|
ConfigPath: configPath,
|
|
LogOptions: log.LogOptions{
|
|
Verbose: rootFlags.Verbose,
|
|
Debug: rootFlags.Debug,
|
|
Cron: opts.Cron,
|
|
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 {
|
|
// Start the snapshot creation in a goroutine
|
|
go func() {
|
|
// Run the snapshot creation
|
|
if err := v.CreateSnapshot(opts); err != nil {
|
|
if err != context.Canceled {
|
|
log.Error("Snapshot creation failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// Shutdown the app when snapshot completes
|
|
if err := v.Shutdowner.Shutdown(); err != nil {
|
|
log.Error("Failed to shutdown", "error", err)
|
|
}
|
|
}()
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
log.Debug("Stopping snapshot creation")
|
|
// Cancel the Vaultik context
|
|
v.Cancel()
|
|
return nil
|
|
},
|
|
})
|
|
}),
|
|
},
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.Daemon, "daemon", false, "Run in daemon mode with inotify monitoring")
|
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)")
|
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false, "Delete all previous snapshots and unreferenced blobs after backup")
|
|
cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotListCommand creates the 'snapshot list' subcommand
|
|
func newSnapshotListCommand() *cobra.Command {
|
|
var jsonOutput bool
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List all snapshots",
|
|
Long: "Lists all snapshots with their ID, timestamp, and compressed size",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Use unified config resolution
|
|
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.ListSnapshots(jsonOutput); err != nil {
|
|
if err != context.Canceled {
|
|
log.Error("Failed to list snapshots", "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(&jsonOutput, "json", false, "Output in JSON format")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
|
func newSnapshotPurgeCommand() *cobra.Command {
|
|
opts := &vaultik.SnapshotPurgeOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "purge",
|
|
Short: "Purge old snapshots",
|
|
Long: `Removes snapshots based on age or count criteria.
|
|
|
|
When --keep-latest is used, retention is applied per snapshot name. For example,
|
|
if you have snapshots named "home" and "system", --keep-latest keeps the most
|
|
recent of each.
|
|
|
|
Use --name to restrict the purge to a single snapshot name.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Validate flags
|
|
if !opts.KeepLatest && opts.OlderThan == "" {
|
|
return fmt.Errorf("must specify either --keep-latest or --older-than")
|
|
}
|
|
if opts.KeepLatest && opts.OlderThan != "" {
|
|
return fmt.Errorf("cannot specify both --keep-latest and --older-than")
|
|
}
|
|
|
|
// Use unified config resolution
|
|
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.PurgeSnapshotsWithOptions(opts); err != nil {
|
|
if err != context.Canceled {
|
|
log.Error("Failed to purge snapshots", "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(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot per name")
|
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
|
cmd.Flags().StringVar(&opts.Name, "name", "", "Filter purge to a specific snapshot name")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotVerifyCommand creates the 'snapshot verify' subcommand
|
|
func newSnapshotVerifyCommand() *cobra.Command {
|
|
opts := &vaultik.VerifyOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "verify <snapshot-id>",
|
|
Short: "Verify snapshot integrity",
|
|
Long: "Verifies that all blobs referenced in a snapshot exist",
|
|
Args: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 1 {
|
|
_ = cmd.Help()
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("snapshot ID required")
|
|
}
|
|
return fmt.Errorf("expected 1 argument, got %d", len(args))
|
|
}
|
|
return nil
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
snapshotID := args[0]
|
|
|
|
// Use unified config resolution
|
|
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 || opts.JSON,
|
|
},
|
|
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() {
|
|
var err error
|
|
if opts.Deep {
|
|
err = v.RunDeepVerify(snapshotID, opts)
|
|
} else {
|
|
err = v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
}
|
|
if err != nil {
|
|
if err != context.Canceled {
|
|
if !opts.JSON {
|
|
log.Error("Verification 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(&opts.Deep, "deep", false, "Download and verify blob hashes")
|
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotRemoveCommand creates the 'snapshot remove' subcommand
|
|
func newSnapshotRemoveCommand() *cobra.Command {
|
|
opts := &vaultik.RemoveOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "remove [snapshot-id]",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove a snapshot from the local database",
|
|
Long: `Removes a snapshot from the local database.
|
|
|
|
By default, only removes from the local database. Use --remote to also remove
|
|
the snapshot metadata from remote storage.
|
|
|
|
Note: This does NOT remove blobs. Use 'vaultik prune' to remove orphaned blobs
|
|
after removing snapshots.
|
|
|
|
Use --all --force to remove all snapshots.`,
|
|
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 {
|
|
_ = cmd.Help()
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("snapshot ID required (or use --all --force)")
|
|
}
|
|
return fmt.Errorf("expected 1 argument, got %d", len(args))
|
|
}
|
|
return nil
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Use unified config resolution
|
|
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 || opts.JSON,
|
|
},
|
|
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() {
|
|
var err error
|
|
if opts.All {
|
|
_, err = v.RemoveAllSnapshots(opts)
|
|
} else {
|
|
_, err = v.RemoveSnapshot(args[0], opts)
|
|
}
|
|
if err != nil {
|
|
if err != context.Canceled {
|
|
if !opts.JSON {
|
|
log.Error("Failed to remove snapshot", "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().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
|
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.Remote, "remote", false, "Also remove snapshot metadata from remote storage")
|
|
cmd.Flags().BoolVar(&opts.All, "all", false, "Remove all snapshots (requires --force)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotPruneCommand creates the 'snapshot prune' subcommand
|
|
func newSnapshotPruneCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "prune",
|
|
Short: "Remove orphaned data from local database",
|
|
Long: `Removes orphaned files, chunks, and blobs from the local database.
|
|
|
|
This cleans up data that is no longer referenced by any snapshot, which can
|
|
accumulate from incomplete backups or deleted snapshots.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Use unified config resolution
|
|
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.PruneDatabase(); err != nil {
|
|
if err != context.Canceled {
|
|
log.Error("Failed to prune database", "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
|
|
}
|