feat: per-name purge filtering for snapshot purge
All checks were successful
check / check (pull_request) Successful in 4m28s
All checks were successful
check / check (pull_request) Successful in 4m28s
PurgeSnapshots now applies --keep-latest retention per snapshot name instead of globally across all names. Previously, --keep-latest would keep only the single most recent snapshot regardless of name, deleting the latest snapshots of other names (e.g. keeping only the newest 'system' snapshot while deleting all 'home' snapshots). Changes: - Add parseSnapshotName() to extract snapshot name from snapshot IDs - Add SnapshotPurgeOptions struct with Name field for --name filtering - Add PurgeSnapshotsWithOptions() method accepting full options - Modify --keep-latest to group snapshots by name and keep the latest per group (backward compatible: PurgeSnapshots() wrapper preserved) - Add --name flag to both 'vaultik purge' and 'vaultik snapshot purge' CLI commands to filter purge operations to a specific snapshot name - Add comprehensive tests for per-name purge behavior including: multi-name retention, name filtering, legacy/mixed format support, older-than with name filter, and edge cases closes #9
This commit is contained in:
@@ -150,7 +150,7 @@ passphrase is needed or stored locally.
|
|||||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--daemon] [--prune]
|
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--daemon] [--prune]
|
||||||
vaultik [--config <path>] snapshot list [--json]
|
vaultik [--config <path>] snapshot list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--name <name>] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force]
|
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force]
|
||||||
vaultik [--config <path>] snapshot prune
|
vaultik [--config <path>] snapshot prune
|
||||||
vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...]
|
vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...]
|
||||||
@@ -180,8 +180,9 @@ vaultik [--config <path>] store info
|
|||||||
* `--deep`: Download and verify blob contents (not just existence)
|
* `--deep`: Download and verify blob contents (not just existence)
|
||||||
|
|
||||||
**snapshot purge**: Remove old snapshots based on criteria
|
**snapshot purge**: Remove old snapshots based on criteria
|
||||||
* `--keep-latest`: Keep only the most recent snapshot
|
* `--keep-latest`: Keep the most recent snapshot per snapshot name
|
||||||
* `--older-than`: Remove snapshots older than duration (e.g., 30d, 6mo, 1y)
|
* `--older-than`: Remove snapshots older than duration (e.g., 30d, 6mo, 1y)
|
||||||
|
* `--name`: Filter purge to a specific snapshot name
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
**snapshot remove**: Remove a specific snapshot
|
**snapshot remove**: Remove a specific snapshot
|
||||||
|
|||||||
@@ -11,16 +11,9 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PurgeOptions contains options for the purge command
|
|
||||||
type PurgeOptions struct {
|
|
||||||
KeepLatest bool
|
|
||||||
OlderThan string
|
|
||||||
Force bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPurgeCommand creates the purge command
|
// NewPurgeCommand creates the purge command
|
||||||
func NewPurgeCommand() *cobra.Command {
|
func NewPurgeCommand() *cobra.Command {
|
||||||
opts := &PurgeOptions{}
|
opts := &vaultik.SnapshotPurgeOptions{}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "purge",
|
Use: "purge",
|
||||||
@@ -28,8 +21,15 @@ func NewPurgeCommand() *cobra.Command {
|
|||||||
Long: `Removes snapshots based on age or count criteria.
|
Long: `Removes snapshots based on age or count criteria.
|
||||||
|
|
||||||
This command allows you to:
|
This command allows you to:
|
||||||
- Keep only the latest snapshot (--keep-latest)
|
- Keep only the latest snapshot per name (--keep-latest)
|
||||||
- Remove snapshots older than a specific duration (--older-than)
|
- Remove snapshots older than a specific duration (--older-than)
|
||||||
|
- Filter to a specific snapshot name (--name)
|
||||||
|
|
||||||
|
When --keep-latest is used, retention is applied per snapshot name. For example,
|
||||||
|
if you have snapshots named "home" and "system", --keep-latest keeps the most
|
||||||
|
recent of each.
|
||||||
|
|
||||||
|
Use --name to restrict the purge to a single snapshot name.
|
||||||
|
|
||||||
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
|
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.`,
|
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
||||||
@@ -66,7 +66,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
// Start the purge operation in a goroutine
|
// Start the purge operation in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
// Run the purge operation
|
// Run the purge operation
|
||||||
if err := v.PurgeSnapshots(opts.KeepLatest, opts.OlderThan, opts.Force); err != nil {
|
if err := v.PurgeSnapshotsWithOptions(opts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Purge operation failed", "error", err)
|
log.Error("Purge operation failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -92,9 +92,10 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot")
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot per name")
|
||||||
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g. 30d, 6m, 1y)")
|
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 prompts")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts")
|
||||||
|
cmd.Flags().StringVar(&opts.Name, "name", "", "Filter purge to a specific snapshot name")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,21 +167,25 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
|
|
||||||
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
||||||
func newSnapshotPurgeCommand() *cobra.Command {
|
func newSnapshotPurgeCommand() *cobra.Command {
|
||||||
var keepLatest bool
|
opts := &vaultik.SnapshotPurgeOptions{}
|
||||||
var olderThan string
|
|
||||||
var force bool
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "purge",
|
Use: "purge",
|
||||||
Short: "Purge old snapshots",
|
Short: "Purge old snapshots",
|
||||||
Long: "Removes snapshots based on age or count criteria",
|
Long: `Removes snapshots based on age or count criteria.
|
||||||
Args: cobra.NoArgs,
|
|
||||||
|
When --keep-latest is used, retention is applied per snapshot name. For example,
|
||||||
|
if you have snapshots named "home" and "system", --keep-latest keeps the most
|
||||||
|
recent of each.
|
||||||
|
|
||||||
|
Use --name to restrict the purge to a single snapshot name.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Validate flags
|
// Validate flags
|
||||||
if !keepLatest && olderThan == "" {
|
if !opts.KeepLatest && opts.OlderThan == "" {
|
||||||
return fmt.Errorf("must specify either --keep-latest or --older-than")
|
return fmt.Errorf("must specify either --keep-latest or --older-than")
|
||||||
}
|
}
|
||||||
if keepLatest && olderThan != "" {
|
if opts.KeepLatest && opts.OlderThan != "" {
|
||||||
return fmt.Errorf("cannot specify both --keep-latest and --older-than")
|
return fmt.Errorf("cannot specify both --keep-latest and --older-than")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +209,7 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
go func() {
|
go func() {
|
||||||
if err := v.PurgeSnapshots(keepLatest, olderThan, force); err != nil {
|
if err := v.PurgeSnapshotsWithOptions(opts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Failed to purge snapshots", "error", err)
|
log.Error("Failed to purge snapshots", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -228,9 +232,10 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&keepLatest, "keep-latest", false, "Keep only the latest snapshot")
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot per name")
|
||||||
cmd.Flags().StringVar(&olderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
||||||
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().StringVar(&opts.Name, "name", "", "Filter purge to a specific snapshot name")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
|||||||
return timestamp.UTC(), nil
|
return timestamp.UTC(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSnapshotName extracts the snapshot name from a snapshot ID.
|
||||||
|
// Format: hostname_snapshotname_timestamp (3 parts) or hostname_timestamp (2 parts, no name).
|
||||||
|
// Returns the snapshot name, or empty string if no name component is present.
|
||||||
|
func parseSnapshotName(snapshotID string) string {
|
||||||
|
parts := strings.Split(snapshotID, "_")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
// Format: hostname_timestamp — no snapshot name
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Format: hostname_name_timestamp — middle parts are the name.
|
||||||
|
// The last part is the RFC3339 timestamp, the first part is the hostname,
|
||||||
|
// everything in between is the snapshot name (which may itself contain underscores).
|
||||||
|
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 days
|
||||||
func parseDuration(s string) (time.Duration, error) {
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
// Check for days suffix
|
// Check for days suffix
|
||||||
|
|||||||
119
internal/vaultik/helpers_test.go
Normal file
119
internal/vaultik/helpers_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package vaultik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSnapshotName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
snapshotID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard format with name",
|
||||||
|
snapshotID: "myhost_home_2026-01-12T14:41:15Z",
|
||||||
|
want: "home",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard format with different name",
|
||||||
|
snapshotID: "server1_system_2026-02-15T09:30:00Z",
|
||||||
|
want: "system",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no snapshot name (legacy format)",
|
||||||
|
snapshotID: "myhost_2026-01-12T14:41:15Z",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name with underscores",
|
||||||
|
snapshotID: "myhost_my_special_backup_2026-03-01T00:00:00Z",
|
||||||
|
want: "my_special_backup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single part (edge case)",
|
||||||
|
snapshotID: "nounderscore",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
snapshotID: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseSnapshotName(tt.snapshotID)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("parseSnapshotName(%q) = %q, want %q", tt.snapshotID, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSnapshotTimestamp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
snapshotID string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid with name",
|
||||||
|
snapshotID: "myhost_home_2026-01-12T14:41:15Z",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid without name",
|
||||||
|
snapshotID: "myhost_2026-01-12T14:41:15Z",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - single part",
|
||||||
|
snapshotID: "nounderscore",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - bad timestamp",
|
||||||
|
snapshotID: "myhost_home_notadate",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := parseSnapshotTimestamp(tt.snapshotID)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parseSnapshotTimestamp(%q) error = %v, wantErr %v", tt.snapshotID, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotPurgeOptions(t *testing.T) {
|
||||||
|
opts := &SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Name: "home",
|
||||||
|
Force: true,
|
||||||
|
}
|
||||||
|
if !opts.KeepLatest {
|
||||||
|
t.Error("Expected KeepLatest to be true")
|
||||||
|
}
|
||||||
|
if opts.Name != "home" {
|
||||||
|
t.Errorf("Expected Name to be 'home', got %q", opts.Name)
|
||||||
|
}
|
||||||
|
if !opts.Force {
|
||||||
|
t.Error("Expected Force to be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts2 := &SnapshotPurgeOptions{
|
||||||
|
OlderThan: "30d",
|
||||||
|
Name: "system",
|
||||||
|
}
|
||||||
|
if opts2.OlderThan != "30d" {
|
||||||
|
t.Errorf("Expected OlderThan to be '30d', got %q", opts2.OlderThan)
|
||||||
|
}
|
||||||
|
if opts2.Name != "system" {
|
||||||
|
t.Errorf("Expected Name to be 'system', got %q", opts2.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
303
internal/vaultik/purge_per_name_test.go
Normal file
303
internal/vaultik/purge_per_name_test.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/vaultik/internal/database"
|
||||||
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
||||||
|
"git.eeqj.de/sneak/vaultik/internal/types"
|
||||||
|
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupPurgeTest creates a Vaultik instance with an in-memory database and mock
|
||||||
|
// storage pre-populated with the given snapshot IDs. Each snapshot is marked as
|
||||||
|
// completed. Remote metadata stubs are created so syncWithRemote keeps them.
|
||||||
|
func setupPurgeTest(t *testing.T, snapshotIDs []string) *vaultik.Vaultik {
|
||||||
|
t.Helper()
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.New(ctx, ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
|
// Insert each snapshot into the DB and create remote metadata stubs.
|
||||||
|
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||||
|
for _, id := range snapshotIDs {
|
||||||
|
// Parse timestamp from the snapshot ID
|
||||||
|
parts := strings.Split(id, "_")
|
||||||
|
timestampStr := parts[len(parts)-1]
|
||||||
|
startedAt, err := time.Parse(time.RFC3339, timestampStr)
|
||||||
|
require.NoError(t, err, "parsing timestamp from snapshot ID %q", id)
|
||||||
|
|
||||||
|
completedAt := startedAt.Add(5 * time.Minute)
|
||||||
|
snap := &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(id),
|
||||||
|
Hostname: "testhost",
|
||||||
|
VaultikVersion: "test",
|
||||||
|
StartedAt: startedAt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
}
|
||||||
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return repos.Snapshots.Create(ctx, tx, snap)
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "creating snapshot %s", id)
|
||||||
|
|
||||||
|
// Create remote metadata stub so syncWithRemote keeps it
|
||||||
|
metadataKey := "metadata/" + id + "/manifest.json.zst"
|
||||||
|
err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
stdin := &bytes.Buffer{}
|
||||||
|
|
||||||
|
v := &vaultik.Vaultik{
|
||||||
|
Storage: mockStorage,
|
||||||
|
Repositories: repos,
|
||||||
|
DB: db,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
Stdin: stdin,
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// listRemainingSnapshots returns IDs of all completed snapshots in the database.
|
||||||
|
func listRemainingSnapshots(t *testing.T, v *vaultik.Vaultik) []string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
dbSnaps, err := v.Repositories.Snapshots.ListRecent(ctx, 10000)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for _, s := range dbSnaps {
|
||||||
|
if s.CompletedAt != nil {
|
||||||
|
ids = append(ids, s.ID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_PerName(t *testing.T) {
|
||||||
|
// Create snapshots for two different names: "home" and "system".
|
||||||
|
// With per-name --keep-latest, the latest of each should be kept.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_system_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T02:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T03:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T04:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
|
||||||
|
// Should keep the latest of each name
|
||||||
|
assert.Len(t, remaining, 2, "should keep exactly 2 snapshots (one per name)")
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2026-01-01T04:00:00Z", "should keep latest system")
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T03:00:00Z", "should keep latest home")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_SingleName(t *testing.T) {
|
||||||
|
// All snapshots have the same name — keep-latest should keep exactly one.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_home_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T02:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
assert.Len(t, remaining, 1)
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T02:00:00Z", "should keep the newest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_WithNameFilter(t *testing.T) {
|
||||||
|
// Use --name to filter purge to only "home" snapshots.
|
||||||
|
// "system" snapshots should be untouched.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_system_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T02:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T03:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T04:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
Name: "home",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
|
||||||
|
// 2 system snapshots untouched + 1 latest home = 3
|
||||||
|
assert.Len(t, remaining, 3)
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2026-01-01T00:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2026-01-01T02:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T04:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_NoSnapshots(t *testing.T) {
|
||||||
|
v := setupPurgeTest(t, nil)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_NameFilterNoMatch(t *testing.T) {
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_system_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T01:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
Name: "nonexistent",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// All snapshots should remain — the name filter matched nothing
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
assert.Len(t, remaining, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeOlderThan_WithNameFilter(t *testing.T) {
|
||||||
|
// Snapshots with different names and timestamps.
|
||||||
|
// --older-than should apply only to the named subset when --name is used.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_system_2020-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2020-01-01T00:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
// Purge only "home" snapshots older than 365 days
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
OlderThan: "365d",
|
||||||
|
Force: true,
|
||||||
|
Name: "home",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
|
||||||
|
// Old system stays (not filtered by name), old home deleted, recent ones stay
|
||||||
|
assert.Len(t, remaining, 3)
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2020-01-01T00:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2026-01-01T00:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_LegacyNoNameSnapshots(t *testing.T) {
|
||||||
|
// Legacy snapshots without a name component (hostname_timestamp).
|
||||||
|
// Should be grouped together under empty-name.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_2026-01-01T02:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
assert.Len(t, remaining, 1)
|
||||||
|
assert.Contains(t, remaining, "testhost_2026-01-01T02:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_MixedNamedAndLegacy(t *testing.T) {
|
||||||
|
// Mix of named snapshots and legacy ones (no name).
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_2026-01-01T02:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T03:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
|
||||||
|
// Should keep latest of each group: latest legacy + latest home
|
||||||
|
assert.Len(t, remaining, 2)
|
||||||
|
assert.Contains(t, remaining, "testhost_2026-01-01T02:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T03:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeKeepLatest_ThreeNames(t *testing.T) {
|
||||||
|
// Three different snapshot names with multiple snapshots each.
|
||||||
|
snapshotIDs := []string{
|
||||||
|
"testhost_home_2026-01-01T00:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T01:00:00Z",
|
||||||
|
"testhost_media_2026-01-01T02:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T03:00:00Z",
|
||||||
|
"testhost_system_2026-01-01T04:00:00Z",
|
||||||
|
"testhost_media_2026-01-01T05:00:00Z",
|
||||||
|
"testhost_home_2026-01-01T06:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
v := setupPurgeTest(t, snapshotIDs)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
remaining := listRemainingSnapshots(t, v)
|
||||||
|
assert.Len(t, remaining, 3, "should keep one per name")
|
||||||
|
assert.Contains(t, remaining, "testhost_home_2026-01-01T06:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_system_2026-01-01T04:00:00Z")
|
||||||
|
assert.Contains(t, remaining, "testhost_media_2026-01-01T05:00:00Z")
|
||||||
|
}
|
||||||
@@ -520,8 +520,30 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
|
|||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeSnapshots removes old snapshots based on criteria
|
// SnapshotPurgeOptions contains options for the snapshot purge command
|
||||||
|
type SnapshotPurgeOptions struct {
|
||||||
|
KeepLatest bool
|
||||||
|
OlderThan string
|
||||||
|
Force bool
|
||||||
|
Name string // Filter purge to a specific snapshot name
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeSnapshots removes old snapshots based on criteria.
|
||||||
|
// When keepLatest is true, retention is applied per snapshot name — the latest
|
||||||
|
// snapshot for each distinct name is kept.
|
||||||
func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) error {
|
func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) error {
|
||||||
|
return v.PurgeSnapshotsWithOptions(&SnapshotPurgeOptions{
|
||||||
|
KeepLatest: keepLatest,
|
||||||
|
OlderThan: olderThan,
|
||||||
|
Force: force,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeSnapshotsWithOptions removes old snapshots based on criteria with full options.
|
||||||
|
// When KeepLatest is true, retention is applied per snapshot name — the latest
|
||||||
|
// snapshot for each distinct name is kept. If Name is non-empty, only snapshots
|
||||||
|
// matching that name are considered for purge.
|
||||||
|
func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||||
// Sync with remote first
|
// Sync with remote first
|
||||||
if err := v.syncWithRemote(); err != nil {
|
if err := v.syncWithRemote(); err != nil {
|
||||||
return fmt.Errorf("syncing with remote: %w", err)
|
return fmt.Errorf("syncing with remote: %w", err)
|
||||||
@@ -545,14 +567,51 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If --name is specified, filter to only snapshots matching that name
|
||||||
|
if opts.Name != "" {
|
||||||
|
filtered := make([]SnapshotInfo, 0, len(snapshots))
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
if parseSnapshotName(snap.ID.String()) == opts.Name {
|
||||||
|
filtered = append(filtered, snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshots = filtered
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
// Sort by timestamp (newest first)
|
||||||
sort.Slice(snapshots, func(i, j int) bool {
|
sort.Slice(snapshots, func(i, j int) bool {
|
||||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
toDelete, err := v.collectSnapshotsToPurge(snapshots, keepLatest, olderThan)
|
var toDelete []SnapshotInfo
|
||||||
if err != nil {
|
|
||||||
return err
|
if opts.KeepLatest {
|
||||||
|
// Keep the latest snapshot per snapshot name
|
||||||
|
// Group snapshots by name, then mark all but the newest in each group
|
||||||
|
latestByName := make(map[string]bool) // tracks whether we've seen the latest for each name
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
name := parseSnapshotName(snap.ID.String())
|
||||||
|
if latestByName[name] {
|
||||||
|
// Already kept the latest for this name — delete this one
|
||||||
|
toDelete = append(toDelete, snap)
|
||||||
|
} else {
|
||||||
|
// This is the latest (sorted newest-first) — keep it
|
||||||
|
latestByName[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if opts.OlderThan != "" {
|
||||||
|
// Parse duration
|
||||||
|
duration, err := parseDuration(opts.OlderThan)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid duration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-duration)
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
if snap.Timestamp.Before(cutoff) {
|
||||||
|
toDelete = append(toDelete, snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
@@ -560,37 +619,7 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.confirmAndExecutePurge(toDelete, force)
|
return v.confirmAndExecutePurge(toDelete, opts.Force)
|
||||||
}
|
|
||||||
|
|
||||||
// collectSnapshotsToPurge determines which snapshots to delete based on retention criteria
|
|
||||||
func (v *Vaultik) collectSnapshotsToPurge(snapshots []SnapshotInfo, keepLatest bool, olderThan string) ([]SnapshotInfo, error) {
|
|
||||||
if keepLatest {
|
|
||||||
// Keep only the most recent snapshot
|
|
||||||
if len(snapshots) > 1 {
|
|
||||||
return snapshots[1:], nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if olderThan != "" {
|
|
||||||
// Parse duration
|
|
||||||
duration, err := parseDuration(olderThan)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid duration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoff := time.Now().UTC().Add(-duration)
|
|
||||||
var toDelete []SnapshotInfo
|
|
||||||
for _, snap := range snapshots {
|
|
||||||
if snap.Timestamp.Before(cutoff) {
|
|
||||||
toDelete = append(toDelete, snap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toDelete, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmAndExecutePurge shows deletion candidates, confirms with user, and deletes snapshots
|
// confirmAndExecutePurge shows deletion candidates, confirms with user, and deletes snapshots
|
||||||
|
|||||||
Reference in New Issue
Block a user