Files
selfpost/internal/health/machine.go
T
mixandClaude Opus 5.5 ce363f48dd logtail, health, panel: cite the source of what they rely on (assumptions part A)
Comments only, no behaviour changed. Each place the log tailer, Health and
cmd/panel rely on another program now says where that is written, for the
version the image runs: Postfix 3.7.11 (log_adhoc(), msg_logger_print(),
smtpd_whatsup(), cleanup_milter.c, qmgr_active.c, postlogd.c, showq.c,
pipe.c), logrotate 3.21.0, supervisor 4.2.5, proc(5). Measurements stay
measurements and say where and when; test fixtures say whether they were
seen or composed.

Where the source contradicts the code the comment does not bless it but
names the register entry (A-8 expiry, A-12 the ISO stamp no Postfix writes,
A-15 the last recipient, A-21/A-23 sasl_username, A-33 postlogd's old
inode, A-38 a socket file is not a listener, A-65 the certificate served);
those are fixed in their own commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:11 +03:00

643 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package health
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// machineSampleWindow is the longest gap between two readings that still counts
// as "now". CPU busy time and network throughput are differences between two
// samples, and the status fragment polls every five seconds — but a page opened
// after the panel sat idle for an hour would otherwise average that whole hour
// and present it as the current load. Past this the reading only re-baselines,
// and the rates say they are still being measured.
const machineSampleWindow = time.Minute
// Thresholds for the two resources whose exhaustion actually threatens mail: a
// fully busy processor slows queue processing, and a machine out of memory has
// its processes killed. Network throughput has no comparable threshold — what
// counts as a lot depends entirely on the link — so it is reported, not graded.
const (
cpuWarnPct = 90.0
memWarnPct = 90.0
memErrorPct = 97.0
)
// Machine is the resource usage of the machine this container runs on: the
// status page's answer to "is the server itself under strain", next to the
// component checks that answer "is mail flowing".
//
// The numbers come from the kernel's /proc filesystem. /proc/stat and
// /proc/meminfo are system-wide and no namespace changes them (proc(5):
// "kernel/system statistics", "statistics about memory usage on the
// system"), so CPU and memory describe the host, while /proc/net/dev
// describes whatever network namespace the container is in
// (network_namespaces(7) names /proc/net among what a network namespace
// isolates) - its own veth under the default bridge, the host's interfaces
// under network_mode: host.
type Machine struct {
CPU CPU
Memory Memory
Network Network
// Window is the interval the rates were measured over; zero until a
// second reading exists.
Window time.Duration
Status Status
}
// WindowText names the sampling interval for the card's description.
func (m Machine) WindowText() string {
if m.Window <= 0 {
return ""
}
return m.Window.Round(time.Second).String()
}
// CPU is processor load over the sampling window.
type CPU struct {
// Measured is false until two readings exist to compare; BusyPct means
// nothing until it is true.
Measured bool
BusyPct float64
// Cores and Threads are physical cores and logical CPUs (SMT). Both come
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
// the cpuN lines in /proc/stat and Cores matches Threads.
Cores int
Threads int
// Load is the 1/5/15-minute load average, present when /proc/loadavg
// could be read. Unlike BusyPct it needs no previous sample. It is kept
// for the sampler but is no longer shown on the Status card.
Load [3]float64
HasLoad bool
Status Status
Detail string
}
// Percent is BusyPct as a whole number, for the <meter> element's value
// attribute. The bar carries its value in an attribute rather than a width in a
// style attribute because the panel's CSP has no inline-style exemption
// (security.md).
func (c CPU) Percent() int { return percent(c.BusyPct) }
// BusyText is the reading as it appears beside the bar.
func (c CPU) BusyText() string { return fmt.Sprintf("%d%%", c.Percent()) }
// Memory is main memory (and swap, where the machine has any) at the moment of
// the reading. Unlike CPU and network it is a level, not a rate, so a single
// reading is enough and it is never in the "measuring" state.
type Memory struct {
Measured bool
TotalBytes uint64
AvailableBytes uint64
UsedBytes uint64
UsedPct float64
SwapTotalBytes uint64
SwapUsedBytes uint64
Status Status
Detail string
}
// Percent, UsedText and TotalText render the reading for the template; see
// CPU.Percent for why the bar's value travels as an attribute.
func (m Memory) Percent() int { return percent(m.UsedPct) }
func (m Memory) UsedText() string { return humanBytes(m.UsedBytes) }
func (m Memory) TotalText() string { return humanBytes(m.TotalBytes) }
func (m Memory) PctText() string { return fmt.Sprintf("%d%%", m.Percent()) }
// Interface is one network interface's traffic: the counters since the
// interface came up, and the throughput over the sampling window.
type Interface struct {
Name string
RxBytes uint64
TxBytes uint64
RxRate float64 // bytes per second, valid when the Network is Measured
TxRate float64
Measured bool
}
func (i Interface) InText() string { return humanBytes(i.RxBytes) }
func (i Interface) OutText() string { return humanBytes(i.TxBytes) }
func (i Interface) InRateText() string { return humanRate(i.RxRate) }
func (i Interface) OutRateText() string { return humanRate(i.TxRate) }
// Network is the traffic across every interface that has carried any, loopback
// excluded — loopback traffic is the container talking to itself (the panel to
// SQLite, Postfix to its milters) and says nothing about the link.
type Network struct {
Measured bool
Interfaces []Interface
RxRate float64
TxRate float64
Status Status
Detail string
}
func (n Network) InRateText() string { return humanRate(n.RxRate) }
func (n Network) OutRateText() string { return humanRate(n.TxRate) }
// MachineSampler reads those counters. Its zero value is ready to use and it is
// safe for concurrent use, but one sampler has to be shared by every caller:
// the rates are measured against the reading the previous call left behind, so
// a fresh sampler per request would never have anything to compare against.
type MachineSampler struct {
// procRoot replaces /proc in tests; empty means the real one.
procRoot string
mu sync.Mutex
prevAt time.Time
prevCPU cpuTimes
prevNet map[string]netCounters
}
func (m *MachineSampler) root() string {
if m.procRoot == "" {
return "/proc"
}
return m.procRoot
}
// cpuTimes is the aggregate of /proc/stat's "cpu" line: all time accounted for,
// and the part of it the processor spent doing nothing.
type cpuTimes struct {
total uint64
idle uint64
}
// netCounters is one interface's byte counters from /proc/net/dev.
type netCounters struct {
rx uint64
tx uint64
}
// Sample reads the current counters and reports usage since the previous call.
// Like every other check here it never fails: a counter that cannot be read
// becomes an unknown status with an explanation, so a kernel that does not
// publish one of these files (or a panel run outside Linux for development)
// costs one line of the card rather than the page.
func (m *MachineSampler) Sample() Machine {
root := m.root()
now := time.Now()
cpuNow, logical, cpuErr := readCPUTimes(root)
cores, threads := readCPUTopology(root)
if threads == 0 {
threads = logical
}
if cores == 0 {
cores = threads
}
netNow, netErr := readNetDev(root)
m.mu.Lock()
prevAt, prevCPU, prevNet := m.prevAt, m.prevCPU, m.prevNet
if cpuErr == nil {
m.prevCPU = cpuNow
}
if netErr == nil {
m.prevNet = netNow
}
if cpuErr == nil || netErr == nil {
m.prevAt = now
}
m.mu.Unlock()
// A window of zero (two calls in the same instant) would divide by zero;
// one longer than machineSampleWindow is no longer a description of now.
window := now.Sub(prevAt)
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
mach := Machine{
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
Memory: readMemory(root),
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
}
if fresh {
mach.Window = window
}
mach.Status = Worst(mach.CPU.Status, mach.Memory.Status, mach.Network.Status)
return mach
}
// cpuUsage grades the processor over the window. Detail carries only the
// core and thread counts — load average and busy prose stay out of the Status
// card (see roadmap panel-docs for operator-facing explanation later).
func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
c := CPU{Cores: cores, Threads: threads}
if !isZeroLoad(load) {
c.Load, c.HasLoad = load, true
}
if err != nil {
c.Status = StatusUnknown
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
return c
}
c.Detail = c.contextText()
if !fresh || cur.total <= prev.total {
c.Status = StatusUnknown
return c
}
total := cur.total - prev.total
idle := uint64(0)
if cur.idle > prev.idle {
idle = cur.idle - prev.idle
}
if idle > total {
idle = total
}
c.Measured = true
c.BusyPct = 100 * float64(total-idle) / float64(total)
if c.BusyPct >= cpuWarnPct {
c.Status = StatusWarn
} else {
c.Status = StatusOK
}
return c
}
// contextText is the CPU detail column: physical cores and logical threads.
func (c CPU) contextText() string {
switch {
case c.Cores > 0 && c.Threads > 0:
return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
case c.Threads > 0:
return fmt.Sprintf("%d threads", c.Threads)
case c.Cores > 0:
return fmt.Sprintf("%d cores", c.Cores)
default:
return ""
}
}
// readMemory reports main memory from /proc/meminfo. Used is derived from
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
// MemFree on a healthy machine is near zero and would report a permanent
// emergency. MemAvailable is the kernel's own estimate of what a new workload
// could actually get (proc(5), /proc/meminfo, MemAvailable "since Linux
// 3.14": "An estimate of how much memory is available for starting new
// applications, without swapping").
func readMemory(root string) Memory {
var m Memory
fields, err := readMeminfo(root)
if err != nil {
m.Status = StatusUnknown
m.Detail = "The kernel's memory counters (/proc/meminfo) could not be read here."
return m
}
total, available := fields["MemTotal"], fields["MemAvailable"]
if total == 0 {
m.Status = StatusUnknown
m.Detail = "/proc/meminfo does not report a total memory size."
return m
}
if available > total {
available = total
}
m.Measured = true
m.TotalBytes = total
m.AvailableBytes = available
m.UsedBytes = total - available
m.UsedPct = 100 * float64(m.UsedBytes) / float64(total)
m.SwapTotalBytes = fields["SwapTotal"]
if swapFree := fields["SwapFree"]; m.SwapTotalBytes > swapFree {
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
}
detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
if m.SwapTotalBytes > 0 {
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
}
switch {
case m.UsedPct >= memErrorPct:
m.Status = StatusError
m.Detail = detail + " Memory is exhausted; the kernel kills processes to reclaim it, and Postfix or the panel are candidates."
case m.UsedPct >= memWarnPct:
m.Status = StatusWarn
m.Detail = detail + " Little headroom left."
default:
m.Status = StatusOK
m.Detail = detail
}
return m
}
// networkUsage turns two readings of the interface counters into throughput.
// It never grades: there is no usage figure that is wrong for a mail server, so
// the row is informational and only reports "unknown" when the counters are
// unreadable.
func networkUsage(prev, cur map[string]netCounters, window time.Duration, fresh bool, err error) Network {
var n Network
if err != nil {
n.Status = StatusUnknown
n.Detail = "The kernel's network counters (/proc/net/dev) could not be read here."
return n
}
names := make([]string, 0, len(cur))
for name := range cur {
names = append(names, name)
}
// Map order is random, and this table is re-rendered every few seconds:
// without a sort the rows would shuffle under the reader.
sort.Strings(names)
n.Measured = fresh
for _, name := range names {
c := cur[name]
// An interface that has never carried a byte is a veth or a bridge
// the deployment happens to have, not part of the mail path.
if c.rx == 0 && c.tx == 0 {
continue
}
iface := Interface{Name: name, RxBytes: c.rx, TxBytes: c.tx, Measured: fresh}
if fresh {
p := prev[name]
secs := window.Seconds()
// Counters only go up; a drop means the interface (or the
// container) was recreated, so there is no rate to report.
if c.rx >= p.rx {
iface.RxRate = float64(c.rx-p.rx) / secs
}
if c.tx >= p.tx {
iface.TxRate = float64(c.tx-p.tx) / secs
}
n.RxRate += iface.RxRate
n.TxRate += iface.TxRate
}
n.Interfaces = append(n.Interfaces, iface)
}
n.Status = StatusOK
switch {
case len(n.Interfaces) == 0:
n.Detail = "No interface outside loopback has carried any traffic."
case !fresh:
n.Detail = "Measuring — this reading sets the baseline; the next refresh has the throughput."
}
return n
}
// readCPUTimes returns the aggregate processor times and the number of cores
// from /proc/stat. The times are in USER_HZ ticks, which cancel out because
// only their ratio is used.
func readCPUTimes(root string) (cpuTimes, int, error) {
data, err := os.ReadFile(filepath.Join(root, "stat"))
if err != nil {
return cpuTimes{}, 0, err
}
var (
times cpuTimes
cores int
found bool
)
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
continue
}
if fields[0] != "cpu" {
cores++ // cpu0, cpu1, … — one line per online logical CPU
continue
}
// user nice system idle iowait irq softirq steal (guest fields are
// already counted inside user/nice, so they are left out). Field
// order: proc(5), /proc/stat. That guest time is in user/nice too:
// Linux kernel/sched/cputime.c, account_guest_time() adds it to
// CPUTIME_USER (or CPUTIME_NICE) as well as CPUTIME_GUEST (torvalds/
// linux master read 2026-10-14; the kernel is the host's).
for i, f := range fields[1:] {
if i >= 8 {
break
}
v, err := strconv.ParseUint(f, 10, 64)
if err != nil {
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: unreadable cpu time %q", f)
}
times.total += v
// idle plus iowait: both are time the processor had nothing to
// run, and separating them tells the reader nothing here.
if i == 3 || i == 4 {
times.idle += v
}
}
found = true
}
if !found {
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: no aggregate cpu line")
}
return times, cores, nil
}
// readCPUTopology returns physical core and logical thread counts from
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
// back to the cpuN count from /proc/stat. The keys are x86's: Linux
// arch/x86/kernel/cpu/proc.c, show_cpuinfo() prints "processor" and, on SMP,
// "physical id" and "core id" (show_cpuinfo_core(); torvalds/linux master
// read 2026-10-14). proc(5) says only that the items are architecture
// dependent; where they are missing each processor counts as a core.
func readCPUTopology(root string) (cores, threads int) {
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
if err != nil {
return 0, 0
}
type coreKey struct{ phys, core int }
seen := map[coreKey]bool{}
var (
inCPU bool
phys = -1
core = -1
idx int
)
flush := func() {
if !inCPU {
return
}
p, c := phys, core
if p < 0 {
p = idx
}
if c < 0 {
c = idx
}
seen[coreKey{p, c}] = true
inCPU, phys, core = false, -1, -1
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
flush()
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "processor":
flush()
inCPU = true
threads++
idx = threads - 1
if n, err := strconv.Atoi(val); err == nil {
idx = n
}
case "physical id":
if n, err := strconv.Atoi(val); err == nil {
phys = n
}
case "core id":
if n, err := strconv.Atoi(val); err == nil {
core = n
}
}
}
flush()
if threads == 0 {
return 0, 0
}
return len(seen), threads
}
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
// /proc/loadavg simply has none reported, so the failure is a zero value rather
// than an error.
func readLoadAvg(root string) [3]float64 {
var load [3]float64
data, err := os.ReadFile(filepath.Join(root, "loadavg"))
if err != nil {
return load
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return load
}
for i := range load {
v, err := strconv.ParseFloat(fields[i], 64)
if err != nil {
return [3]float64{}
}
load[i] = v
}
return load
}
func isZeroLoad(load [3]float64) bool {
return load == [3]float64{}
}
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
// The file reports kB (kibibytes, despite the label) for these fields: Linux
// fs/proc/meminfo.c, show_val_kb() prints pages << (PAGE_SHIFT - 10)
// (torvalds/linux master read 2026-10-14).
func readMeminfo(root string) (map[string]uint64, error) {
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
if err != nil {
return nil, err
}
want := map[string]bool{"MemTotal": true, "MemAvailable": true, "SwapTotal": true, "SwapFree": true}
out := make(map[string]uint64, len(want))
for _, line := range strings.Split(string(data), "\n") {
name, rest, ok := strings.Cut(line, ":")
if !ok || !want[name] {
continue
}
fields := strings.Fields(rest)
if len(fields) == 0 {
continue
}
v, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
continue
}
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
v *= 1024
}
out[name] = v
}
if len(out) == 0 {
return nil, fmt.Errorf("/proc/meminfo: no readable fields")
}
return out, nil
}
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
// name is separated by a colon, which may or may not have a space after it, so
// the split is on the colon and not on whitespace. proc(5), /proc/net/dev:
// eight receive columns (bytes first), then the transmit ones (bytes first).
// Linux net/core/net-procfs.c, dev_seq_printf_stats(): "%6s: %7llu ..." in
// master read 2026-10-14; the host's kernel may be older, and the colon is
// all this relies on (net/core/dev.c, dev_valid_name() refuses ':' in a
// device name).
func readNetDev(root string) (map[string]netCounters, error) {
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
if err != nil {
return nil, err
}
out := make(map[string]netCounters)
for _, line := range strings.Split(string(data), "\n") {
name, rest, ok := strings.Cut(line, ":")
name = strings.TrimSpace(name)
if !ok || name == "" || name == "lo" || strings.Contains(name, " ") {
continue // header lines carry no name, or several words
}
fields := strings.Fields(rest)
if len(fields) < 9 {
continue
}
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out[name] = netCounters{rx: rx, tx: tx}
}
return out, nil
}
// percent rounds a 0–100 reading to a whole number and clamps it, so a bar's
// value attribute is always inside the range the element declares.
func percent(v float64) int {
switch {
case v <= 0:
return 0
case v >= 100:
return 100
default:
return int(v + 0.5)
}
}
// humanBytes renders a byte count in the binary units memory and traffic are
// conventionally read in.
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit && exp < 4; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
}
// humanRate renders throughput in bytes per second, to match the totals beside
// it rather than the bits per second a link is sold in.
func humanRate(perSec float64) string {
if perSec < 0 {
perSec = 0
}
return humanBytes(uint64(perSec+0.5)) + "/s"
}