All checks were successful
check / check (push) Successful in 5s
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green. ## Version bump - `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated) - `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` - `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables) - `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged - CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change ## Lint remediation The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights: - `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is` - `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated - `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added - `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants - `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code) - tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages - `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications - remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags) - removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`) `make check` (tests with `-race`, lint, fmt-check) passes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #62 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
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
|
|
}
|