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", Short: "Create a new snapshot", Long: `Creates a new snapshot of the configured directories. 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.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // 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, }, 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") return cmd } // newSnapshotListCommand creates the 'snapshot list' subcommand func newSnapshotListCommand() *cobra.Command { var jsonOutput bool cmd := &cobra.Command{ Use: "list", 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, }, 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 { var keepLatest bool var olderThan string var force bool cmd := &cobra.Command{ Use: "purge", Short: "Purge old snapshots", Long: "Removes snapshots based on age or count criteria", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Validate flags if !keepLatest && olderThan == "" { return fmt.Errorf("must specify either --keep-latest or --older-than") } if keepLatest && 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, }, 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.PurgeSnapshots(keepLatest, olderThan, force); 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(&keepLatest, "keep-latest", false, "Keep only the latest snapshot") cmd.Flags().StringVar(&olderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)") cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") return cmd } // newSnapshotVerifyCommand creates the 'snapshot verify' subcommand func newSnapshotVerifyCommand() *cobra.Command { var deep bool cmd := &cobra.Command{ Use: "verify ", Short: "Verify snapshot integrity", Long: "Verifies that all blobs referenced in a snapshot exist", Args: cobra.ExactArgs(1), 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, }, 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.VerifySnapshot(snapshotID, deep); err != nil { if err != context.Canceled { 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(&deep, "deep", false, "Download and verify blob hashes") return cmd } // newSnapshotRemoveCommand creates the 'snapshot remove' subcommand func newSnapshotRemoveCommand() *cobra.Command { opts := &vaultik.RemoveOptions{} cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a snapshot and its orphaned blobs", Long: `Removes a snapshot and any blobs that are no longer referenced by other snapshots. This command downloads manifests from all other snapshots to determine which blobs are still in use, then deletes any blobs that would become orphaned.`, Args: cobra.ExactArgs(1), 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, }, 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.RemoveSnapshot(snapshotID, opts); err != nil { if err != context.Canceled { 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 deleted without deleting") 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, }, 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 }