package cli import ( "context" "fmt" "os" "github.com/spf13/cobra" "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/vaultik" ) // 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(newSnapshotCleanupCommand()) cmd.AddCommand(newSnapshotRestoreCommand()) 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 // --skip-errors is a global flag on the root command. opts.SkipErrors = rootFlags.SkipErrors // 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() { // --cron suppression is wired through v.UI by setupGlobals. if err := v.CreateSnapshot(opts); err != nil { if err != context.Canceled { log.Error("Snapshot creation failed", "error", err) ReportError("Snapshot creation failed: %v", err) os.Exit(1) } } // 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.Cron, "cron", false, "Run in cron mode (silent unless error)") cmd.Flags().BoolVar(&opts.Prune, "prune", false, "After backup, drop older snapshots of the same name and remove orphaned blobs") cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "", "With --prune: keep snapshots newer than this duration (e.g. 4w, 30d, 6mo) instead of only the latest") 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) ReportError("Failed to list snapshots: %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 }, }) }), }, }) }, } 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. Retention is per-snapshot-name: --keep-latest keeps the latest of each configured snapshot name, not the latest globally. Use --snapshot to restrict the operation to specific snapshot names.`, 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) ReportError("Failed to purge snapshots: %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 }, }) }), }, }) }, } cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot of each 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().StringArrayVar(&opts.Names, "snapshot", nil, "Restrict to snapshots with these names (repeat for multiple)") return cmd } // newSnapshotVerifyCommand creates the 'snapshot verify' subcommand func newSnapshotVerifyCommand() *cobra.Command { opts := &vaultik.VerifyOptions{} cmd := &cobra.Command{ Use: "verify ", 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() { if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil { if err != context.Canceled { if !opts.JSON { log.Error("Verification failed", "error", err) ReportError("Verification 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 }, }) }), }, }) }, } 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 local index and remote metadata", Long: `Removes a snapshot. By default, this removes the snapshot from the local index database and strips the snapshot's metadata from the backup destination store. Blobs are NOT touched: deleting them requires reading every remaining remote manifest (the destination store may hold snapshots this host doesn't know about), which is what 'vaultik prune' does. On success the command prints the exact 'vaultik prune' invocation to run as a follow-up. Use --local-only to skip the remote half (e.g. when you want to forget a snapshot locally without touching the destination store). If the remote is unreachable, the local-database removal still completes and a warning is emitted; rerun 'vaultik prune' once the destination store is reachable to finish remote cleanup. 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) ReportError("Failed to remove snapshot: %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 }, }) }), }, }) }, } 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.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 }