Fix every finding surfaced by the canonical .golangci.yml with golangci-lint v2.12.2 (refs #61), behavior-preserving throughout: - err113: dynamic errors replaced with package-level sentinels and %w wrapping; direct comparisons converted to errors.Is - goprintffuncname: printf-style helpers renamed with an f suffix (ui.Writer message methods, cli.ReportErrorf, database.Fatalf, vaultik stdoutf) and all call sites updated - revive: stuttering type names renamed (blob.Handler, blob.WithReader, blob.ChunkPosition, storage.URL, storage.Info), doc comments added, unused parameters blanked, package comments added - contextcheck/noctx: ctx threaded through blob.Packer (AddChunk/Flush/FinalizeBlob/PackChunks) and scanner call sites; context-aware exec and sql variants used - funlen/cyclop/gocognit/nestif/dupl: oversized or duplicated functions split into focused helpers across production and test code - paralleltest/tparallel/thelper/usetesting/testpackage: tests parallelized where safe (global log.Initialize kept in the serial phase), helpers marked, t.TempDir adopted, external test packages where only exported API is used - gosec: integer conversions clamped or justified, header timeouts added, remaining findings suppressed with per-site justifications - mnd/goconst/lll/wsl_v5/nlreturn/noinlineerr/errcheck and other mechanical findings fixed directly Remove the deprecated log.LogOptions alias (callers migrated to log.Options). make check is green.
333 lines
9.8 KiB
Go
333 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
var (
|
|
errSnapshotIDRequired = errors.New("snapshot ID required")
|
|
errWrongArgCount = errors.New("wrong argument count")
|
|
errPurgeCriteriaNeeded = errors.New(
|
|
"must specify either --keep-latest or --older-than")
|
|
errPurgeCriteriaBoth = errors.New(
|
|
"cannot specify both --keep-latest and --older-than")
|
|
)
|
|
|
|
// requireSnapshotIDArg validates that exactly one positional argument
|
|
// (the snapshot ID) was supplied, printing help otherwise.
|
|
func requireSnapshotIDArg(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 1 {
|
|
_ = cmd.Help()
|
|
|
|
if len(args) == 0 {
|
|
return errSnapshotIDRequired
|
|
}
|
|
|
|
return fmt.Errorf("%w: expected 1 argument, got %d",
|
|
errWrongArgCount, len(args))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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(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.Options{
|
|
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(_ context.Context) error {
|
|
// Start the snapshot creation in a goroutine
|
|
go func() {
|
|
// --cron suppression is wired through v.UI by setupGlobals.
|
|
err := v.CreateSnapshot(opts)
|
|
if err != nil {
|
|
if !errors.Is(err, context.Canceled) {
|
|
log.Error("Snapshot creation failed", "error", err)
|
|
ReportErrorf("Snapshot creation failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Shutdown the app when snapshot completes
|
|
err = v.Shutdowner.Shutdown()
|
|
if err != nil {
|
|
log.Error("Failed to shutdown", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(_ 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, _ []string) error {
|
|
return runVaultikApp(cmd, false, false,
|
|
"Failed to list snapshots",
|
|
func(v *vaultik.Vaultik) error {
|
|
return v.ListSnapshots(jsonOutput)
|
|
})
|
|
},
|
|
}
|
|
|
|
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, _ []string) error {
|
|
// Validate flags
|
|
if !opts.KeepLatest && opts.OlderThan == "" {
|
|
return errPurgeCriteriaNeeded
|
|
}
|
|
|
|
if opts.KeepLatest && opts.OlderThan != "" {
|
|
return errPurgeCriteriaBoth
|
|
}
|
|
|
|
return runVaultikApp(cmd, false, false,
|
|
"Failed to purge snapshots",
|
|
func(v *vaultik.Vaultik) error {
|
|
return v.PurgeSnapshotsWithOptions(opts)
|
|
})
|
|
},
|
|
}
|
|
|
|
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: requireSnapshotIDArg,
|
|
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.Options{
|
|
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(_ context.Context) error {
|
|
go func() {
|
|
err := v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
if err != nil {
|
|
if !errors.Is(err, context.Canceled) {
|
|
if !opts.JSON {
|
|
log.Error("Verification failed", "error", err)
|
|
ReportErrorf("Verification failed: %v", err)
|
|
}
|
|
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
err = v.Shutdowner.Shutdown()
|
|
if err != nil {
|
|
log.Error("Failed to shutdown", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(_ 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.
|
|
|
|
To wipe the entire destination store and start over, use 'vaultik remote
|
|
nuke --force' — it is the single supported entry point for that.`,
|
|
Args: requireSnapshotIDArg,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runVaultikApp(cmd, opts.JSON, opts.JSON,
|
|
"Failed to remove snapshot",
|
|
func(v *vaultik.Vaultik) error {
|
|
_, err := v.RemoveSnapshot(args[0], opts)
|
|
|
|
return err
|
|
})
|
|
},
|
|
}
|
|
|
|
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")
|
|
|
|
return cmd
|
|
}
|