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
128 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|