Add --keep-newer-than flag for rolling retention window
snapshot create --prune now accepts --keep-newer-than <duration> (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.
This commit is contained in:
12
README.md
12
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 <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--skip-errors]
|
||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>] [--skip-errors]
|
||||
vaultik [--config <path>] snapshot list [--json]
|
||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--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 <duration>`: 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
type SnapshotCreateOptions struct {
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
if keepNewerThan != "" {
|
||||
purgeOpts.OlderThan = keepNewerThan
|
||||
} else {
|
||||
purgeOpts.KeepLatest = true
|
||||
}
|
||||
|
||||
if err := v.PurgeSnapshotsWithOptions(purgeOpts); err != nil {
|
||||
return fmt.Errorf("purging old snapshots: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user