feat: implement C.4 — hermetic container e2e gate + native release build

Separate test/e2e Go module drives the shipped deploy/docker-compose.yml
(plus a test-only override: self-signed cert, low ports, isolated compose
project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as
an administrator and their applications would over HTTP/SMTP — covering the
class of failure unit tests can't see (container wiring). Positive path:
setup -> login -> domain -> DKIM record published into the fake zone ->
application -> SMTP AUTH send -> DKIM verified against the DNS-published
key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay,
sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter
fail-open, SELFPOST_HOSTNAME gate, session survives docker restart.

release.yml moves off qemu to a native per-arch build (amd64/arm64), each
gated by this suite before its tag is pushed and merged into the version
manifest.

Verified green on selfpost.mixfed.ru via `make e2e`; go vet/gofmt clean in
both modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:29:14 +03:00
parent 3b0a71cf11
commit c8b067a686
21 changed files with 1674 additions and 17 deletions
+61
View File
@@ -0,0 +1,61 @@
package e2e
import (
"fmt"
"strings"
"time"
)
// wantRunning are the supervisord programs that must be RUNNING once the
// container is up (build/supervisord.conf). postfix-reload is deliberately
// excluded: autostart=false, its healthy resting state is STOPPED.
var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"}
// checkSupervisorProcesses shells into the container directly (not through the
// panel's /status page) so it works before an administrator account even
// exists — this is the first thing the harness checks after `docker compose
// up` (plan C.4). Programs take a moment to leave STARTING right after the
// container starts, so this polls rather than checking once.
func checkSupervisorProcesses(s *stack) error {
var lastErr error
err := waitFor("all supervised programs to reach their steady state", 20*time.Second, 500*time.Millisecond, func() (bool, error) {
out, execErr := s.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status")
// supervisorctl exits non-zero when any program is not RUNNING, which
// is expected for postfix-reload — parse the output regardless of exit
// status.
states := parseSupervisorStatus(out)
if len(states) == 0 {
lastErr = fmt.Errorf("supervisorctl status produced nothing to parse: %v\n%s", execErr, out)
return false, lastErr
}
for _, name := range wantRunning {
state, ok := states[name]
if !ok {
lastErr = fmt.Errorf("program %q not reported by supervisorctl:\n%s", name, out)
return false, lastErr
}
if state != "RUNNING" {
lastErr = fmt.Errorf("program %q is %s, want RUNNING:\n%s", name, state, out)
return false, lastErr
}
}
if state, ok := states["postfix-reload"]; ok && state != "STOPPED" {
lastErr = fmt.Errorf("program postfix-reload is %s, want STOPPED (autostart=false):\n%s", state, out)
return false, lastErr
}
return true, nil
})
return err
}
func parseSupervisorStatus(out string) map[string]string {
states := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
states[fields[0]] = fields[1]
}
return states
}