Z11 Z12 Z15 the ledger is one instance per dataset and kind, its state a map taken keyed by the ZFS snapshot's name
Builds: design 42 zfs-owns-zfs §21, §22, §26, §28, A-36 (corrects A-30)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ type LedgerEntry struct {
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// LedgerInstance is the instance path of a scheduled snapshot: `snapshot/<kind>-<UTC stamp>-<dataset>`, 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/<kind>-<dataset>`, 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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
Reference in New Issue
Block a user