The safety review of the first version found it safe for the pool's data and asked for these fixes before it goes on the home server: - zfs_snapshot: no recursive. Every answer gives the dataset's free space and the pool's capacity, and says that the snapshot holds every block deleted or overwritten until it is destroyed by hand. - A command that runs out of time is ended with its whole process group. An act that timed out is answered from the list, or as "outcome unknown", never as "NOT DONE". - The watch runs as the operator account. - The event follower reads ZFS's queue from its start, with a cursor per boot, so the pool's import and any downtime reach the journal once. It writes "dropped N events", kills zpool before waiting for it, and exits non-zero after five failed follows in a row. - Past error counts are a warning, and counts that rise are unhealthy. - The guard test catches .Run(, exec.Command(, exec.CommandContext( and process starts. - The fixtures and the parser follow OpenZFS 2.4.4's own printers: human scan sizes under -p, no iostat section lines, error scrubs, per-vdev rebuild resilvers, paused scrubs. The operator also asked the module to own the scrub. A timer and a service of the module's own run zpool scrub -w -- <pool> from the scrub-pool, scrub-cadence, scrub-day and scrub-time settings. In the same declaration, the distribution's weekly and monthly timers for that pool are held stopped and disabled, so exactly one schedule scrubs it. zfs_scrub shows the schedule and the next run, with any resilver's progress. zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop act with why. The health check warns of a late or failed scrub and of a running resilver, and is unhealthy for a resilver that finished with errors.
258 lines
13 KiB
Go
258 lines
13 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. Recorded as the event snapshot.taken; 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"))
|
|
if err != nil {
|
|
if _, absentHere := err.(errAbsent); absentHere {
|
|
return NotApplicable{Applicable: false, Said: err.Error()}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return ans, nil
|
|
},
|
|
},
|
|
{
|
|
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
|
|
}
|