diff --git a/modules/zfs/cmd/zfs-tools/bus.go b/modules/zfs/cmd/zfs-tools/bus.go index 2760a8e64..8a8f4ba8f 100644 --- a/modules/zfs/cmd/zfs-tools/bus.go +++ b/modules/zfs/cmd/zfs-tools/bus.go @@ -136,30 +136,42 @@ func (busLedger) Apply(instance, operation string, body any, call string) error 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 +// absentInstance says whether a ledger get said the instance is not there, which an instance never written is. +func absentInstance(err error) bool { + s := strings.ToLower(err.Error()) + for _, w := range []string{"not found", "no instance", "no such", "does not exist", "unknown instance", "not kept"} { + if strings.Contains(s, w) { + return true + } } + return false +} + +func (busLedger) Entries(datasets []string) ([]LedgerEntry, error) { 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 + for _, ds := range datasets { + for _, kind := range keepKinds { + raw, err := stdio.BusState(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: LedgerInstance(kind, ds)}) + if err != nil { + if absentInstance(err) { + continue + } + return nil, err + } + var st buscalls.InstanceState + if err := json.Unmarshal(raw, &st); err != nil { + return nil, err + } + var body struct { + Taken map[string]LedgerEntry `json:"taken"` + } + if err := json.Unmarshal(st.State, &body); err != nil { + return nil, err + } + for _, name := range sortedKeys(body.Taken) { + out = append(out, body.Taken[name]) + } } - 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/health.go b/modules/zfs/cmd/zfs-tools/health.go index 7bfad37f7..7aa45b1d9 100644 --- a/modules/zfs/cmd/zfs-tools/health.go +++ b/modules/zfs/cmd/zfs-tools/health.go @@ -211,7 +211,7 @@ func (m *Machine) Health(ctx context.Context) Health { } sched, serr := m.ScheduleNow(ctx) return m.Errors.Judge(Look{Pools: pools, Status: status, Now: m.Now(), Schedule: sched, ScheduleErr: serr, - Served: m.readServed(ServedFile), Module: m.moduleLook()}) + Served: m.readServed(ServedFile), Module: m.moduleLook(ctx)}) } func sentence(s string) string { diff --git a/modules/zfs/cmd/zfs-tools/health_module.go b/modules/zfs/cmd/zfs-tools/health_module.go index e975e32e9..b58dc0b5f 100644 --- a/modules/zfs/cmd/zfs-tools/health_module.go +++ b/modules/zfs/cmd/zfs-tools/health_module.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "strings" @@ -34,7 +35,7 @@ var snapshotLate = map[string]time.Duration{"hourly": 2 * time.Hour, "daily": 36 // moduleLook reads what the module itself keeps. It answers nil when the config file is not readable or not valid: // the module is then judged on its pools alone, and the refusal is already in the state `applied`. -func (m *Machine) moduleLook() *ModuleLook { +func (m *Machine) moduleLook(ctx context.Context) *ModuleLook { text, err := m.ReadFile(ConfigFile) if err != nil { return nil @@ -58,7 +59,7 @@ func (m *Machine) moduleLook() *ModuleLook { } l.Paused = m.Paused() if m.Ledger != nil { - if es, err := m.Ledger.Entries(); err == nil { + if es, err := m.ledgerAll(ctx); err == nil { l.Ledger, l.LedgerKnown = es, true } } diff --git a/modules/zfs/cmd/zfs-tools/snapshots_take.go b/modules/zfs/cmd/zfs-tools/snapshots_take.go index 977618db5..ab5cd49b2 100644 --- a/modules/zfs/cmd/zfs-tools/snapshots_take.go +++ b/modules/zfs/cmd/zfs-tools/snapshots_take.go @@ -47,10 +47,10 @@ type LedgerEntry struct { 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, "/", "~") +// LedgerInstance is the instance path of a dataset's scheduled snapshots of one kind: `snapshot/-`, the +// dataset's `/` written `~` (A-36). Its state is the map `taken`, keyed by the ZFS snapshot's name. +func LedgerInstance(kind, dataset string) string { + return "snapshot/" + kind + "-" + strings.ReplaceAll(dataset, "/", "~") } // InstanceOf is the instance of a record's snapshot, refusing a name that is not a scheduled snapshot's. @@ -62,7 +62,7 @@ func InstanceOf(dataset, name string) (string, error) { if dataset == "" || strings.ContainsAny(dataset, "@~ ") { return "", fmt.Errorf("%q is not a dataset's name", dataset) } - return LedgerInstance(m[1], m[2], dataset), nil + return LedgerInstance(m[1], dataset), nil } // ledgerEntries reads the ledger through the module's tool zfs_snapshots_schedule. @@ -252,8 +252,8 @@ const LedgerName = "snapshots" 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) + // Entries are the entries of the `taken` map of every kind's instance of each dataset; a dataset with none has none. + Entries(datasets []string) ([]LedgerEntry, error) } // Record applies the run's `taken` or `removed` to the ledger, refusing a snapshot not named mesh- (A-20). @@ -269,7 +269,9 @@ func (m *Machine) Record(op string, e LedgerEntry, call string) error { 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, struct { + Name string `json:"name"` + }{e.Name}, 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 index b2aec5650..4ec20f2e6 100644 --- a/modules/zfs/cmd/zfs-tools/snapshots_test.go +++ b/modules/zfs/cmd/zfs-tools/snapshots_test.go @@ -131,23 +131,25 @@ func TestADueKindTakesOneSnapshotPerFilesystemAndRecordsEach(t *testing.T) { } // A-20: the tool zfs_snapshots_record refuses a snapshot not named mesh- and one of another operation. -type fakeLedger struct{ applied []string } +type fakeLedger struct{ applied, bodies []string } func (l *fakeLedger) Apply(instance, op string, body any, call string) error { l.applied = append(l.applied, op+" "+instance) + b, _ := json.Marshal(body) + l.bodies = append(l.bodies, string(b)) return nil } -func (l *fakeLedger) Entries() ([]LedgerEntry, error) { return nil, nil } +func (l *fakeLedger) Entries([]string) ([]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" { + if err := m.Record("taken", e, "id"); err != nil || len(l.applied) != 1 || l.applied[0] != "taken snapshot/hourly-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" { + if err := m.Record("removed", e, "id"); err != nil || l.applied[1] != "removed snapshot/hourly-tank~media" || l.bodies[1] != `{"name":"mesh-hourly-20261009T1000Z"}` || !strings.Contains(l.bodies[0], `"name":"mesh-hourly-20261009T1000Z"`) { t.Fatal(err, l.applied) } for _, bad := range []LedgerEntry{{Dataset: "tank", Name: "before-move"}, {Dataset: "tank", Name: "mesh-yearly-20261009T1000Z"}, diff --git a/modules/zfs/cmd/zfs-tools/tools.go b/modules/zfs/cmd/zfs-tools/tools.go index 8fee83e58..6a0adda26 100644 --- a/modules/zfs/cmd/zfs-tools/tools.go +++ b/modules/zfs/cmd/zfs-tools/tools.go @@ -114,7 +114,7 @@ func Tools(m *Machine) []stdio.Tool { readTool("zfs_snapshots_schedule", "The schedule of the scheduled ZFS snapshots: the keep counts, when each kind next falls due, whether it is paused, "+ "the entries past their keep count the next run would remove, and the ledger `{\"ledger\": [...]}` of every scheduled ZFS snapshot the run took "+ "and did not remove. The scheduled run reads the ledger from here. Changes nothing. (r)", nil, - func(map[string]any) (any, error) { return m.SnapshotSchedule() }), + func(map[string]any) (any, error) { return m.SnapshotSchedule(ctx) }), readTool("zfs_module_events", "The module's own events, newest first, from its events stream (config.applied, config.refused, config.waiting, "+ "snapshot.taken.person and .schedule, snapshot.removed.person and .schedule, snapshot.skipped.person and .schedule, run.skipped, scrub.requested), "+ "the last N of one kind or of all, with how many of each kind. Not ZFS's own events: zfs_events reads those. Changes nothing. (r)", diff --git a/modules/zfs/cmd/zfs-tools/tools_config.go b/modules/zfs/cmd/zfs-tools/tools_config.go index 156b4ce39..875f59188 100644 --- a/modules/zfs/cmd/zfs-tools/tools_config.go +++ b/modules/zfs/cmd/zfs-tools/tools_config.go @@ -256,12 +256,12 @@ func wouldRemove(entries []LedgerEntry, k *Keeps) []LedgerEntry { // SnapshotSchedule shows the schedule of the scheduled ZFS snapshots, its next run, what the next run would remove and // the ledger. The ledger is shown even when the config says nothing of it: the scheduled run reads it from here. -func (m *Machine) SnapshotSchedule() (ScheduleAnswer, error) { +func (m *Machine) SnapshotSchedule(ctx context.Context) (ScheduleAnswer, error) { a := ScheduleAnswer{Keeps: map[string]int{}, Next: map[string]string{}, WouldRemove: []LedgerEntry{}, Ledger: []LedgerEntry{}, Said: []string{}} if m.Ledger == nil { return a, fmt.Errorf("this process reaches no ledger") } - entries, err := m.Ledger.Entries() + entries, err := m.ledgerAll(ctx) if err != nil { return a, fmt.Errorf("the ledger %s could not be read: %v", LedgerName, err) } @@ -379,7 +379,7 @@ func (m *Machine) CheckSnapshots(ctx context.Context) (CheckAnswer, error) { if m.Ledger == nil { return a, fmt.Errorf("this process reaches no ledger") } - entries, err := m.Ledger.Entries() + entries, err := m.ledgerAll(ctx) if err != nil { return a, fmt.Errorf("the ledger %s could not be read: %v", LedgerName, err) } @@ -445,3 +445,24 @@ func (m *Machine) FindingsNow() (FindingsAnswer, error) { } return a, nil } + +// ledgerAll reads the ledger's entries for every filesystem of the config's pool (A-36: the ledger is one instance per +// dataset and kind, so the datasets name what to read). No pool set, no entries. +func (m *Machine) ledgerAll(ctx context.Context) ([]LedgerEntry, error) { + text, err := m.ReadFile(ConfigFile) + if err != nil { + return nil, fmt.Errorf("%s could not be read: %v", ConfigFile, err) + } + var c struct { + Pool string `json:"pool"` + } + _ = json.Unmarshal(text, &c) + if c.Pool == "" { + return nil, nil + } + fs, _, err := m.filesystems(ctx, c.Pool) + if err != nil { + return nil, err + } + return m.Ledger.Entries(fs) +} diff --git a/modules/zfs/cmd/zfs-tools/tools_config_test.go b/modules/zfs/cmd/zfs-tools/tools_config_test.go index 5e55fca27..edb15491d 100644 --- a/modules/zfs/cmd/zfs-tools/tools_config_test.go +++ b/modules/zfs/cmd/zfs-tools/tools_config_test.go @@ -16,7 +16,7 @@ type staticLedger struct { } func (l staticLedger) Apply(string, string, any, string) error { return nil } -func (l staticLedger) Entries() ([]LedgerEntry, error) { return l.entries, l.err } +func (l staticLedger) Entries([]string) ([]LedgerEntry, error) { return l.entries, l.err } type staticLog struct { evs []LoggedEvent @@ -124,11 +124,11 @@ func TestSnapshotsScheduleAnswersTheLedgerTheRunReads(t *testing.T) { {name: "paused", conf: `{"pool":"tank","snapshots":{"hourly":1}}`, paused: true, said: "paused"}, {name: "none keeps what exists", conf: `{"pool":"tank","snapshots":{"none":true}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "are kept"}, {name: "not managed", conf: `{"pool":"tank"}`, said: "not managed"}, - {name: "refused config still answers the ledger", conf: `{"snapshots":{"hourly":999}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "refused"}, - {name: "ledger unreadable", conf: `{}`, l: staticLedger{err: errors.New("no bus")}, wantErr: true}, + {name: "refused config still answers the ledger", conf: `{"pool":"tank","snapshots":{"hourly":999}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "refused"}, + {name: "ledger unreadable", conf: `{"pool":"tank"}`, l: staticLedger{err: errors.New("no bus")}, wantErr: true}, } for _, c := range cases { - f := newFake(t) + f := withZFS(t) f.now = time.Date(2026, 10, 9, 12, 30, 0, 0, time.UTC) // a Friday m := f.machine() st := memStates{} @@ -137,7 +137,7 @@ func TestSnapshotsScheduleAnswersTheLedgerTheRunReads(t *testing.T) { if c.paused { st.Put(StateSchedule, KeyPaused, map[string]string{"by": "x", "why": "y"}) } - a, err := m.SnapshotSchedule() + a, err := m.SnapshotSchedule(context.Background()) if (err != nil) != c.wantErr { t.Errorf("%s: %v", c.name, err) continue @@ -195,9 +195,10 @@ func TestModuleEventsAreTheStreamsLastNNewestFirstByKind(t *testing.T) { // ยง28: zfs_snapshots_check names each ledger entry whose ZFS snapshot is gone and each mesh- snapshot the ledger // does not hold, and changes nothing. func TestSnapshotsCheckNamesWhatTheLedgerAndZFSDoNotShare(t *testing.T) { - f := newFake(t) + f := withZFS(t) f.on(line("zfs", SnapshotsArgv()), "tank@mesh-hourly-20261009T1000Z\t1791400000\t1\t2\ntank@mesh-hourly-20261009T1100Z\t1791400000\t1\t2\ntank@by-hand\t1791400000\t1\t2\n") f.uid = 0 + f.files[ConfigFile] = `{"pool":"tank"}` m := f.machine() m.Ledger = staticLedger{entries: []LedgerEntry{{"tank", "mesh-hourly-20261009T1000Z", "hourly", "2026-10-09T10:00:00Z"}, {"tank", "mesh-hourly-20261009T0900Z", "hourly", "2026-10-09T09:00:00Z"}}}