From a357a85faa6292192cce5ab7a4a70ab57c39fa55 Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:43:09 +0100 Subject: [PATCH] Z11 zfs-snapshots-take: the run takes its due ZFS snapshots per filesystem and records each in the ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §21, §26, A-19, A-20, A-30, A-31 --- modules/zfs/cmd/zfs-tools/bus.go | 42 +++ modules/zfs/cmd/zfs-tools/machine.go | 2 + modules/zfs/cmd/zfs-tools/main.go | 2 +- modules/zfs/cmd/zfs-tools/manifest_test.go | 6 +- modules/zfs/cmd/zfs-tools/run.go | 26 ++ modules/zfs/cmd/zfs-tools/snapshots_take.go | 275 ++++++++++++++++++++ modules/zfs/cmd/zfs-tools/snapshots_test.go | 162 ++++++++++++ modules/zfs/cmd/zfs-tools/tools.go | 20 ++ modules/zfs/module.json | 1 + 9 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 modules/zfs/cmd/zfs-tools/snapshots_take.go create mode 100644 modules/zfs/cmd/zfs-tools/snapshots_test.go diff --git a/modules/zfs/cmd/zfs-tools/bus.go b/modules/zfs/cmd/zfs-tools/bus.go index 5213f39c3..41ec813b3 100644 --- a/modules/zfs/cmd/zfs-tools/bus.go +++ b/modules/zfs/cmd/zfs-tools/bus.go @@ -8,6 +8,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/process" ) @@ -119,3 +120,44 @@ func (h *eventHub) await(kinds []string, bound time.Duration) (map[string]any, b return nil, false } } + +// busLedger is the ledger `snapshots` through the tool runner. +type busLedger struct{} + +func (busLedger) Apply(instance, operation string, body any, call string) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + _, err = stdio.BusState(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: instance, + Operation: operation, Body: raw, Call: call}) + return err +} + +func (busLedger) Entries() ([]LedgerEntry, error) { + raw, err := stdio.BusState(buscalls.LedgerQuery, buscalls.QueryRequest{Ledger: LedgerName, Index: "by-dataset-kind"}) + if err != nil { + return nil, err + } + var q buscalls.QueryAnswer + if err := json.Unmarshal(raw, &q); err != nil { + return nil, err + } + var out []LedgerEntry + for _, ie := range q.Entries { + raw, err := stdio.BusState(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: ie.Instance}) + if err != nil { + return nil, err + } + var st buscalls.InstanceState + if err := json.Unmarshal(raw, &st); err != nil { + return nil, err + } + var e LedgerEntry + if err := json.Unmarshal(st.State, &e); err != nil { + return nil, err + } + out = append(out, e) + } + return out, nil +} diff --git a/modules/zfs/cmd/zfs-tools/machine.go b/modules/zfs/cmd/zfs-tools/machine.go index 86ef58a42..669aa5039 100644 --- a/modules/zfs/cmd/zfs-tools/machine.go +++ b/modules/zfs/cmd/zfs-tools/machine.go @@ -117,6 +117,8 @@ type Machine struct { // Lock takes an exclusive lock on a file: waiting for it, or answering at once whether it was got. The // release gives it up. Lock func(path string, wait bool) (release func(), got bool, err error) + // Ledger is the ledger `snapshots` as the tools bundle reaches it; nil in a process, which cannot (A-20). + Ledger LedgerStore // Call invokes a tool or a seat verb as this module (`seat:.@`, `.`); nil where // this process reaches none. Call func(key string, body any) (json.RawMessage, error) diff --git a/modules/zfs/cmd/zfs-tools/main.go b/modules/zfs/cmd/zfs-tools/main.go index 55898bc45..0fb7282db 100644 --- a/modules/zfs/cmd/zfs-tools/main.go +++ b/modules/zfs/cmd/zfs-tools/main.go @@ -67,7 +67,7 @@ func main() { } m := Here() m.Emit = func(eventType string, body any) error { return stdio.Emit(eventType, body) } - m.States, m.Call, m.Await = toolStates{}, stdio.Ask, (&eventHub{}).await + m.States, m.Call, m.Ledger, m.Await = toolStates{}, stdio.Ask, busLedger{}, (&eventHub{}).await // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): zfs. if err := stdio.Serve("", Tools(m)); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/modules/zfs/cmd/zfs-tools/manifest_test.go b/modules/zfs/cmd/zfs-tools/manifest_test.go index e024087e6..45c15b9fb 100644 --- a/modules/zfs/cmd/zfs-tools/manifest_test.go +++ b/modules/zfs/cmd/zfs-tools/manifest_test.go @@ -37,7 +37,7 @@ func readManifest(t *testing.T) (manifest, string) { // which replaces nothing. No seat: a new mesh or node seat is the controller's to define (ADR 0121). // handTools are the tools that start or steer the scheduled run, which replace no command. var handTools = map[string]bool{"zfs_snapshots_run_now": true, "zfs_snapshots_prune_now": true, "zfs_config_apply": true, - "zfs_snapshots_pause": true, "zfs_snapshots_resume": true} + "zfs_snapshots_pause": true, "zfs_snapshots_resume": true, "zfs_snapshots_record": true} func TestTheManifestNamesTheToolsTheBundleServesAndWhatEachReplaces(t *testing.T) { m, raw := readManifest(t) @@ -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"}, - "ZdbConfigArgv": {"zdb", "-C"}, "ZgenhostidArgv": {"zgenhostid", "-f"}, + "SnapshotSetArgv": {"zfs", "set"}, "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,7 +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"), - "ZdbConfigArgv": ZdbConfigArgv("tank"), "ZgenhostidArgv": ZgenhostidArgv("00bab10c")} + "SnapshotSetArgv": SnapshotSetArgv("tank@mesh-hourly-x", "hourly"), "ZdbConfigArgv": ZdbConfigArgv("tank"), "ZgenhostidArgv": ZgenhostidArgv("00bab10c")} for fn, argv := range verbs { for _, a := range argv { if fn != "FollowArgv" && fn != "ZgenhostidArgv" && (a == "-f" || a == "-r" || a == "-R" || a == "-d") { diff --git a/modules/zfs/cmd/zfs-tools/run.go b/modules/zfs/cmd/zfs-tools/run.go index 922cefdf9..a99894446 100644 --- a/modules/zfs/cmd/zfs-tools/run.go +++ b/modules/zfs/cmd/zfs-tools/run.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "sort" "strings" @@ -97,7 +98,14 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string, } } for _, act := range acts { + by := m.actBy(act) switch { + case act == ActSnapshots && guard == "": + ts, err := m.TakeScheduled(ctx, c, true, by) + said = append(said, ts...) + if err != nil { + failed = append(failed, err) + } case act == "apply": s, err := m.applyLocked(ctx) said = append(said, "apply: "+s) @@ -150,6 +158,11 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string, failed = append(failed, fmt.Errorf("the %s of %s was started, and the served slot could not be kept: %v", work.name, c.Pool, err)) } } + ts, err := m.TakeScheduled(ctx, c, false, "") + said = append(said, ts...) + if err != nil { + failed = append(failed, err) + } if len(said) == 0 { said = append(said, "nothing due") } @@ -166,3 +179,16 @@ func joinErrors(errs []error) error { } return fmt.Errorf("%s", strings.Join(parts, "; ")) } + +// actBy is who a hand act was by, from its key. +func (m *Machine) actBy(act string) string { + raw, ok, err := m.States.Get(StateSchedule, act) + if err != nil || !ok { + return "" + } + var v struct { + By string `json:"by"` + } + _ = json.Unmarshal(raw, &v) + return v.By +} diff --git a/modules/zfs/cmd/zfs-tools/snapshots_take.go b/modules/zfs/cmd/zfs-tools/snapshots_take.go new file mode 100644 index 000000000..977618db5 --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/snapshots_take.go @@ -0,0 +1,275 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +// The run's ZFS snapshots: taking them (design 42 zfs-owns-zfs §21, A-20, A-30, A-31). +// +// Each kind whose keep count is above 0 is due at the first run of the hour (hourly), the first run after +// midnight (daily), on Monday (weekly) and on the first of the month (monthly), in the node's time zone, +// against the ledger's last entry of that kind. For every filesystem of the pool the run takes `zfs snapshot -- +// @mesh--`, never recursive, sets `mesh:schedule=` on it, and records `taken` +// through the module's own tool `zfs_snapshots_record` (a process cannot write a ledger, A-20). It reads the +// ledger through `zfs_snapshots_schedule` (A-31). It takes none it cannot record in the same run: with the bus +// down or the ledger full it takes none and says `snapshot.skipped.schedule`, and none while the pool is more +// than 80 % full. + +// FullPercent is the capacity above which no scheduled ZFS snapshot is taken. +const FullPercent = 80 + +// The tools the run calls on its own module (A-20, A-31). +const ( + ToolRecord = "zfs.zfs_snapshots_record" + ToolSchedule = "zfs.zfs_snapshots_schedule" +) + +// StampForm is the UTC stamp in a scheduled snapshot's name. +const StampForm = "20060102T1504Z" + +var meshSnapshot = regexp.MustCompile(`^mesh-(hourly|daily|weekly|monthly)-([0-9]{8}T[0-9]{4}Z)$`) + +// SnapshotSetArgv sets a user property on a snapshot; only mesh:schedule is ever set by the module. +func SnapshotSetArgv(snapshot, kind string) []string { + return []string{"set", "mesh:schedule=" + kind, "--", snapshot} +} + +// LedgerEntry is one instance of the ledger `snapshots` (A-30): the ZFS snapshot the run took. +type LedgerEntry struct { + Dataset string `json:"dataset"` + Name string `json:"name"` + Kind string `json:"kind"` + At string `json:"at"` +} + +// LedgerInstance is the instance path of a scheduled snapshot: `snapshot/--`, the +// dataset's `/` written `~`. +func LedgerInstance(kind, stamp, dataset string) string { + return "snapshot/" + kind + "-" + stamp + "-" + strings.ReplaceAll(dataset, "/", "~") +} + +// InstanceOf is the instance of a record's snapshot, refusing a name that is not a scheduled snapshot's. +func InstanceOf(dataset, name string) (string, error) { + m := meshSnapshot.FindStringSubmatch(name) + if m == nil { + return "", fmt.Errorf("%q is not a scheduled snapshot's name (mesh--): the ledger holds only the run's", name) + } + if dataset == "" || strings.ContainsAny(dataset, "@~ ") { + return "", fmt.Errorf("%q is not a dataset's name", dataset) + } + return LedgerInstance(m[1], m[2], dataset), nil +} + +// ledgerEntries reads the ledger through the module's tool zfs_snapshots_schedule. +func (m *Machine) ledgerEntries() ([]LedgerEntry, error) { + if m.Call == nil { + return nil, fmt.Errorf("this process reaches no tool, so the ledger cannot be read") + } + raw, err := m.Call(ToolSchedule, map[string]any{}) + if err != nil { + return nil, err + } + var ans struct { + Ledger []LedgerEntry `json:"ledger"` + } + if err := json.Unmarshal(raw, &ans); err != nil { + return nil, fmt.Errorf("zfs_snapshots_schedule answered what is no ledger: %v", err) + } + return ans.Ledger, nil +} + +// record tells the ledger of a taken or removed snapshot through zfs_snapshots_record. +func (m *Machine) recordLedger(op string, e LedgerEntry) error { + if m.Call == nil { + return fmt.Errorf("this process reaches no tool, so %s was not recorded", e.Name) + } + _, err := m.Call(ToolRecord, map[string]any{"operation": op, "dataset": e.Dataset, "name": e.Name, "kind": e.Kind, "at": e.At}) + return err +} + +// keepsOf are the kinds with a keep count above 0, in the order they are said. +func keepsOf(k *Keeps) []string { + var out []string + for _, kind := range keepKinds { + if k.Of(kind) > 0 { + out = append(out, kind) + } + } + return out +} + +// dueKinds are the kinds due now: the calendar's moment of the kind, and no entry of the kind since its period began. +func dueKinds(k *Keeps, now time.Time, entries []LedgerEntry) []string { + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var due []string + for _, kind := range keepsOf(k) { + start := day + switch kind { + case "hourly": + start = now.Truncate(time.Hour) + case "weekly": + if now.Weekday() != time.Monday { + continue + } + case "monthly": + if now.Day() != 1 { + continue + } + } + var last time.Time + for _, e := range entries { + if at, err := time.Parse(time.RFC3339, e.At); err == nil && e.Kind == kind && at.After(last) { + last = at + } + } + if last.Before(start) { + due = append(due, kind) + } + } + return due +} + +// ledgerCap is the ledger's bound: the keep counts' sum times two instances per dataset (§26). +func ledgerCap(k *Keeps, datasets int) int { + return (k.Hourly + k.Daily + k.Weekly + k.Monthly) * 2 * datasets +} + +func (m *Machine) skippedSnapshots(person bool, pool string, capacity int, reason string) { + kind := EventSkippedSched + if person { + kind = EventSkippedPerson + } + var failed []error + if err := m.say(kind, map[string]any{"pool": pool, "capacity": capacity, "reason": reason}); err != nil { + m.failure(&failed, kind, err) + } +} + +// filesystems are the pool's filesystems, never a volume. +func (m *Machine) filesystems(ctx context.Context, pool string) ([]string, int, error) { + ds, err := m.Datasets(ctx) + if err != nil { + return nil, 0, err + } + var out []string + for _, d := range ds { + if d.Type == "filesystem" && (d.Name == pool || strings.HasPrefix(d.Name, pool+"/")) { + out = append(out, d.Name) + } + } + capacity := 0 + ps, err := m.Pools(ctx) + if err != nil { + return nil, 0, err + } + for _, p := range ps { + if p.Name == pool { + capacity = p.Capacity + } + } + return out, capacity, nil +} + +// TakeScheduled takes the due ZFS snapshots (every kind with a keep count when force: a person's act, by +// them). It answers what it did, and a failed command as the error. +func (m *Machine) TakeScheduled(ctx context.Context, c *Config, force bool, by string) ([]string, error) { + if c.Snapshots == nil || c.Snapshots.None || c.Pool == "" { + return nil, nil + } + person := force + taken := EventTakenSchedule + if person { + taken = EventTakenPerson + } else { + by = "schedule" + } + fs, capacity, err := m.filesystems(ctx, c.Pool) + if err != nil { + return nil, err + } + if m.Paused() { + m.skippedSnapshots(person, c.Pool, capacity, "paused") + return []string{"snapshots paused"}, nil + } + entries, err := m.ledgerEntries() + if err != nil { + m.skippedSnapshots(person, c.Pool, capacity, "ledger-unavailable: "+err.Error()) + return []string{"no snapshot taken: the ledger cannot be read"}, nil + } + due := keepsOf(c.Snapshots) + if !force { + due = dueKinds(c.Snapshots, m.Now(), entries) + } + if len(due) == 0 || len(fs) == 0 { + return nil, nil + } + if capacity > FullPercent { + m.skippedSnapshots(person, c.Pool, capacity, "full") + return []string{fmt.Sprintf("no snapshot taken: the pool is %d %% full", capacity)}, nil + } + if len(entries)+len(due)*len(fs) > ledgerCap(c.Snapshots, len(fs)) { + m.skippedSnapshots(person, c.Pool, capacity, "ledger-full") + return []string{"no snapshot taken: the ledger is full"}, nil + } + now := m.Now().UTC() + var said []string + var failed []error + for _, kind := range due { + for _, ds := range fs { + name := fmt.Sprintf("%s%s-%s", MeshPrefix, kind, now.Format(StampForm)) + full := ds + "@" + name + if _, err := m.AsRoot(ctx, "zfs", SnapshotArgv(ds, name)...); err != nil { + failed = append(failed, fmt.Errorf("%s: %v", full, err)) + continue + } + if _, err := m.AsRoot(ctx, "zfs", SnapshotSetArgv(full, kind)...); err != nil { + failed = append(failed, fmt.Errorf("%s was taken, and mesh:schedule could not be set: %v", full, err)) + continue + } + e := LedgerEntry{Dataset: ds, Name: name, Kind: kind, At: now.Format(time.RFC3339)} + if err := m.recordLedger("taken", e); err != nil { + failed = append(failed, fmt.Errorf("%s was taken, and the ledger did not take it: %v", full, err)) + continue + } + if err := m.say(taken, map[string]any{"snapshot": full, "by": by}); err != nil { + m.failure(&failed, taken, err) + } + said = append(said, "took "+full) + } + } + return said, joinErrors(failed) +} + +// LedgerName is the ledger's local name in the module's `requires`. +const LedgerName = "snapshots" + +// LedgerStore is the ledger `snapshots` as the tools bundle reaches it (A-20, A-30). +type LedgerStore interface { + // Apply applies an operation to an instance; call is the id of the tool call it serves. + Apply(instance, operation string, body any, call string) error + // Entries are every instance's state. + Entries() ([]LedgerEntry, error) +} + +// Record applies the run's `taken` or `removed` to the ledger, refusing a snapshot not named mesh- (A-20). +func (m *Machine) Record(op string, e LedgerEntry, call string) error { + if op != "taken" && op != "removed" { + return fmt.Errorf("%q is not an operation of the ledger: taken or removed", op) + } + instance, err := InstanceOf(e.Dataset, e.Name) + if err != nil { + return err + } + if m.Ledger == nil { + return fmt.Errorf("this process reaches no ledger") + } + if op == "removed" { + return m.Ledger.Apply(instance, op, struct{}{}, call) + } + return m.Ledger.Apply(instance, op, e, call) +} diff --git a/modules/zfs/cmd/zfs-tools/snapshots_test.go b/modules/zfs/cmd/zfs-tools/snapshots_test.go new file mode 100644 index 000000000..b2aec5650 --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/snapshots_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +type ledgerCalls struct { + entries []LedgerEntry + read error + record error + records []map[string]any +} + +func (l *ledgerCalls) call(key string, body any) (json.RawMessage, error) { + switch key { + case ToolSchedule: + if l.read != nil { + return nil, l.read + } + return json.Marshal(map[string]any{"ledger": l.entries}) + case ToolRecord: + if l.record != nil { + return nil, l.record + } + l.records = append(l.records, body.(map[string]any)) + return json.RawMessage(`{}`), nil + } + return nil, errors.New("not a tool of the run: " + key) +} + +func snapFake(t *testing.T, keeps string, l *ledgerCalls) (*fake, *Machine, memStates) { + f := withZFS(t) + f.uid = 0 + f.now = time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local) // a Friday + m := f.machine() + st := memStates{} + m.States, m.Call = st, l.call + return f, m, st +} + +func keepsOfJSON(t *testing.T, s string) *Config { + c, ref := ParseConfig([]byte(`{"pool":"tank","snapshots":`+s+`}`), 16000) + if len(ref) != 0 { + t.Fatal(ref) + } + return c +} + +// §21, A-20, A-30, A-31, §31: a due kind takes one ZFS snapshot per filesystem of the pool (never a volume, +// never recursive), sets mesh:schedule, records taken, and says snapshot.taken.schedule; with the bus down, the +// ledger full, the pool above 80 % or the schedule paused none is taken, and snapshot.skipped.schedule says why. +func TestADueKindTakesOneSnapshotPerFilesystemAndRecordsEach(t *testing.T) { + stamp := time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local).UTC().Format(StampForm) + fresh := time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local).UTC().Format(time.RFC3339) + old := time.Date(2026, 10, 9, 10, 30, 0, 0, time.Local).UTC().Format(time.RFC3339) + cases := []struct { + name string + keeps string + l *ledgerCalls + setup func(f *fake, m *Machine, st memStates) + force bool + taken int + reason string + evKind string + wantErr bool + }{ + {name: "hourly due", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: []LedgerEntry{{"tank", "mesh-hourly-x", "hourly", old}}}, taken: 3, evKind: EventTakenSchedule}, + {name: "hourly already taken this hour", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: []LedgerEntry{{"tank", "mesh-hourly-x", "hourly", fresh}}}}, + {name: "weekly not on a Friday", keeps: `{"weekly":2}`, l: &ledgerCalls{}}, + {name: "keep none", keeps: `{"none":true}`, l: &ledgerCalls{}}, + {name: "a kind with 0 is not taken", keeps: `{"hourly":0,"daily":0}`, l: &ledgerCalls{}}, + {name: "bus down", keeps: `{"hourly":2}`, l: &ledgerCalls{read: errors.New("no bus")}, reason: "ledger-unavailable", evKind: EventSkippedSched}, + {name: "ledger full", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: make([]LedgerEntry, 6)}, reason: "ledger-full", evKind: EventSkippedSched}, + {name: "pool above 80 percent", keeps: `{"hourly":2}`, l: &ledgerCalls{}, reason: "full", evKind: EventSkippedSched, + setup: func(f *fake, _ *Machine, _ memStates) { + f.on(line("zpool", PoolsArgv()), "tank\t1000\t900\t100\t90\t1\tONLINE\t1.00\n") + }}, + {name: "paused", keeps: `{"hourly":2}`, l: &ledgerCalls{}, reason: "paused", evKind: EventSkippedSched, + setup: func(_ *fake, _ *Machine, st memStates) { st.Put(StateSchedule, KeyPaused, map[string]string{}) }}, + {name: "a person's act takes every kind and says .person", keeps: `{"hourly":1,"daily":1}`, l: &ledgerCalls{}, force: true, taken: 6, evKind: EventTakenPerson}, + {name: "the ledger refuses the record", keeps: `{"hourly":2}`, l: &ledgerCalls{record: errors.New("full")}, wantErr: true}, + } + for _, c := range cases { + f, m, st := snapFake(t, c.keeps, c.l) + if c.setup != nil { + c.setup(f, m, st) + } + cmds := map[string]bool{} + for _, ds := range []string{"tank", "tank/media", "tank/downloads"} { + for _, k := range []string{"hourly", "daily"} { + n := "mesh-" + k + "-" + stamp + f.on("zfs snapshot -- "+ds+"@"+n, "") + f.on("zfs set mesh:schedule="+k+" -- "+ds+"@"+n, "") + cmds[ds+"@"+n] = true + } + } + _, err := m.TakeScheduled(context.Background(), keepsOfJSON(t, c.keeps), c.force, "person@node") + if (err != nil) != c.wantErr { + t.Errorf("%s: error %v", c.name, err) + } + snaps := ran(f, "zfs snapshot") + if c.wantErr { + if len(snaps) == 0 { + t.Errorf("%s: nothing taken", c.name) + } + continue + } + if len(snaps) != c.taken || len(c.l.records) != c.taken || len(ran(f, "zfs set mesh:schedule=")) != c.taken { + t.Errorf("%s: %d snapshots, %d sets, %d records, want %d: %v", c.name, len(snaps), len(ran(f, "zfs set mesh:schedule=")), len(c.l.records), c.taken, f.calls) + } + for _, s := range snaps { + if strings.Contains(s, "tank/vm") || strings.Contains(s, " -r") { + t.Errorf("%s: %s", c.name, s) + } + } + if c.evKind == "" && len(f.emitted) != 0 || c.evKind != "" && (len(f.emitted) == 0 || f.emitted[0]["_type"] != c.evKind) { + t.Errorf("%s: events %v, want %s", c.name, f.emitted, c.evKind) + } + if c.reason != "" && !strings.HasPrefix(f.emitted[0]["reason"].(string), c.reason) { + t.Errorf("%s: reason %v", c.name, f.emitted[0]["reason"]) + } + if c.force && f.emitted[0]["by"] != "person@node" { + t.Errorf("%s: %v", c.name, f.emitted[0]) + } + } +} + +// A-20: the tool zfs_snapshots_record refuses a snapshot not named mesh- and one of another operation. +type fakeLedger struct{ applied []string } + +func (l *fakeLedger) Apply(instance, op string, body any, call string) error { + l.applied = append(l.applied, op+" "+instance) + return nil +} +func (l *fakeLedger) Entries() ([]LedgerEntry, error) { return nil, nil } + +func TestTheLedgerTakesOnlyTheRunsSnapshots(t *testing.T) { + l := &fakeLedger{} + m := newFake(t).machine() + m.Ledger = l + e := LedgerEntry{Dataset: "tank/media", Name: "mesh-hourly-20261009T1000Z", Kind: "hourly", At: "2026-10-09T10:00:00Z"} + if err := m.Record("taken", e, "id"); err != nil || len(l.applied) != 1 || l.applied[0] != "taken snapshot/hourly-20261009T1000Z-tank~media" { + t.Fatalf("%v %v", err, l.applied) + } + if err := m.Record("removed", e, "id"); err != nil || l.applied[1] != "removed snapshot/hourly-20261009T1000Z-tank~media" { + t.Fatal(err, l.applied) + } + for _, bad := range []LedgerEntry{{Dataset: "tank", Name: "before-move"}, {Dataset: "tank", Name: "mesh-yearly-20261009T1000Z"}, + {Dataset: "", Name: "mesh-hourly-20261009T1000Z"}, {Dataset: "tank@x", Name: "mesh-hourly-20261009T1000Z"}} { + if err := m.Record("taken", bad, ""); err == nil { + t.Errorf("accepted %+v", bad) + } + } + if err := m.Record("destroy", e, ""); err == nil || len(l.applied) != 2 { + t.Errorf("another operation: %v %v", err, l.applied) + } +} diff --git a/modules/zfs/cmd/zfs-tools/tools.go b/modules/zfs/cmd/zfs-tools/tools.go index d77989000..9d4ff7f44 100644 --- a/modules/zfs/cmd/zfs-tools/tools.go +++ b/modules/zfs/cmd/zfs-tools/tools.go @@ -86,6 +86,26 @@ func Tools(m *Machine) []stdio.Tool { return map[string]any{"paused": false}, nil }, }, + { + Name: "zfs_snapshots_record", + Description: "Record the scheduled run's `taken` or `removed` ZFS snapshot in the ledger `snapshots`: the run is a process, " + + "which cannot write a ledger, so it calls this tool. Refused for a snapshot not named mesh--: the ledger " + + "holds only the run's. (a)", + Input: map[string]any{ + "operation": map[string]any{"type": "string", "description": "taken or removed"}, + "dataset": map[string]any{"type": "string", "description": "the dataset"}, + "name": map[string]any{"type": "string", "description": "the snapshot's name after the @"}, + "kind": map[string]any{"type": "string", "description": "hourly, daily, weekly or monthly"}, + "at": map[string]any{"type": "string", "description": "when it was taken, RFC 3339 in UTC"}, + }, + Run: func(a map[string]any) (any, error) { + e := LedgerEntry{Dataset: text(a, "dataset"), Name: text(a, "name"), Kind: text(a, "kind"), At: text(a, "at")} + if err := m.Record(text(a, "operation"), e, stdio.CallIDOf(a)); err != nil { + return nil, err + } + return map[string]any{"recorded": true}, nil + }, + }, { Name: "zfs_pools", Description: "Every imported pool's health in a line: its state (ONLINE, DEGRADED, FAULTED, …) with status's own " + diff --git a/modules/zfs/module.json b/modules/zfs/module.json index 082c8dcbe..431e2fb24 100644 --- a/modules/zfs/module.json +++ b/modules/zfs/module.json @@ -64,6 +64,7 @@ "zfs_config_apply", "zfs_snapshots_pause", "zfs_snapshots_resume", + "zfs_snapshots_record", "zfs_io", "zfs_arc", "zfs_events",