Files
mesh-catalog/modules/zfs/cmd/zfs-tools/snapshots_prune_test.go
T
jschoubben 8aad282e6c Z10 Z12 trim without day or time refused, keep count 0 removes nothing
A trim schedule of weekly or monthly with no trim-day or trim-time is one refused value naming it; a keep count of 0 is that kind's none: the existing scheduled ZFS snapshots are kept and a warning names them.

Builds: design 42 zfs-owns-zfs §20, §21, §22, A-33, A-35
2026-11-13 00:51:11 +01:00

128 lines
5.4 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
// §22, §31: pruning removes every surplus of a dataset and kind in one run, oldest first, only by the
// ledger's record, and never a snapshot whose name, property or ledger entry does not fit, nor one with a hold
// or a clone; `none` and a pause remove nothing; a hand-made snapshot is never touched.
func TestPruningRemovesEverySurplusByItsRecordAndNothingThatDoesNotFit(t *testing.T) {
name := func(h int) string { return fmt.Sprintf("mesh-hourly-20261009T%02d00Z", h) }
at := func(h int) string { return fmt.Sprintf("2026-10-09T%02d:00:00Z", h) }
entry := func(ds string, h int) LedgerEntry { return LedgerEntry{ds, name(h), "hourly", at(h)} }
four := []LedgerEntry{entry("tank", 1), entry("tank", 2), entry("tank", 3), entry("tank", 4)}
cases := []struct {
name string
keeps string
l *ledgerCalls
prop map[string]string // by snapshot: its mesh:schedule
hold string // the snapshot with a hold
clone string // the snapshot with a clone
gone string // the snapshot ZFS no longer has
setup func(st memStates)
removed []string
left int
warns string // a warning the run must have kept
wantErr bool
}{
{name: "every surplus in one run, oldest first", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four},
removed: []string{"tank@" + name(1), "tank@" + name(2)}},
{name: "within the keep count", keeps: `{"hourly":4}`, l: &ledgerCalls{entries: four}},
{name: "a hold is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, hold: "tank@" + name(1),
removed: []string{"tank@" + name(2)}, left: 1},
{name: "a clone is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, clone: "tank@" + name(2),
removed: []string{"tank@" + name(1)}, left: 1},
{name: "a property that does not fit is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four},
prop: map[string]string{"tank@" + name(1): "daily"}, removed: []string{"tank@" + name(2)}, left: 1},
{name: "a dataset of another pool is left", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: []LedgerEntry{entry("other", 1), entry("other", 2)}}, left: 1},
{name: "a name that does not fit is left", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: []LedgerEntry{
{"tank", "before-move", "hourly", at(1)}, entry("tank", 2)}}, left: 1},
{name: "none removes nothing", keeps: `{"none":true}`, l: &ledgerCalls{entries: four}},
{name: "a keep count of 0 is none: nothing removed, a warning names them (A-35)", keeps: `{"hourly":0,"daily":1}`,
l: &ledgerCalls{entries: four}, warns: "keep count of hourly is 0"},
{name: "paused removes nothing", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: four},
setup: func(st memStates) { st.Put(StateSchedule, KeyPaused, map[string]string{}) }},
{name: "no read, no removal", keeps: `{"hourly":1}`, l: &ledgerCalls{read: errors.New("no bus")}},
{name: "a snapshot ZFS no longer has is only recorded removed", keeps: `{"hourly":3}`, l: &ledgerCalls{entries: four},
gone: "tank@" + name(1)},
{name: "a failed destroy is the run's failure", keeps: `{"hourly":3}`, l: &ledgerCalls{entries: four}, wantErr: true},
}
for _, c := range cases {
f, m, st := snapFake(t, c.keeps, c.l)
if c.setup != nil {
c.setup(st)
}
var list strings.Builder
for _, e := range c.l.entries {
full := e.Dataset + "@" + e.Name
if full != c.gone {
fmt.Fprintf(&list, "%s\t1791400000\t1000\t2000\n", full)
}
p := c.prop[full]
if p == "" {
p = e.Kind
}
f.on("zfs get -Hp -o value mesh:schedule -- "+full, p+"\n")
h := ""
if full == c.hold {
h = full + "\tkeep\tFri Oct 9 12:00 2026\n"
}
f.on("zfs holds -H -- "+full, h)
cl := "-"
if full == c.clone {
cl = "tank/clone"
}
f.on("zfs get -Hp -o value clones -- "+full, cl+"\n")
if !c.wantErr {
f.on("zfs destroy -- "+full, "")
}
}
f.on(line("zfs", SnapshotsArgv()), list.String()+"tank@by-hand\t1791400000\t1\t2\n")
said, err := m.PruneScheduled(context.Background(), keepsOfJSON(t, c.keeps), false, "")
if c.warns != "" && !strings.Contains(strings.Join(m.Warnings, "|"), c.warns) {
t.Errorf("%s: warnings %v, want %q", c.name, m.Warnings, c.warns)
}
if (err != nil) != c.wantErr {
t.Errorf("%s: error %v", c.name, err)
}
var destroyed []string
for _, d := range ran(f, "zfs destroy") {
destroyed = append(destroyed, strings.TrimPrefix(d, "zfs destroy -- "))
}
if c.gone != "" {
if len(destroyed) != 0 || len(c.l.records) != 1 || c.l.records[0]["operation"] != "removed" {
t.Errorf("%s: destroyed %v, records %v", c.name, destroyed, c.l.records)
}
continue
}
if strings.Join(destroyed, ",") != strings.Join(c.removed, ",") && !c.wantErr {
t.Errorf("%s: destroyed %v, want %v (%v)", c.name, destroyed, c.removed, said)
}
if !c.wantErr && len(c.l.records) != len(c.removed) {
t.Errorf("%s: %d records for %d removals", c.name, len(c.l.records), len(c.removed))
}
skips := 0
for _, e := range f.emitted {
if e["_type"] == EventSkippedSched {
skips++
}
if e["_type"] == EventRemovedSched && e["space freed in bytes"] != uint64(1000) {
t.Errorf("%s: freed %v", c.name, e)
}
}
if skips != c.left || len(f.emitted)-skips != len(c.removed) {
t.Errorf("%s: events %v, left %d", c.name, f.emitted, c.left)
}
for _, d := range destroyed {
if !strings.Contains(d, "@mesh-") || strings.Contains(d, "by-hand") {
t.Errorf("%s: destroyed %s", c.name, d)
}
}
}
}