Files
mesh-catalog/modules/zfs/cmd/zfs-tools/machine.go
T
jochen bd0182785c
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
Run the scrub schedule as a scheduled process, never pause a scrub, and keep the pool's name the only setting in a unit
The second review of #147 found three blockers before the home server.

1. ${setting:} was not filled in a unit's name, so the distribution timers would have reached the
   machine as "zfs-scrub-weekly@${setting:scrub-pool}.timer". mesh-controller #171 fills it and refuses
   any value that is not a unit-name part. The module's own schedule needs no setting in a name now: the
   node-engine's timer for the scheduled process zfs-mesh-scrub fires `zfs-tools scrub-run` every quarter
   of an hour, and the run reads the pool, cadence, day and time from the configuration file.
2. The run does what upstream's zfs-scrub@.service does: if a scrub is running it waits for it with
   zpool wait -t scrub, otherwise it starts one and waits. It never pauses or stops a scrub, and the unit
   has no ExecStop. A resilver defers the scrub. The served slot is kept in /var/lib/zfs-tools.
3. Unassigning left the pool with no schedule. mesh-host #66 (hq ADR 0269, proposed) gives the
   distribution timer back running and enabled. The README says not to unassign before it is merged.

Also: the run comes before the distribution timers in the declaration; the day and time are checked
against fixed sets and reach no unit file (an invalid value runs nothing); a due scrub not started an
hour after its time is a warning; the error counts outlive a restart in a small state file.
2026-10-09 01:40:02 +02:00

299 lines
9.6 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"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: LoadErrorMemory(ErrorsFile)}
}
// 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
path string
}
// 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{}}
}
// ErrorsFile is where the error counts last seen are kept, so a restart of the bundle does not take every
// count as past again.
const ErrorsFile = "/var/lib/zfs-tools/errors.json"
type keptErrors struct {
Counts map[string][3]uint64 `json:"counts"`
Rose map[string]time.Time `json:"rose"`
}
// LoadErrorMemory is the memory kept in path; an empty one when there is none, and either way it is kept
// there from now on.
func LoadErrorMemory(path string) *ErrorMemory {
mem := NewErrorMemory()
mem.path = path
if b, err := os.ReadFile(path); err == nil {
var k keptErrors
if json.Unmarshal(b, &k) == nil {
if k.Counts != nil {
mem.counts = k.Counts
}
if k.Rose != nil {
mem.rose = k.Rose
}
}
}
return mem
}
// save keeps the memory, atomically; a failure only costs the next restart its memory.
func (mem *ErrorMemory) save() {
if mem == nil || mem.path == "" {
return
}
mem.mu.Lock()
b, err := json.Marshal(keptErrors{Counts: mem.counts, Rose: mem.rose})
mem.mu.Unlock()
if err != nil {
return
}
tmp := mem.path + ".new"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, mem.path)
}
}
// 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
}