Files
mesh-catalog/modules/zfs/cmd/zfs-tools/tools.go
T
jschoubben c0067e77fc Z15 zfs-tools: zfs_config, zfs_snapshots_schedule, zfs_module_events, zfs_snapshots_check and zfs_findings read what the module owns, keeps and says
zfs_config also says what the module says before it leaves a node (Z17): the next load of the kernel module uses ZFS's defaults, the three units and /etc/hostid stay.

Builds: design 42 zfs-owns-zfs §28, §30, §10, A-31, A-35
2026-11-13 00:54:06 +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. Waits up to 30 seconds for the act's event, else answers started 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). Waits up to 30 seconds for the act's event, else answers started 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. Waits up to 30 seconds for the act's event, else answers started 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() }),
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}
}