Files
mesh-catalog/modules/zfs/cmd/zfs-tools/tools.go
T
jschoubben 58664eee60 zfs: the hand acts answer started at once and the manifest asks stream-publish, bus-state-calls and ledger-calls (A-43, A-44)
Removes streamHear, HandPoll, HandWait and the stream mark; keeps toolEmit.

Builds: design 42 zfs-owns-zfs §24, §25, lead answers A-43, A-44
2026-11-13 01:52:50 +01:00

346 lines
19 KiB
Go

package main
import (
"context"
"fmt"
"math"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools are the module's tools over one machine. Every one but zfs_snapshot only reads; zfs_snapshot only
// adds a snapshot. Nothing here destroys, rolls back, sets a property or sends: those change the
// operator's data and need a decision of their own.
func Tools(m *Machine) []stdio.Tool {
ctx := context.Background()
dataset := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
return []stdio.Tool{
{
Name: "zfs_datasets",
Description: "Every ZFS filesystem and volume on this machine: name, mountpoint, used, available and referenced " +
"(bytes), compression ratio, sharenfs, how many snapshots it has and the space they hold together " +
"(usedbysnapshots: what destroying all of them would free), each in a line under `said`. A machine " +
"without ZFS answers applicable false, with why. Replaces zfs list. (r)",
Run: func(map[string]any) (any, error) { return m.DatasetsNow(ctx) },
},
{
Name: "zfs_snapshots",
Description: "The snapshots of one dataset (exactly that one, not its children), or of every dataset when none is " +
"named, oldest first: name, creation time, used (the space only that snapshot holds: what destroying it " +
"alone would free) and referenced. Replaces zfs list -t snapshot. (r)",
Input: map[string]any{"dataset": dataset("the dataset, as zfs_datasets names it (optional: every snapshot when left out)")},
Run: func(a map[string]any) (any, error) { return m.SnapshotsOf(ctx, text(a, "dataset")) },
},
{
Name: "zfs_snapshot",
Description: "Take a snapshot dataset@name of one dataset, a person's act with why: refused for a dataset that is not " +
"on this machine, a name with anything but letters, digits, '.', '_' and '-', a snapshot that already exists, " +
"or no why. Only that dataset, never its children. Runs zfs snapshot -- dataset@name through sudo -n, then " +
"answers what zfs list shows: the snapshot made, its creation time, any other snapshot that came or went " +
"meanwhile, the dataset's free space and the pool's capacity. From then on the snapshot holds every block " +
"of the dataset deleted or overwritten, until it is destroyed by hand. A command that ran out of time is " +
"answered from the list, or as outcome unknown. A name beginning mesh- is refused (the scheduled run's). Recorded as the event snapshot.taken.person; ZFS keeps it in the " +
"pool's history too. It adds a snapshot and changes nothing else. Replaces zfs snapshot. (a)",
Input: map[string]any{
"dataset": dataset("the dataset, as zfs_datasets names it"),
"name": map[string]any{"type": "string", "description": "the snapshot's name, after the @: letters, digits, '.', '_' and '-'"},
"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"},
},
Run: func(a map[string]any) (any, error) {
if _, given := a["recursive"]; given {
return nil, fmt.Errorf("recursive does not exist in this version: zfs_snapshot snapshots one dataset, never its children")
}
ans, err := m.TakeSnapshot(ctx, text(a, "dataset"), text(a, "name"), text(a, "why"), stdio.CallerOf(a))
if err != nil {
if _, absentHere := err.(errAbsent); absentHere {
return NotApplicable{Applicable: false, Said: err.Error()}, nil
}
return nil, err
}
return ans, nil
},
},
handTool(m, "zfs_snapshots_run_now", ActSnapshots, "Take the scheduled ZFS snapshots now, as the scheduled run does (a person's act with why): writes the act into the state `schedule` and starts zfs-mesh-run.service through the service manager; the run takes it under the same lock and guards. Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"),
handTool(m, "zfs_snapshots_prune_now", ActPrune, "Remove the scheduled ZFS snapshots past their keep count now, as the scheduled run does, only those its own ledger holds (a person's act with why). Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"),
handTool(m, "zfs_config_apply", ActApply, "Converge the node to its config file now, as the apply does (a person's act with why): the same lock and guards as the apply. Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"),
{
Name: "zfs_snapshots_pause",
Description: "Pause the scheduled ZFS snapshots: writes `paused` {by, why, at} in the state `schedule`; the run then takes and removes nothing until zfs_snapshots_resume. (a)",
Input: map[string]any{"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}},
Run: func(a map[string]any) (any, error) {
if err := m.Pause(stdio.CallerOf(a), text(a, "why")); err != nil {
return nil, err
}
return map[string]any{"paused": true}, nil
},
},
{
Name: "zfs_snapshots_resume",
Description: "Resume the scheduled ZFS snapshots: deletes `paused` from the state `schedule`. (a)",
Run: func(map[string]any) (any, error) {
if err := m.Resume(); err != nil {
return nil, err
}
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
},
},
readTool("zfs_config", "Every value the module owns with its config value, what runs on the node, and how it stands: not managed, as set, "+
"not applied yet, waiting (on what, since when), refused (why), failed, or taking effect at the next load of the kernel module; the "+
"package's files the module never writes (with a .pacnew beside them) and what the module says before it leaves a node: the next load of "+
"the kernel module uses ZFS's defaults, the three units stay, /etc/hostid stays. Changes nothing. (r)", nil,
func(map[string]any) (any, error) { return m.ConfigNow(ctx) }),
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(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)",
map[string]any{"last": map[string]any{"type": "integer", "description": "how many to show (default 20, 1 to 500)"},
"kind": map[string]any{"type": "string", "description": "one kind of event (optional: every kind when left out)"}},
func(a map[string]any) (any, error) {
n, err := whole(a, "last", 20, 1, 500)
if err != nil {
return nil, err
}
return m.ModuleEvents(n, text(a, "kind"))
}),
readTool("zfs_snapshots_check", "Compares the ledger with what ZFS holds: names each ledger entry whose ZFS snapshot is gone and each ZFS snapshot "+
"named mesh- that the ledger does not hold. Changes nothing. (r)", nil,
func(map[string]any) (any, error) { return m.CheckSnapshots(ctx) }),
readTool("zfs_findings", "The event follower's findings: one key for each pool or device that is not ONLINE, deleted when it is ONLINE again or "+
"leaves the pool. Changes nothing. (r)", nil,
func(map[string]any) (any, error) { return m.FindingsNow() }),
{
Name: "zfs_pools",
Description: "Every imported pool's health in a line: its state (ONLINE, DEGRADED, FAULTED, …) with status's own " +
"explanation and action, size, allocated and free (bytes), capacity and fragmentation %, the error count of " +
"its devices, its data errors and its last scan. Replaces zpool list and zpool status -x. (r)",
Run: func(map[string]any) (any, error) { return m.PoolsNow(ctx) },
},
{
Name: "zfs_space",
Description: "Where the space goes: each pool's size, allocated, free, capacity % and fragmentation %; then every " +
"dataset, biggest first, with what uses it (usedbydataset, usedbysnapshots, usedbychildren, " +
"usedbyrefreservation), its quota, refquota, reservation and refreservation where set, the compression and " +
"its ratio, the logical size against the physical and what compression saves. Replaces zfs list -o space " +
"and zpool list -v. (r)",
Run: func(map[string]any) (any, error) { return m.Space(ctx) },
},
{
Name: "zfs_pool_layout",
Description: "Each pool's layout as status gives it: its vdevs (mirror, raidz, draid) and their disks by the name the " +
"pool knows them by (by-id where it was imported so), each with its state and its read, write and checksum " +
"error counts and status's note (resilvering, was /dev/…); then the log, cache, special and dedup devices " +
"and the spares. Replaces zpool status and zpool status -v. (r)",
Run: func(map[string]any) (any, error) { return m.Layout(ctx) },
},
{
Name: "zfs_scrub",
Description: "Each pool's scans as status gives them: its scrub (or error scrub) and its resilver, the pool-wide one or " +
"each vdev's rebuild; when each started and ended, how long it took, what it repaired and the errors it " +
"found, or, while one runs, its progress and the time to go; and how many days since the last finished " +
"scrub, with a WARNING past what the schedule allows. Then the module's scrub schedule: the pool, the " +
"cadence, the day and time, its timer's state and the next run, and how the last run ended. With history " +
"true, also each pool's history (zpool history -i, as root through sudo -n) for the last finished scrub and " +
"resilver status no longer shows. Replaces zpool history -i. (r)",
Input: map[string]any{"history": map[string]any{"type": "boolean", "description": "true to read each pool's history too (default false)"}},
Run: func(a map[string]any) (any, error) {
h, err := flag(a, "history")
if err != nil {
return nil, err
}
return m.Scrubs(ctx, h)
},
},
scrubAct("start", "Start a scrub of one pool, or resume a paused one: zpool scrub -- pool through sudo -n, a person's "+
"act with why. A scrub reads every block and rewrites from redundancy only a block it finds damaged; it adds "+
"I/O load for its whole length (hours to days on a large pool) and never changes what a file holds. "+
"Answers what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub. (a)", m),
scrubAct("pause", "Pause one pool's running scrub, keeping its place: zpool scrub -p -- pool through sudo -n, with why; "+
"zfs_scrub_start resumes it. Answers what status says afterwards; recorded as the event scrub.requested. "+
"Replaces zpool scrub -p. (a)", m),
scrubAct("stop", "Stop one pool's scrub, losing its place: zpool scrub -s -- pool through sudo -n, with why. Answers "+
"what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub -s. (a)", m),
{
Name: "zfs_io",
Description: "A sample of every pool's I/O over a few seconds (zpool iostat -v, the report since boot left out): " +
"read and write operations per second and bytes per second per pool, vdev and device, with each " +
"vdev's allocated and free. Replaces zpool iostat and zpool iostat -v. (r)",
Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, 1 to 10)"}},
Run: func(a map[string]any) (any, error) {
n, err := whole(a, "seconds", 3, 1, 10)
if err != nil {
return nil, err
}
return m.IO(ctx, n)
},
},
{
Name: "zfs_arc",
Description: "The ARC, ZFS's cache in memory, since the machine started, from /proc/spl/kstat/zfs/arcstats: its " +
"size, target, minimum and maximum (bytes), hits, misses and hit ratio %, the ratio for what was asked " +
"for alone (not read ahead), and the L2ARC on a cache device where there is one. Replaces arcstat, " +
"arc_summary and cat /proc/spl/kstat/zfs/arcstats. (r)",
Run: func(map[string]any) (any, error) { return m.Arc() },
},
{
Name: "zfs_events",
Description: "ZFS's recent events, newest first, as root through sudo -n: by default only errors (I/O, checksum and " +
"device errors) and changes of state (a device's state, a scrub or resilver started or finished, a pool " +
"imported), each with its time, class, pool, device and state; all true gives every event. ZFS keeps " +
"a bounded number since its module loaded. Replaces zpool events and zpool events -v. (r)",
Input: map[string]any{
"all": map[string]any{"type": "boolean", "description": "true for every event, not only errors and changes of state (default false)"},
"limit": map[string]any{"type": "integer", "description": "how many to show (default 50, at most 500)"},
},
Run: func(a map[string]any) (any, error) {
all, err := flag(a, "all")
if err != nil {
return nil, err
}
n, err := whole(a, "limit", 50, 1, 500)
if err != nil {
return nil, err
}
return m.Events(ctx, all, n)
},
},
{
Name: "zfs_properties",
Description: "Every property of one dataset with where its value comes from (local, default, inherited from …): " +
"compression, recordsize, atime, sync, xattr, sharenfs, sharesmb, mountpoint, readonly, encryption, " +
"keystatus, keyformat and encryptionroot and the rest, the usual ones said first in lines. Never a key: " +
"keylocation's value is withheld. Replaces zfs get all. (r)",
Input: map[string]any{"dataset": dataset("the dataset, as zfs_datasets names it")},
Run: func(a map[string]any) (any, error) { return m.PropertiesOf(ctx, text(a, "dataset")) },
},
{
Name: "zfs_health",
Description: "Whether this machine's ZFS is healthy, as the module's health check (novox/hq ADR 0240). Unhealthy " +
"for a fault now: a pool not ONLINE (with status's reason and action), a device not online, a device's " +
"error counts that rose since the last look (unhealthy for 15 minutes after the rise), data errors, a " +
"resilver that finished with errors, a pool more than 90 % full, no pool imported, or ZFS not on this " +
"machine. Warnings leave it healthy: error counts from before, to clear by hand once understood; a pool " +
"more than 80 % full; a resilver running; a last scrub later than its schedule allows (or 35 days without " +
"one); a scheduled scrub that failed, or a schedule whose timer does not run. With why, one sentence per " +
"finding. (r)",
Run: func(map[string]any) (any, error) { return m.Health(ctx), nil },
},
}
}
// scrubAct is one of the scrub's acts as a tool.
func scrubAct(act, description string, m *Machine) stdio.Tool {
return stdio.Tool{
Name: "zfs_scrub_" + act,
Description: description,
Input: map[string]any{
"pool": map[string]any{"type": "string", "description": "the pool, as zfs_pools names it"},
"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"},
},
Run: func(a map[string]any) (any, error) {
ans, err := m.ScrubAct(context.Background(), act, text(a, "pool"), text(a, "why"))
if err != nil {
if _, absentHere := err.(errAbsent); absentHere {
return NotApplicable{Applicable: false, Said: err.Error()}, nil
}
return nil, err
}
return ans, nil
},
}
}
func text(a map[string]any, key string) string {
s, _ := a[key].(string)
return strings.TrimSpace(s)
}
// flag is a yes-or-no argument: true only when given as true; anything that is not a yes or a no is
// refused rather than read as no.
func flag(a map[string]any, key string) (bool, error) {
switch v := a[key].(type) {
case nil:
return false, nil
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true":
return true, nil
case "false", "":
return false, nil
}
return false, fmt.Errorf("%s is true or false, not %q", key, v)
}
return false, fmt.Errorf("%s is true or false", key)
}
// whole is a whole-number argument, defaulted, refused outside least..most.
func whole(a map[string]any, key string, fallback, least, most int) (int, error) {
v, given := a[key]
if !given || v == nil {
return fallback, nil
}
var n int
switch x := v.(type) {
case float64:
if x != math.Trunc(x) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, x)
}
n = int(x)
case string:
i, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, x)
}
n = i
default:
return 0, fmt.Errorf("%s must be a whole number", key)
}
if n < least || n > most {
return 0, fmt.Errorf("%s must be from %d to %d, not %d", key, least, most, n)
}
return n, nil
}
// handTool is one hand act as a tool.
func handTool(m *Machine, name, act, description string) stdio.Tool {
return stdio.Tool{
Name: name,
Description: description,
Input: map[string]any{"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}},
Run: func(a map[string]any) (any, error) {
return m.HandAct(context.Background(), act, stdio.CallerOf(a), text(a, "why"))
},
}
}
// readTool is one of the module's own read tools.
func readTool(name, description string, input map[string]any, run func(map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: name, Description: description, Input: input, Run: run}
}