Z11 zfs-snapshots-take: the run takes its due ZFS snapshots per filesystem and records each in the ledger

Builds: design 42 zfs-owns-zfs §21, §26, A-19, A-20, A-30, A-31
This commit is contained in:
2026-11-13 00:43:09 +01:00
parent 316039ca95
commit a357a85faa
9 changed files with 532 additions and 4 deletions
+42
View File
@@ -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
}
+2
View File
@@ -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:<seat>.<verb>@<node>`, `<module>.<tool>`); nil where
// this process reaches none.
Call func(key string, body any) (json.RawMessage, error)
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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") {
+26
View File
@@ -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
}
+275
View File
@@ -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 --
// <dataset>@mesh-<kind>-<UTC stamp>`, never recursive, sets `mesh:schedule=<kind>` 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/<kind>-<UTC stamp>-<dataset>`, 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-<kind>-<UTC stamp>): 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)
}
+162
View File
@@ -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)
}
}
+20
View File
@@ -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-<kind>-<UTC stamp>: 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 " +
+1
View File
@@ -64,6 +64,7 @@
"zfs_config_apply",
"zfs_snapshots_pause",
"zfs_snapshots_resume",
"zfs_snapshots_record",
"zfs_io",
"zfs_arc",
"zfs_events",