Files
mesh-catalog/modules/zfs/cmd/zfs-tools/machine.go
T
jochen 7c8497225d
mesh/merge-gate pass: builds new: modules/zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded (0.00s)
mesh/delivery superseded: a newer head of the same pull request
Add the zfs module, so the mesh can see the home server's pools and snapshot a dataset
The home server's pool holds the operator's media library, and no mesh tool could say whether it is
healthy, how full it is, when it was last scrubbed or what snapshots exist; the only way was a login.
This module reads all of it with fixed zfs and zpool commands (list, get, status, iostat, events,
history) and has one act, zfs_snapshot, which only adds a snapshot: refused for an unknown dataset, a
name outside [A-Za-z0-9._-], an existing snapshot or no why; run as sudo -n zfs snapshot -- ds@name;
answered from zfs list afterwards, with any other snapshot that came or went meanwhile; recorded as the
event snapshot.taken. No destroy, rollback, set or send exists, and a test holds every command to its
verb.

Health (ADR 0240): zfs_health on the watch process, unhealthy for a pool not ONLINE, device or data
errors, a pool past 90 % full or no ZFS at all; past 80 % and a scrub older than 35 days are warnings.
Beside it, the event follower (zpool events -f, as root) keeps ZFS's errors in its journal past a
reboot and is judged by its unit, the check the module does not run itself. No seat (ADR 0121), no
package: ZFS is the machine's.
2026-10-09 00:11:31 +02:00

187 lines
6.0 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"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
// Ran is what one command did.
type Ran struct {
Out string
Err string
Code int
}
// 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).
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.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())}
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else {
r.Code = -1
if r.Err == "" {
r.Err = err.Error()
}
}
if ctx.Err() != nil {
r.Code = -1
r.Err = fmt.Sprintf("did not answer within its time (%v)", ctx.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
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ReadFile: os.ReadFile}
}
// 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 }
// 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.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
}