Files
mesh-catalog/modules/zfs/cmd/zfs-tools/snapshots_prune.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

167 lines
5.7 KiB
Go

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]
if c.Snapshots.Of(g[0].Kind) == 0 {
// A-35: a keep count of 0 is that kind's none: its scheduled snapshots are kept and a warning names them.
names := make([]string, 0, len(g))
for _, e := range g {
names = append(names, e.Dataset+"@"+e.Name)
}
sort.Strings(names)
m.Warnings = append(m.Warnings, fmt.Sprintf("the keep count of %s is 0, which removes nothing: kept %s", g[0].Kind, strings.Join(names, ", ")))
continue
}
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)
}