Files
mesh-catalog/modules/zfs/cmd/zfs-tools/tools_config_test.go
T
2026-11-13 01:03:17 +01:00

239 lines
10 KiB
Go

package main
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
type staticLedger struct {
entries []LedgerEntry
err error
}
func (l staticLedger) Apply(string, string, any, string) error { return nil }
func (l staticLedger) Entries([]string) ([]LedgerEntry, error) { return l.entries, l.err }
type staticLog struct {
evs []LoggedEvent
err error
}
func (l staticLog) Last(n int, kind string) ([]LoggedEvent, error) {
var out []LoggedEvent
for _, e := range l.evs {
if kind == "" || e.Kind == kind {
out = append(out, e)
}
}
if len(out) > n {
out = out[len(out)-n:]
}
return out, l.err
}
// §28, §30, §10, §29: zfs_config shows each owned value with its config value, what runs, and how it stands
// (not managed, as set, not applied yet, waiting, refused, awaiting a load), the package's files with a .pacnew
// beside them, and what the module says before it leaves a node.
func TestZfsConfigShowsEveryValueHowItStandsAndWhatTheModuleSaysBeforeLeaving(t *testing.T) {
f := newFake(t)
m := f.machine()
st := memStates{}
m.States = st
f.files[ConfigFile] = `{"pool":"tank","arc-max":4096,"module-options":["zfs_txg_timeout=5"],"hostid":"0a0b0c0d","cache-devices":["x"],
"log-devices":[],"zed":"on","trim-schedule":"","snapshots":{"daily":3}}`
f.files[ParametersDir+"zfs_arc_max"] = "4294967296\n"
hb := make([]byte, 4)
binary.NativeEndian.PutUint32(hb, 0x0a0b0c0d)
f.files[HostIDFile] = string(hb)
m.Exists = func(p string) bool { return p == "/etc/default/zfs" || p == "/etc/default/zfs.pacnew" }
st.Put(StateApplied, "arc-max", AppliedEntry{Before: "1", After: "4294967296", Outcome: OutcomeApplied})
st.Put(StateApplied, "module-options.zfs_txg_timeout", AppliedEntry{After: "5", Outcome: OutcomeAwaitingLoad})
st.Put(StateApplied, "hostid", AppliedEntry{After: "0a0b0c0d", Outcome: OutcomeRefused, Why: "the pool's import would need -f"})
st.Put(StateApplied, "cache-devices", AppliedEntry{Outcome: OutcomeWaiting, WaitsOn: "a scrub of tank", Since: f.now})
st.Put(StateApplied, "trim-day", AppliedEntry{Outcome: OutcomeApplied}) // a value gone back to not managed is not shown
a, err := m.ConfigNow(context.Background())
if err != nil {
t.Fatal(err)
}
by := map[string]ConfigValue{}
for _, v := range a.Values {
by[v.Key] = v
}
want := map[string]string{"pool": StateAsSet, "arc-max": StateAsSet, "module-options": StateAwaitingLoad, "hostid": StateRefused,
"cache-devices": StateWaiting, "log-devices": StateNotManaged, "zed": StateAsSet, "trim-schedule": StateNotManaged,
"trim-day": StateNotManaged, "snapshots": StateAsSet, "arc-min": StateNotManaged}
for k, w := range want {
if by[k].State != w {
t.Errorf("%s: %q, want %q", k, by[k].State, w)
}
}
if len(a.Values) != len(configKeys) {
t.Errorf("%d values for %d keys", len(a.Values), len(configKeys))
}
if by["arc-max"].Running != "4294967296 bytes" || by["hostid"].Running != "0a0b0c0d" || by["hostid"].Why == "" ||
!strings.Contains(by["hostid"].Note, "check of the pool's import") || by["cache-devices"].WaitsOn != "a scrub of tank" || by["cache-devices"].Since == "" {
t.Errorf("values: %+v", by)
}
if by["arc-min"].Value != nil {
t.Errorf("an unmanaged value shows %s", by["arc-min"].Value)
}
pacnew := false
for _, fl := range a.Files {
pacnew = pacnew || (fl.Path == "/etc/default/zfs" && fl.Present && fl.Pacnew)
}
all := strings.Join(a.Leaving, " ")
if !pacnew || !strings.Contains(strings.Join(a.Said, " "), "/etc/default/zfs.pacnew") || len(a.Files) != len(packageFiles) {
t.Errorf("files %+v said %v", a.Files, a.Said)
}
for _, s := range []string{"ZFS's own defaults", "zfs-import-cache.service, zfs-mount.service and zfs.target stay", "/etc/hostid stays"} {
if !strings.Contains(all, s) {
t.Errorf("leaving does not say %q: %v", s, a.Leaving)
}
}
delete(f.files, ConfigFile)
if _, err := m.ConfigNow(context.Background()); err == nil {
t.Error("an unreadable config file was answered")
}
}
// §28, A-31: zfs_snapshots_schedule answers the keep counts, each kind's next moment, the pause, what the next
// run would remove (a keep count of 0 removes nothing, A-35) and the ledger as `{"ledger": [...]}`, which the run reads.
func TestSnapshotsScheduleAnswersTheLedgerTheRunReads(t *testing.T) {
e := func(h int) LedgerEntry {
return LedgerEntry{"tank", "mesh-hourly-x" + string(rune('0'+h)), "hourly", time.Date(2026, 10, 9, h, 0, 0, 0, time.UTC).Format(time.RFC3339)}
}
old := LedgerEntry{"tank", "mesh-daily-old", "daily", "2026-09-01T00:00:00Z"}
cases := []struct {
name, conf string
l staticLedger
paused bool
remove int
next map[string]string
said string
wantErr bool
}{
{name: "counts and surplus", conf: `{"pool":"tank","snapshots":{"hourly":2,"daily":0}}`, l: staticLedger{entries: []LedgerEntry{e(1), e(2), e(3), old}},
remove: 1, next: map[string]string{"hourly": "2026-10-09T13:00:00"}, said: "would remove 1"},
{name: "weekly and monthly", conf: `{"pool":"tank","snapshots":{"weekly":1,"monthly":1,"daily":1}}`, next: map[string]string{
"daily": "2026-10-10T00:00:00", "weekly": "2026-10-12T00:00:00", "monthly": "2026-11-01T00:00:00"}},
{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: `{"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 := withZFS(t)
f.now = time.Date(2026, 10, 9, 12, 30, 0, 0, time.UTC) // a Friday
m := f.machine()
st := memStates{}
m.States, m.Ledger = st, c.l
f.files[ConfigFile] = c.conf
if c.paused {
st.Put(StateSchedule, KeyPaused, map[string]string{"by": "x", "why": "y"})
}
a, err := m.SnapshotSchedule(context.Background())
if (err != nil) != c.wantErr {
t.Errorf("%s: %v", c.name, err)
continue
}
if c.wantErr {
continue
}
if len(a.WouldRemove) != c.remove || (c.paused && a.Paused == nil) || !strings.Contains(strings.Join(a.Said, " "), c.said) {
t.Errorf("%s: %+v", c.name, a)
}
for k, v := range c.next {
if !strings.HasPrefix(a.Next[k], v) {
t.Errorf("%s: next %s %q, want %q", c.name, k, a.Next[k], v)
}
}
// The run reads the answer as it reads the ledger.
raw, _ := json.Marshal(a)
r := f.machine()
r.Call = func(key string, _ any) (json.RawMessage, error) { return raw, nil }
got, err := r.ledgerEntries()
if err != nil || len(got) != len(c.l.entries) {
t.Errorf("%s: the run reads %v, %v", c.name, got, err)
}
}
}
// §28: zfs_module_events answers the last N events of the stream, newest first, of one kind when named, with how
// many of each; a kind the module does not say is refused.
func TestModuleEventsAreTheStreamsLastNNewestFirstByKind(t *testing.T) {
f := newFake(t)
m := f.machine()
at := func(i int) time.Time { return time.Date(2026, 10, 9, 10, i, 0, 0, time.UTC) }
m.Log = staticLog{evs: []LoggedEvent{{1, EventApplied, at(1), nil}, {2, EventRefused, at(2), nil}, {3, EventApplied, at(3), nil}, {4, EventRunSkipped, at(4), nil}}}
a, err := m.ModuleEvents(3, "")
if err != nil || len(a.Events) != 3 || a.Events[0].Sequence != 4 || a.ByKind[EventApplied] != 1 || a.ByKind[EventRunSkipped] != 1 {
t.Errorf("last 3: %+v %v", a, err)
}
if a, err := m.ModuleEvents(5, EventApplied); err != nil || len(a.Events) != 2 || a.Events[0].Sequence != 3 {
t.Errorf("one kind: %+v %v", a, err)
}
if _, err := m.ModuleEvents(5, "config.nothing"); err == nil {
t.Error("an unknown kind was answered")
}
m.Log = staticLog{err: errors.New("no bus")}
if _, err := m.ModuleEvents(5, ""); err == nil {
t.Error("a stream that cannot be read was answered")
}
for subject, kind := range map[string]string{"zfs.snapshot.taken.person": EventTakenPerson, "snapshot.taken.schedule": EventTakenSchedule, "other": "other"} {
if KindOfSubject(subject) != kind {
t.Errorf("%s is %s", subject, KindOfSubject(subject))
}
}
}
// §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 := 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"}}}
a, err := m.CheckSnapshots(context.Background())
if err != nil || len(a.Gone) != 1 || a.Gone[0].Name != "mesh-hourly-20261009T0900Z" ||
len(a.Unrecorded) != 1 || a.Unrecorded[0] != "tank@mesh-hourly-20261009T1100Z" {
t.Errorf("%+v %v", a, err)
}
for _, c := range f.calls {
if strings.Contains(c, "destroy") || strings.Contains(c, " set ") || strings.Contains(c, "snapshot --") {
t.Errorf("a check changed something: %s", c)
}
}
m.Ledger = staticLedger{err: errors.New("no bus")}
if _, err := m.CheckSnapshots(context.Background()); err == nil {
t.Error("a ledger that cannot be read was answered")
}
}
// §27, §28: zfs_findings shows the follower's keys, none when every device is ONLINE.
func TestFindingsAreTheFollowersKeys(t *testing.T) {
f := newFake(t)
m := f.machine()
st := memStates{}
m.States = st
if a, err := m.FindingsNow(); err != nil || len(a.Findings) != 0 || !strings.Contains(a.Said[0], "No pool or device") {
t.Errorf("%+v %v", a, err)
}
st.Put(StateFindings, "pool.tank.sda", map[string]string{"summary": "sda is FAULTED"})
if a, err := m.FindingsNow(); err != nil || len(a.Findings) != 1 || a.Findings[0].Key != "pool.tank.sda" {
t.Errorf("%+v %v", a, err)
}
m.States = nil
if _, err := m.FindingsNow(); err == nil {
t.Error("no state, no answer")
}
}