Files
mesh-catalog/modules/zfs/cmd/zfs-tools/health.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

222 lines
7.9 KiB
Go

package main
import (
"context"
"fmt"
"strings"
"time"
)
// The lines a pool's capacity is judged against: past CapacityWarn a warning, past CapacityFail
// unhealthy. ZFS slows as a pool fills, and a full pool can refuse even a deletion.
const (
CapacityWarn = 80
CapacityFail = 90
)
// Health is the module's health, as the node-engine asks it (novox/hq ADR 0240): unhealthy with why, one
// sentence per finding; warnings do not make it unhealthy.
type Health struct {
Healthy bool `json:"healthy"`
Why string `json:"why"`
Found []string `json:"found,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// ErrorsFresh is how long a rise in a device's error counts keeps the health unhealthy: long enough for
// the node-engine's two looks to see it, and a condition to be raised.
const ErrorsFresh = 15 * time.Minute
// Look is what one health check read.
type Look struct {
Pools []Pool
Status []PoolStatus
Now time.Time
Schedule *Schedule
// ScheduleErr is a schedule whose settings are not valid.
ScheduleErr error
// Served is the last slot the scheduled run served, per pool.
Served map[string]int64
}
// errorFinding judges one line of status's error counts against the last look: a count that rose is a
// fault now; a count that was there before is past, a warning to clear by hand once understood.
func (mem *ErrorMemory) errorFinding(pool string, d *Device, now time.Time) (fault, past string) {
key := pool + "/" + d.Kind + "/" + d.Name
counts := [3]uint64{d.Read, d.Write, d.Checksum}
names := [3]string{"read", "write", "checksum"}
if mem == nil {
mem = NewErrorMemory()
}
mem.mu.Lock()
defer mem.mu.Unlock()
before, seen := mem.counts[key]
mem.counts[key] = counts
var rose []string
for i := range counts {
if seen && counts[i] > before[i] {
rose = append(rose, fmt.Sprintf("%s errors rose from %d to %d", names[i], before[i], counts[i]))
}
}
if len(rose) > 0 {
mem.rose[key] = now
return fmt.Sprintf("%s %s in pool %s: %s since the last look.", d.Kind, d.Name, pool, strings.Join(rose, ", ")), ""
}
if at, ok := mem.rose[key]; ok && now.Sub(at) < ErrorsFresh {
return fmt.Sprintf("%s %s in pool %s: its errors rose at %s (read %d, write %d, checksum %d).", d.Kind, d.Name, pool,
at.Local().Format("15:04"), d.Read, d.Write, d.Checksum), ""
}
if d.Errors() == 0 {
return "", ""
}
var parts []string
for i := range counts {
if counts[i] > 0 {
parts = append(parts, fmt.Sprintf("%d past %s errors", counts[i], names[i]))
}
}
return "", fmt.Sprintf("%s %s in pool %s has %s; clear them by hand once understood.", d.Kind, d.Name, pool, strings.Join(parts, " and "))
}
// Judge judges the pools from what one look read. Unhealthy is a fault now: a pool or device not online,
// error counts that rose, data errors, a pool past CapacityFail, a resilver that finished with errors.
// A warning is a past fault or a risk: error counts that were there before, a pool past CapacityWarn, a
// resilver running, a scrub later than its schedule allows, a schedule that failed or does not run.
func (mem *ErrorMemory) Judge(l Look) Health {
var found, warn []string
if len(l.Pools) == 0 {
found = append(found, "ZFS is on this machine and no pool is imported.")
}
if l.ScheduleErr != nil {
warn = append(warn, sentence("the scrub schedule's settings are not valid: "+l.ScheduleErr.Error()))
}
for _, p := range l.Pools {
s := statusOf(l.Status, p.Name)
state := p.Health
if s != nil && s.State != "" {
state = s.State
}
if state != "ONLINE" {
line := fmt.Sprintf("Pool %s is %s", p.Name, state)
if s != nil && s.Status != "" {
line += ": " + strings.TrimSuffix(s.Status, ".")
}
if s != nil && s.Action != "" {
line += ". Action: " + strings.TrimSuffix(s.Action, ".")
}
found = append(found, line+".")
}
switch {
case p.Capacity > CapacityFail:
found = append(found, fmt.Sprintf("Pool %s is %d %% full, past %d %%: %s free.", p.Name, p.Capacity, CapacityFail, bytesSaid(p.Free)))
case p.Capacity > CapacityWarn:
warn = append(warn, fmt.Sprintf("Pool %s is %d %% full, past %d %%: %s free.", p.Name, p.Capacity, CapacityWarn, bytesSaid(p.Free)))
}
if s == nil {
found = append(found, fmt.Sprintf("zpool status said nothing of pool %s.", p.Name))
continue
}
walk(s.Root, func(d *Device, _ int) {
if d.Kind == "section" {
return
}
fault, past := mem.errorFinding(p.Name, d, l.Now)
if fault != "" {
found = append(found, sentence(fault))
}
if past != "" {
warn = append(warn, sentence(past))
}
if d.Kind != "pool" && d.State != "" && d.State != "ONLINE" && d.State != "AVAIL" && d.State != "INUSE" {
// A device not online already makes its pool DEGRADED or worse; said here by name.
found = append(found, fmt.Sprintf("%s %s in pool %s is %s.", d.Kind, d.Name, p.Name, d.State))
}
}, 0)
if e := strings.TrimSpace(s.Errors); e != "" && e != "No known data errors" {
found = append(found, fmt.Sprintf("Pool %s: %s.", p.Name, strings.TrimSuffix(e, ".")))
}
for _, r := range s.Resilvers() {
what := "the resilver"
if r.Vdev != "" {
what += " of " + r.Vdev
}
switch {
case r.State == "running":
line := fmt.Sprintf("A resilver")
if r.Vdev != "" {
line += " of " + r.Vdev
}
line += fmt.Sprintf(" in pool %s is running", p.Name)
if r.Done != nil {
line += fmt.Sprintf(": %.2f %% done", *r.Done)
}
if r.ToGo != "" {
line += ", " + r.ToGo + " to go"
}
warn = append(warn, sentence(strings.ToLower(line[:1])+line[1:]))
case r.State == "finished" && deref(r.Errors) > 0:
found = append(found, sentence(fmt.Sprintf("%s in pool %s finished with %d errors, %s", what, p.Name, deref(r.Errors), stamp(r.Ended))))
}
}
late, by := ScrubLate(p.Name, l.Schedule)
ended := LastScrubEnded(*s)
switch {
case ended != nil && l.Now.Sub(*ended) > late:
warn = append(warn, fmt.Sprintf("Pool %s was last scrubbed %.0f days ago, more than %s (%d days).", p.Name,
l.Now.Sub(*ended).Hours()/24, by, int(late.Hours()/24)))
case len(s.Scans) == 0:
warn = append(warn, fmt.Sprintf("Pool %s was never scrubbed.", p.Name))
}
if l.Schedule != nil && l.Schedule.Pool == p.Name {
warn = append(warn, scheduleFindings(l.Schedule, s, l.Now, l.Served)...)
}
}
if l.Schedule != nil && statusOf(l.Status, l.Schedule.Pool) == nil {
warn = append(warn, fmt.Sprintf("The scrub schedule names pool %s, which is not imported here.", l.Schedule.Pool))
}
mem.save()
if len(found) > 0 {
return Health{Healthy: false, Why: strings.Join(found, " "), Found: found, Warnings: warn}
}
var parts []string
for _, p := range l.Pools {
parts = append(parts, fmt.Sprintf("%s ONLINE, %d %% full", p.Name, p.Capacity))
}
why := "Every pool is online with no new errors: " + strings.Join(parts, "; ") + "."
if len(warn) > 0 {
why += " Warnings: " + strings.Join(warn, " ")
}
return Health{Healthy: true, Why: why, Warnings: warn}
}
// Health reads the pools and judges them. A machine without ZFS is unhealthy, loudly: the module was put
// where it does not apply.
func (m *Machine) Health(ctx context.Context) Health {
pools, err := m.Pools(ctx)
if err != nil {
if a, ok := err.(errAbsent); ok {
return Health{Healthy: false, Why: sentence(a.why) + " Unassign the module from this machine.", Found: []string{a.why}}
}
return Health{Healthy: false, Why: sentence("zpool list did not answer: " + err.Error()), Found: []string{err.Error()}}
}
status, err := m.Status(ctx)
if err != nil {
return Health{Healthy: false, Why: sentence("zpool status did not answer: " + err.Error()), Found: []string{err.Error()}}
}
sched, serr := m.ScheduleNow(ctx)
return m.Errors.Judge(Look{Pools: pools, Status: status, Now: m.Now(), Schedule: sched, ScheduleErr: serr,
Served: m.readServed(ServedFile)})
}
func sentence(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
s = strings.ToUpper(s[:1]) + s[1:]
if !strings.HasSuffix(s, ".") {
s += "."
}
return s
}