From ee240faa3217e5eb4ff1c234e89f478f81cf1961 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 9 Jun 2026 13:22:24 -0400 Subject: [PATCH] Add --keep-newer-than flag for rolling retention window snapshot create --prune now accepts --keep-newer-than (e.g. 4w, 30d, 6mo) to keep a rolling window of snapshots instead of only the latest. Supports d/w/mo/y units and combinations (2w3d). Without --keep-newer-than, --prune still defaults to keep-latest-only. --- README.md | 12 ++++++--- internal/cli/snapshot.go | 1 + internal/vaultik/helpers.go | 44 ++++++++++++++++++++++++-------- internal/vaultik/helpers_test.go | 36 ++++++++++++++++++++++++++ internal/vaultik/snapshot.go | 36 ++++++++++++++++---------- 5 files changed, 101 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f042ce1..524fd00 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ go install git.eeqj.de/sneak/vaultik@latest # Back up and clean up old snapshots + orphan blobs in one shot vaultik --config /etc/vaultik.yml snapshot create --prune + + # Daily cron: back up, keep last 4 weeks of snapshots + vaultik --config /etc/vaultik.yml snapshot create --cron --prune --keep-newer-than 4w ``` --- @@ -111,7 +114,7 @@ go install git.eeqj.de/sneak/vaultik@latest ### commands ```sh -vaultik [--config ] snapshot create [snapshot-names...] [--cron] [--prune] [--skip-errors] +vaultik [--config ] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than ] [--skip-errors] vaultik [--config ] snapshot list [--json] vaultik [--config ] snapshot verify [--deep] [--json] vaultik [--config ] snapshot purge [--keep-latest | --older-than ] [--snapshot ...] [--force] @@ -144,8 +147,11 @@ vaultik version **snapshot create**: Perform incremental backup of configured snapshots. * Optional snapshot names argument to create specific snapshots (default: all) * `--cron`: Silent unless error (for crontab) -* `--prune`: After backup, drop older snapshots of each backed-up name (keeping - only the latest) and remove orphaned blobs from remote storage +* `--prune`: After backup, drop older snapshots of each backed-up name and + remove orphaned blobs from remote storage. By default keeps only the latest + snapshot per name; use `--keep-newer-than` for a rolling window. +* `--keep-newer-than `: With `--prune`, keep snapshots newer than + this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`) * `--skip-errors`: Skip file read errors (log them loudly but continue) **snapshot list**: List all snapshots with their timestamps and sizes. diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index ec7e30b..1347ec6 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -101,6 +101,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, 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 diff --git a/internal/vaultik/helpers.go b/internal/vaultik/helpers.go index 16c1ed4..6ea2571 100644 --- a/internal/vaultik/helpers.go +++ b/internal/vaultik/helpers.go @@ -2,6 +2,7 @@ package vaultik import ( "fmt" + "regexp" "strconv" "strings" "time" @@ -95,18 +96,39 @@ func parseSnapshotName(snapshotID string) string { return strings.Join(parts[1:len(parts)-1], "_") } -// parseDuration parses a duration string with support for days +// parseDuration parses a duration string with support for human-friendly units: +// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go +// duration units (h, m, s). func parseDuration(s string) (time.Duration, error) { - // Check for days suffix - if strings.HasSuffix(s, "d") { - daysStr := strings.TrimSuffix(s, "d") - days, err := strconv.Atoi(daysStr) - if err != nil { - return 0, fmt.Errorf("invalid days value: %w", err) - } - return time.Duration(days) * 24 * time.Hour, nil + if d, err := time.ParseDuration(s); err == nil { + return d, nil } - // Otherwise use standard Go duration parsing - return time.ParseDuration(s) + re := regexp.MustCompile(`(\d+)\s*([a-zA-Z]+)`) + matches := re.FindAllStringSubmatch(s, -1) + if len(matches) == 0 { + return 0, fmt.Errorf("invalid duration: %q", s) + } + + var total time.Duration + for _, match := range matches { + n, err := strconv.Atoi(match[1]) + if err != nil { + return 0, fmt.Errorf("invalid number %q: %w", match[1], err) + } + unit := strings.ToLower(match[2]) + switch unit { + case "d", "day", "days": + total += time.Duration(n) * 24 * time.Hour + case "w", "week", "weeks": + total += time.Duration(n) * 7 * 24 * time.Hour + case "mo", "month", "months": + total += time.Duration(n) * 30 * 24 * time.Hour + case "y", "year", "years": + total += time.Duration(n) * 365 * 24 * time.Hour + default: + return 0, fmt.Errorf("unknown time unit %q", unit) + } + } + return total, nil } diff --git a/internal/vaultik/helpers_test.go b/internal/vaultik/helpers_test.go index ef7bf5b..76a3ea3 100644 --- a/internal/vaultik/helpers_test.go +++ b/internal/vaultik/helpers_test.go @@ -2,6 +2,7 @@ package vaultik import ( "testing" + "time" ) func TestParseSnapshotName(t *testing.T) { @@ -37,6 +38,41 @@ func TestParseSnapshotName(t *testing.T) { } } +func TestParseDuration(t *testing.T) { + tests := []struct { + input string + want time.Duration + err bool + }{ + {"30d", 30 * 24 * time.Hour, false}, + {"4w", 4 * 7 * 24 * time.Hour, false}, + {"6mo", 6 * 30 * 24 * time.Hour, false}, + {"1y", 365 * 24 * time.Hour, false}, + {"2w3d", 2*7*24*time.Hour + 3*24*time.Hour, false}, + {"1h", time.Hour, false}, + {"30s", 30 * time.Second, false}, + {"garbage", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseDuration(tt.input) + if tt.err { + if err == nil { + t.Fatalf("expected error for %q, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("parseDuration(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + func TestParseSnapshotTimestamp(t *testing.T) { tests := []struct { name string diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 09aa780..34dccd7 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -22,10 +22,11 @@ import ( // SnapshotCreateOptions contains options for the snapshot create command type SnapshotCreateOptions struct { - Cron bool - Prune bool - SkipErrors bool // Skip file read errors (log them loudly but continue) - Snapshots []string // Optional list of snapshot names to process (empty = all) + Cron bool + Prune bool + KeepNewerThan string // With --prune: keep snapshots newer than this duration (e.g. "4w"); default: keep only latest + SkipErrors bool // Skip file read errors (log them loudly but continue) + Snapshots []string // Optional list of snapshot names to process (empty = all) } // CreateSnapshot executes the snapshot creation operation @@ -86,7 +87,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { } if opts.Prune { - if err := v.runPostBackupPrune(snapshotNames); err != nil { + if err := v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan); err != nil { return fmt.Errorf("post-backup prune: %w", err) } } @@ -94,19 +95,26 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { return nil } -// runPostBackupPrune drops older snapshots of the given names (keeping only -// the latest of each) and removes orphan blobs from remote storage. Invoked -// when `snapshot create --prune` is used. -func (v *Vaultik) runPostBackupPrune(snapshotNames []string) error { - log.Info("Running post-backup prune", "snapshots", snapshotNames) +// runPostBackupPrune drops older snapshots of the given names and removes +// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"), +// snapshots newer than that duration are kept. Otherwise only the latest +// snapshot of each name is kept. +func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error { + log.Info("Running post-backup prune", "snapshots", snapshotNames, "keep_newer_than", keepNewerThan) v.printlnStdout("\n=== Post-backup prune ===") purgeOpts := &SnapshotPurgeOptions{ - KeepLatest: true, - Force: true, - Names: snapshotNames, - Quiet: true, + Force: true, + Names: snapshotNames, + Quiet: true, } + + if keepNewerThan != "" { + purgeOpts.OlderThan = keepNewerThan + } else { + purgeOpts.KeepLatest = true + } + if err := v.PurgeSnapshotsWithOptions(purgeOpts); err != nil { return fmt.Errorf("purging old snapshots: %w", err) }