All user-facing output now goes through a single ui.Writer with a
uniform style:
》 (white) for begin / info / notice
》 (green) for complete / success
Warning: for warnings (orange)
ERROR: for errors (red)
》 (indented) for progress heartbeats
Color is enabled when stdout is a TTY and NO_COLOR is unset.
Standards:
- Complete-sentence messages with fully qualified terms ("backup
destination store", "local index database", "snapshot source
files enumeration").
- Every Complete has a matching Begin.
- Natural verb tense conveys state ("Uploading" -> "Uploaded"). The
words "begin"/"complete" never appear in message bodies; the marker
color carries that information.
- ETA means clock time, not duration. Progress lines say "estimated
remaining time (<dur>), finish at <time>" with both labeled.
Adds globals.CommitDate (populated by Makefile/Dockerfile/goreleaser
via ldflags from `git show -s --format=%cI HEAD`) and a startup banner
printed once per invocation.
Strips fx call-chain noise from startup errors so users see the actual
underlying error (e.g. "creating base path: mkdir /Volumes/BACKUPS:
permission denied" instead of three layers of "could not build
arguments for function ...").
README documents the output style and the ui package conventions.
524 lines
15 KiB
Go
524 lines
15 KiB
Go
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(newSnapshotPruneCommand())
|
|
cmd.AddCommand(newSnapshotCleanupCommand())
|
|
|
|
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() {
|
|
// --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)
|
|
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")
|
|
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.
|
|
|
|
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)
|
|
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 <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() {
|
|
if err := v.VerifySnapshotWithOptions(snapshotID, opts); 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
|
|
}
|
|
|
|
// 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
|
|
}
|