Merge feature/keep-newer-than
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
|
# Back up and clean up old snapshots + orphan blobs in one shot
|
||||||
vaultik --config /etc/vaultik.yml snapshot create --prune
|
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
|
### commands
|
||||||
|
|
||||||
```sh
|
```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 list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
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.
|
**snapshot create**: Perform incremental backup of configured snapshots.
|
||||||
* Optional snapshot names argument to create specific snapshots (default: all)
|
* Optional snapshot names argument to create specific snapshots (default: all)
|
||||||
* `--cron`: Silent unless error (for crontab)
|
* `--cron`: Silent unless error (for crontab)
|
||||||
* `--prune`: After backup, drop older snapshots of each backed-up name (keeping
|
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
||||||
only the latest) and remove orphaned blobs from remote storage
|
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)
|
* `--skip-errors`: Skip file read errors (log them loudly but continue)
|
||||||
|
|
||||||
**snapshot list**: List all snapshots with their timestamps and sizes.
|
**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.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().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)")
|
cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package vaultik
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -95,18 +96,39 @@ func parseSnapshotName(snapshotID string) string {
|
|||||||
return strings.Join(parts[1:len(parts)-1], "_")
|
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) {
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
// Check for days suffix
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
if strings.HasSuffix(s, "d") {
|
return d, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use standard Go duration parsing
|
re := regexp.MustCompile(`(\d+)\s*([a-zA-Z]+)`)
|
||||||
return time.ParseDuration(s)
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSnapshotName(t *testing.T) {
|
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) {
|
func TestParseSnapshotTimestamp(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ import (
|
|||||||
|
|
||||||
// SnapshotCreateOptions contains options for the snapshot create command
|
// SnapshotCreateOptions contains options for the snapshot create command
|
||||||
type SnapshotCreateOptions struct {
|
type SnapshotCreateOptions struct {
|
||||||
Cron bool
|
Cron bool
|
||||||
Prune bool
|
Prune bool
|
||||||
SkipErrors bool // Skip file read errors (log them loudly but continue)
|
KeepNewerThan string // With --prune: keep snapshots newer than this duration (e.g. "4w"); default: keep only latest
|
||||||
Snapshots []string // Optional list of snapshot names to process (empty = all)
|
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
|
// CreateSnapshot executes the snapshot creation operation
|
||||||
@@ -86,7 +87,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Prune {
|
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)
|
return fmt.Errorf("post-backup prune: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,19 +95,26 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runPostBackupPrune drops older snapshots of the given names (keeping only
|
// runPostBackupPrune drops older snapshots of the given names and removes
|
||||||
// the latest of each) and removes orphan blobs from remote storage. Invoked
|
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
||||||
// when `snapshot create --prune` is used.
|
// snapshots newer than that duration are kept. Otherwise only the latest
|
||||||
func (v *Vaultik) runPostBackupPrune(snapshotNames []string) error {
|
// snapshot of each name is kept.
|
||||||
log.Info("Running post-backup prune", "snapshots", snapshotNames)
|
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 ===")
|
v.printlnStdout("\n=== Post-backup prune ===")
|
||||||
|
|
||||||
purgeOpts := &SnapshotPurgeOptions{
|
purgeOpts := &SnapshotPurgeOptions{
|
||||||
KeepLatest: true,
|
Force: true,
|
||||||
Force: true,
|
Names: snapshotNames,
|
||||||
Names: snapshotNames,
|
Quiet: true,
|
||||||
Quiet: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if keepNewerThan != "" {
|
||||||
|
purgeOpts.OlderThan = keepNewerThan
|
||||||
|
} else {
|
||||||
|
purgeOpts.KeepLatest = true
|
||||||
|
}
|
||||||
|
|
||||||
if err := v.PurgeSnapshotsWithOptions(purgeOpts); err != nil {
|
if err := v.PurgeSnapshotsWithOptions(purgeOpts); err != nil {
|
||||||
return fmt.Errorf("purging old snapshots: %w", err)
|
return fmt.Errorf("purging old snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user