Z12 zfs-snapshots-remove: the run removes surplus scheduled ZFS snapshots only by their own record
Builds: design 42 zfs-owns-zfs §22, §26, A-30, A-31
This commit is contained in:
@@ -228,7 +228,7 @@ func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) {
|
||||
"DatasetSetArgv": {"zfs", "set"}, "DatasetInheritArgv": {"zfs", "inherit"},
|
||||
"DeviceStatusArgv": {"zpool", "status"}, "AllRealStatusArgv": {"zpool", "status"}, "RemoveArgv": {"zpool", "remove"},
|
||||
"AddArgv": {"zpool", "add"}, "WipefsArgv": {"wipefs", "--no-act"}, "ZdbLabelArgv": {"zdb", "-l"},
|
||||
"SnapshotSetArgv": {"zfs", "set"}, "ZdbConfigArgv": {"zdb", "-C"}, "ZgenhostidArgv": {"zgenhostid", "-f"},
|
||||
"SnapshotSetArgv": {"zfs", "set"}, "HoldsArgv": {"zfs", "holds"}, "SnapshotPropertyArgv": {"zfs", "get"}, "DestroyArgv": {"zfs", "destroy"}, "ZdbConfigArgv": {"zdb", "-C"}, "ZgenhostidArgv": {"zgenhostid", "-f"},
|
||||
}
|
||||
verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(),
|
||||
"StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"),
|
||||
@@ -240,6 +240,7 @@ func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) {
|
||||
"DeviceStatusArgv": DeviceStatusArgv("tank", true), "AllRealStatusArgv": AllRealStatusArgv(),
|
||||
"RemoveArgv": RemoveArgv("tank", "x"), "AddArgv": AddArgv("tank", "cache", false, "/dev/x"),
|
||||
"WipefsArgv": WipefsArgv("/dev/x"), "ZdbLabelArgv": ZdbLabelArgv("/dev/x"),
|
||||
"HoldsArgv": HoldsArgv("tank@x"), "SnapshotPropertyArgv": SnapshotPropertyArgv("tank@x", "clones"), "DestroyArgv": DestroyArgv("tank", "x"),
|
||||
"SnapshotSetArgv": SnapshotSetArgv("tank@mesh-hourly-x", "hourly"), "ZdbConfigArgv": ZdbConfigArgv("tank"), "ZgenhostidArgv": ZgenhostidArgv("00bab10c")}
|
||||
for fn, argv := range verbs {
|
||||
for _, a := range argv {
|
||||
|
||||
@@ -112,6 +112,12 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string,
|
||||
if err != nil {
|
||||
failed = append(failed, err)
|
||||
}
|
||||
case act == ActPrune && guard == "":
|
||||
ts, err := m.PruneScheduled(ctx, c, true, by)
|
||||
said = append(said, ts...)
|
||||
if err != nil {
|
||||
failed = append(failed, err)
|
||||
}
|
||||
case guard != "":
|
||||
said = append(said, m.skip(guard))
|
||||
default:
|
||||
@@ -163,6 +169,11 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string,
|
||||
if err != nil {
|
||||
failed = append(failed, err)
|
||||
}
|
||||
ps, err := m.PruneScheduled(ctx, c, false, "")
|
||||
said = append(said, ps...)
|
||||
if err != nil {
|
||||
failed = append(failed, err)
|
||||
}
|
||||
if len(said) == 0 {
|
||||
said = append(said, "nothing due")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The run's ZFS snapshots: removing them, only by their own record (design 42 zfs-owns-zfs §22).
|
||||
//
|
||||
// After taking, the run reads the ledger again in the same run (no read, no removal) and, per dataset and kind,
|
||||
// removes the oldest past its keep count, all of them in that run. Before any destroy it checks that the name
|
||||
// is `<a dataset of the pool>@mesh-<kind>-<UTC stamp>`, that the snapshot carries `mesh:schedule=<kind>`, that
|
||||
// it is in the ledger, that it has no hold and no clone; anything else is refused, said and left. Then `zfs
|
||||
// destroy -- <dataset>@<snapshot>`, the ledger's `removed`, and `snapshot.removed.schedule`. When `snapshots`
|
||||
// goes back to `none` nothing is removed. A ZFS snapshot taken by hand is never removed.
|
||||
|
||||
// HoldsArgv lists a snapshot's holds.
|
||||
func HoldsArgv(snapshot string) []string { return []string{"holds", "-H", "--", snapshot} }
|
||||
|
||||
// SnapshotPropertyArgv reads one property of a snapshot (mesh:schedule, clones).
|
||||
func SnapshotPropertyArgv(snapshot, property string) []string {
|
||||
return []string{"get", "-Hp", "-o", "value", property, "--", snapshot}
|
||||
}
|
||||
|
||||
// DestroyArgv destroys one snapshot, never a dataset: the name always holds its @.
|
||||
func DestroyArgv(dataset, snapshot string) []string {
|
||||
return []string{"destroy", "--", dataset + "@" + snapshot}
|
||||
}
|
||||
|
||||
// surplus are the entries of a dataset and kind past its keep count, oldest first.
|
||||
func surplus(entries []LedgerEntry, keep int) []LedgerEntry {
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].At != entries[j].At {
|
||||
return entries[i].At < entries[j].At
|
||||
}
|
||||
return entries[i].Name < entries[j].Name
|
||||
})
|
||||
if len(entries) <= keep {
|
||||
return nil
|
||||
}
|
||||
return entries[:len(entries)-keep]
|
||||
}
|
||||
|
||||
// refuseRemoval is the reason an entry may not be destroyed, "" when every check holds.
|
||||
func (m *Machine) refuseRemoval(ctx context.Context, pool string, e LedgerEntry) (string, error) {
|
||||
full := e.Dataset + "@" + e.Name
|
||||
nm := meshSnapshot.FindStringSubmatch(e.Name)
|
||||
switch {
|
||||
case e.Dataset != pool && !strings.HasPrefix(e.Dataset, pool+"/"):
|
||||
return "its dataset is not a dataset of the pool", nil
|
||||
case nm == nil || nm[1] != e.Kind:
|
||||
return "its name is not mesh-" + e.Kind + "-<UTC stamp>", nil
|
||||
}
|
||||
sched, err := m.ask(ctx, "zfs", SnapshotPropertyArgv(full, "mesh:schedule")...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(sched) != e.Kind {
|
||||
return fmt.Sprintf("it carries mesh:schedule=%q, not %q", strings.TrimSpace(sched), e.Kind), nil
|
||||
}
|
||||
holds, err := m.ask(ctx, "zfs", HoldsArgv(full)...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(holds) != "" {
|
||||
return "it has a hold", nil
|
||||
}
|
||||
clones, err := m.ask(ctx, "zfs", SnapshotPropertyArgv(full, "clones")...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c := strings.TrimSpace(clones); c != "" && c != "-" {
|
||||
return "it has a clone", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// PruneScheduled removes the scheduled ZFS snapshots past their keep counts (a person's act when person, by
|
||||
// them). It answers what it did, and a failed command as the error.
|
||||
func (m *Machine) PruneScheduled(ctx context.Context, c *Config, person bool, by string) ([]string, error) {
|
||||
if c.Snapshots == nil || c.Snapshots.None || c.Pool == "" {
|
||||
return nil, nil
|
||||
}
|
||||
removed, skipped := EventRemovedSched, EventSkippedSched
|
||||
if person {
|
||||
removed, skipped = EventRemovedPerson, EventSkippedPerson
|
||||
}
|
||||
if m.Paused() {
|
||||
return []string{"snapshots paused: nothing removed"}, nil
|
||||
}
|
||||
entries, err := m.ledgerEntries()
|
||||
if err != nil {
|
||||
return []string{"nothing removed: the ledger cannot be read"}, nil
|
||||
}
|
||||
snaps, err := m.Snapshots(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
have := map[string]Snapshot{}
|
||||
for _, s := range snaps {
|
||||
have[s.Name] = s
|
||||
}
|
||||
groups := map[string][]LedgerEntry{}
|
||||
for _, e := range entries {
|
||||
k := e.Dataset + " " + e.Kind
|
||||
groups[k] = append(groups[k], e)
|
||||
}
|
||||
var said []string
|
||||
var failed []error
|
||||
capacity := 0
|
||||
if ps, err := m.Pools(ctx); err == nil {
|
||||
for _, p := range ps {
|
||||
if p.Name == c.Pool {
|
||||
capacity = p.Capacity
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(groups) {
|
||||
g := groups[k]
|
||||
for _, e := range surplus(g, c.Snapshots.Of(g[0].Kind)) {
|
||||
full := e.Dataset + "@" + e.Name
|
||||
why, err := m.refuseRemoval(ctx, c.Pool, e)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %v", full, err))
|
||||
continue
|
||||
}
|
||||
if why != "" {
|
||||
var f2 []error
|
||||
if err := m.say(skipped, map[string]any{"pool": c.Pool, "capacity": capacity, "reason": "refused to remove " + full + ": " + why}); err != nil {
|
||||
m.failure(&f2, skipped, err)
|
||||
}
|
||||
said = append(said, "left "+full+": "+why)
|
||||
continue
|
||||
}
|
||||
freed := have[full].Used
|
||||
if _, there := have[full]; there {
|
||||
if _, err := m.AsRoot(ctx, "zfs", DestroyArgv(e.Dataset, e.Name)...); err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %v", full, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := m.recordLedger("removed", e); err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s was removed, and the ledger did not take it: %v", full, err))
|
||||
continue
|
||||
}
|
||||
if err := m.say(removed, map[string]any{"snapshot": full, "kind": e.Kind, "space freed in bytes": freed, "by": by}); err != nil {
|
||||
m.failure(&failed, removed, err)
|
||||
}
|
||||
said = append(said, fmt.Sprintf("removed %s (%s freed)", full, strconv.FormatUint(freed, 10)))
|
||||
}
|
||||
}
|
||||
return said, joinErrors(failed)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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
|
||||
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: "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 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user