Seen by the owner on the test server, on real data (2026-10-10).
- Overview's Processes card said "7 of 7 running" with six programs
running and postfix-reload idle. The card counts the programs that
are meant to stay up; a one-shot helper (health.Process.OneShot) is
left out of both numbers and named only when it is in trouble.
- The Milter sockets card clipped its second line ("OpenDKIM,
send-log,...") once the spam filter was the third socket. The line
names the sockets that do not answer when some do not; with all
answering it is their names, or "incl. the spam filter" - the
mockup's words - when the filter is among them.
- A real 2048-bit DKIM value did not fit the three rows of its field
and scrolled inside it. The field is as tall as the value needs
(3 to 12 rows).
The fixtures had a shortened key, two sockets and no one-shot program,
so none of this showed in the evidence screenshots.
go test -count=1 ./internal/health/... ./internal/web/...: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
3.4 KiB
Go
106 lines
3.4 KiB
Go
package health
|
|
|
|
import (
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// supervisorConf is the supervisord configuration the panel's other control
|
|
// calls already address (see internal/postfix and internal/domain).
|
|
const supervisorConf = "/etc/supervisor/supervisord.conf"
|
|
|
|
// oneShotPrograms are supervisord entries that are meant to sit idle: they are
|
|
// started on demand and exit immediately, so STOPPED/EXITED is their healthy
|
|
// state rather than a fault (see build/supervisord.conf).
|
|
var oneShotPrograms = map[string]bool{
|
|
"postfix-reload": true,
|
|
}
|
|
|
|
// Process is one supervised program as supervisord reports it.
|
|
type Process struct {
|
|
Name string
|
|
State string // supervisord's own state word, e.g. RUNNING
|
|
Detail string // the rest of the line: pid/uptime, or exit information
|
|
Status Status
|
|
// OneShot marks a program that is started on demand and exits at once
|
|
// (oneShotPrograms): idle is its healthy state, and it is not one of the
|
|
// programs that are expected to be running.
|
|
OneShot bool
|
|
}
|
|
|
|
// Processes returns the state of every supervised program (architecture.md §
|
|
// Image and processes — the three processes plus the reload/cert/logrotate
|
|
// helpers).
|
|
//
|
|
// The command takes fixed arguments and no user input, so it never goes through
|
|
// a shell (security.md). `supervisorctl status` deliberately exits non-zero when
|
|
// some program is not running, so the output is parsed first and the exit status
|
|
// only matters when nothing could be parsed from it.
|
|
func Processes() ([]Process, error) {
|
|
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
|
|
out, err := cmd.CombinedOutput()
|
|
procs := parseProcesses(string(out))
|
|
if len(procs) == 0 {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("supervisorctl status: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil, fmt.Errorf("supervisorctl status: no programs reported")
|
|
}
|
|
return procs, nil
|
|
}
|
|
|
|
// supervisorStates are the state words supervisord prints. Lines whose second
|
|
// field is not one of them are not status lines (banners, error text) and are
|
|
// skipped, so unexpected output cannot masquerade as a process.
|
|
var supervisorStates = map[string]bool{
|
|
"STOPPED": true,
|
|
"STARTING": true,
|
|
"RUNNING": true,
|
|
"BACKOFF": true,
|
|
"STOPPING": true,
|
|
"EXITED": true,
|
|
"FATAL": true,
|
|
"UNKNOWN": true,
|
|
}
|
|
|
|
// parseProcesses turns supervisorctl's tabular output into Process values. Each
|
|
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
|
|
func parseProcesses(out string) []Process {
|
|
var procs []Process
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 || !supervisorStates[fields[1]] {
|
|
continue
|
|
}
|
|
name, state := fields[0], fields[1]
|
|
procs = append(procs, Process{
|
|
Name: name,
|
|
State: state,
|
|
Detail: strings.Join(fields[2:], " "),
|
|
Status: processStatus(name, state),
|
|
OneShot: oneShotPrograms[name],
|
|
})
|
|
}
|
|
return procs
|
|
}
|
|
|
|
// processStatus grades a supervisord state. A one-shot program that is not
|
|
// running is healthy; anything else that is not RUNNING means a component of
|
|
// the mail path is down or flapping.
|
|
func processStatus(name, state string) Status {
|
|
switch state {
|
|
case "RUNNING":
|
|
return StatusOK
|
|
case "STARTING", "STOPPING":
|
|
return StatusWarn
|
|
case "STOPPED", "EXITED":
|
|
if oneShotPrograms[name] {
|
|
return StatusOK
|
|
}
|
|
return StatusError
|
|
default: // BACKOFF, FATAL, UNKNOWN
|
|
return StatusError
|
|
}
|
|
}
|