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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 " +
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"zfs_config_apply",
|
||||
"zfs_snapshots_pause",
|
||||
"zfs_snapshots_resume",
|
||||
"zfs_snapshots_record",
|
||||
"zfs_io",
|
||||
"zfs_arc",
|
||||
"zfs_events",
|
||||
|
||||
Reference in New Issue
Block a user