Files
mesh-catalog/modules/zfs/cmd/zfs-tools/machine.go
T
jochen e69506a26f
mesh/merge-gate pass: builds zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Make the zfs module safe to put on the home server, and give it the pool's scrub schedule
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.
2026-10-09 01:17:34 +02:00

252 lines
8.5 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
// The machine as the module sees it.
//
// **It runs fixed commands, never a shell.** Every command is a program and an argument list built here;
// nothing an argument says is ever read by a shell. The tools run as the operator account (novox/hq ADR
// 0175 §4). What ZFS lets any account read — `zfs list`, `zfs get`, `zpool list`, `zpool status`,
// `zpool iostat` — runs as that account. What ZFS keeps for root — `zpool events`, `zpool history` and
// `zfs snapshot` — goes through `sudo -n`, as the packet filter's, the service manager's and nfs-server's
// tools do: without a prompt, so a refusal is said rather than waited on.
// CommandTimeout bounds every command but a sample.
const CommandTimeout = 30 * time.Second
// KillGrace is how long a command that ran out of time is given to end on SIGTERM before its whole
// process group is killed.
var KillGrace = 5 * time.Second
// Ran is what one command did.
type Ran struct {
Out string
Err string
Code int
// TimedOut is a command that did not answer within its time and was ended: what it did is not known
// from its answer, only from reading the machine again.
TimedOut bool
}
// Runner runs one command; injected, so every tool is tested over recorded answers.
type Runner func(ctx context.Context, name string, args ...string) Ran
// ExecRunner runs a command on the machine, bounded by the context (CommandTimeout unless the caller set
// one).
//
// **A command that runs out of time is ended with everything it started.** It runs in a process group of
// its own; at the deadline the group is sent SIGTERM, and after KillGrace SIGKILL. Under sudo the command
// itself runs as root, which this account cannot signal: SIGTERM reaches sudo, which passes it on to the
// command, and the group's SIGKILL ends sudo. The output pipes are closed after KillGrace whatever still
// holds them, so the call always returns.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
if _, has := ctx.Deadline(); !has {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, CommandTimeout)
defer cancel()
}
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = KillGrace
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Start(); err != nil {
return Ran{Code: -1, Err: err.Error()}
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
timedOut := false
select {
case err = <-done:
case <-ctx.Done():
timedOut = true
pgid := cmd.Process.Pid
_ = syscall.Kill(-pgid, syscall.SIGTERM)
select {
case err = <-done:
case <-time.After(KillGrace):
_ = syscall.Kill(-pgid, syscall.SIGKILL)
err = <-done
}
// Anything of the group still alive and signalable goes with it.
_ = syscall.Kill(-pgid, syscall.SIGKILL)
}
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String()), TimedOut: timedOut}
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) && !timedOut {
r.Code = exit.ExitCode()
} else {
r.Code = -1
if r.Err == "" {
r.Err = err.Error()
}
}
}
if timedOut {
r.Code = -1
r.Err = strings.TrimSpace(fmt.Sprintf("did not answer within its time and was ended. %s", r.Err))
}
return r
}
// Machine is what the module reads and the one thing it does.
type Machine struct {
Run Runner
// UID is this process's account: root runs a command as it is, any other account through sudo -n.
UID int
Now func() time.Time
// ReadFile reads a kernel file (the ARC's counters).
ReadFile func(path string) ([]byte, error)
// Emit publishes an event as this module (the record of an act); nil where nothing can be published.
Emit func(eventType string, body any) error
// Errors is what the health check last saw of each device's error counts; nil judges every count
// as past.
Errors *ErrorMemory
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ReadFile: os.ReadFile, Errors: NewErrorMemory()}
}
// NotApplicable is the answer of a machine without ZFS: the module does not apply here.
type NotApplicable struct {
Applicable bool `json:"applicable"`
Said string `json:"said"`
}
// absent says why ZFS cannot be asked on this machine, from a command's failure; "" when the failure is
// something else.
func absent(r Ran) string {
switch {
case r.Code == -1 && (strings.Contains(r.Err, "executable file not found") || strings.Contains(r.Err, "no such file or directory")):
return "ZFS is not installed on this machine (there is no zfs or zpool command), so this module does not apply here"
case strings.Contains(r.Err, "/dev/zfs") || strings.Contains(r.Err, "ZFS modules cannot be auto-loaded") ||
strings.Contains(r.Err, "modprobe zfs"):
return "ZFS's kernel module is not loaded on this machine (" + firstLine(r.Err) + "), so there is no pool to read"
}
return ""
}
// errAbsent is the error a tool answers when ZFS is not here.
type errAbsent struct{ why string }
func (e errAbsent) Error() string { return e.why }
// errTimedOut is an act that did not answer within its time and was ended: whether it took effect is
// known only by reading the machine again.
type errTimedOut struct{ what string }
func (e errTimedOut) Error() string {
return e.what + " did not answer within its time and was ended; whether it took effect is not known"
}
// ErrorMemory is what the health check last saw of each device's error counts, so a count that rises is
// told from one that was there before (a fault now, against a fault past).
type ErrorMemory struct {
mu sync.Mutex
counts map[string][3]uint64
rose map[string]time.Time
}
// NewErrorMemory remembers nothing yet: every count on the first look is past.
func NewErrorMemory() *ErrorMemory {
return &ErrorMemory{counts: map[string][3]uint64{}, rose: map[string]time.Time{}}
}
// ask runs a read-only command as this account and answers its output, or why it could not.
func (m *Machine) ask(ctx context.Context, name string, args ...string) (string, error) {
r := m.Run(ctx, name, args...)
if r.Code != 0 {
if why := absent(r); why != "" {
return "", errAbsent{why}
}
return "", fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, firstLine(orOut(r)))
}
return r.Out, nil
}
// Escalated is a command as it is run: as given when this process is root, else through sudo without a
// prompt.
func (m *Machine) Escalated(name string, args ...string) (string, []string) {
if m.UID == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// AsRoot runs what only root may do, and names a refusal by how it failed.
func (m *Machine) AsRoot(ctx context.Context, name string, args ...string) (Ran, error) {
program, argv := m.Escalated(name, args...)
r := m.Run(ctx, program, argv...)
if r.TimedOut {
return r, errTimedOut{name + " " + strings.Join(args, " ")}
}
if r.Code != 0 {
why := orOut(r)
if why2 := absent(r); why2 != "" && program != "sudo" {
return r, errAbsent{why2}
}
if program == "sudo" && (strings.Contains(why, "a password is required") || strings.Contains(why, "not allowed") ||
strings.Contains(why, "may not run sudo")) {
return r, fmt.Errorf("%s needs root, and sudo refused the operator account without a prompt: %s", name, firstLine(why))
}
if program == "sudo" && strings.Contains(why, "command not found") {
return r, errAbsent{"ZFS is not installed on this machine (sudo found no " + name + " command), so this module does not apply here"}
}
return r, fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, firstLine(why))
}
return r, nil
}
func orOut(r Ran) string {
if strings.TrimSpace(r.Err) != "" {
return r.Err
}
return strings.TrimSpace(r.Out)
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// bytesSaid says a size for a person: 1.2 TiB, 512 MiB, 900 B.
func bytesSaid(n uint64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
f := float64(n)
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%d B", n)
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
// round1 rounds to one decimal, for what a person reads.
func round1(f float64) float64 {
if f < 0 {
return -float64(int64(-f*10+0.5)) / 10
}
return float64(int64(f*10+0.5)) / 10
}