Files
selfpost/build/crashexit_test.go
T
mixandClaude Opus 5.5 a39eda5a0b audit 5: findings of the two reviews
- The e2e's numeric-settings test ran a container that passed every gate
  and never ended: the good values are checked beside a bad one after
  them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
  kills anvil, a zero milter timeout smtpd) and one past an int, in
  seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
  reads into a queue at once and drops past 200 lines a second, saying
  how many (security F1, F2; a stuck relay would hold OpenDKIM in
  syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
  mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
  what else exits the container; security.md the restart after five panel
  exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
  (Groups 104 998), so it did reach the supervisor socket before this
  stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:14 +03:00

96 lines
3.5 KiB
Go

// Package build holds the image's scripts; this file tests crashexit.py, the
// supervisor event listener that takes the container down.
package build
import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"testing"
)
// event is one event as supervisord writes it to a listener's stdin
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
// payload's length, then the payload).
func event(name, payload string) string {
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
}
// runListener feeds events to crashexit.py in dry run and returns what it
// wrote to stderr.
func runListener(t *testing.T, events ...string) string {
t.Helper()
// python3 on Linux; on Windows "python3" may be the Store's stub, which
// runs nothing.
names := []string{"python3", "python"}
if runtime.GOOS == "windows" {
names = []string{"python", "python3"}
}
python := ""
for _, n := range names {
if p, err := exec.LookPath(n); err == nil {
python = p
break
}
}
if python == "" {
t.Skip("no python")
}
cmd := exec.Command(python, "crashexit.py")
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
}
// Python on Windows writes its text stdout with \r\n.
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
}
return stderr.String()
}
// A program on the mail path that keeps crashing after its startsecs - never
// FATAL - takes the container down at the fifth unexpected exit (audit
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
func TestCrashexitCountsCrashes(t *testing.T) {
exit := func(name, expected string) string {
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
}
var four []string
for i := 0; i < 4; i++ {
four = append(four, exit("panel", "0"))
}
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
t.Errorf("four crashes shut down:\n%s", got)
}
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
t.Errorf("five crashes did not shut down:\n%s", got)
}
var calm []string
for i := 0; i < 6; i++ {
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
}
calm = append(calm, exit("opendkim", "0"), exit("opendkim", "0"), exit("opendkim", "0"))
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
}
}
// FATAL of a mail-path program shuts down at once; of a spared one, not.
func TestCrashexitFatal(t *testing.T) {
fatal := func(name string) string {
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
}
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
t.Errorf("postfix FATAL:\n%s", got)
}
if got := runListener(t, fatal("logrotate"), fatal("syslog-relay")); strings.Contains(got, "would shut down") {
t.Errorf("a spared program's FATAL shut down:\n%s", got)
}
}