Files
selfpost/test/e2e/hostname_gate_test.go
T
mixeme 1639701de3
test / test (push) Has been cancelled
test(e2e): entrypoint ERR trap and richer CI failure logs
CI showed exit=1 in ~3s with empty docker logs after /data/opendkim was
created. Log each entrypoint step and trap ERR with LINENO; also read the
container LogPath and docker logs by container id when compose logs are empty.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:12:47 +03:00

202 lines
7.5 KiB
Go

package e2e
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// runEntrypoint runs the selfpost:e2e image's entrypoint standalone (no
// compose, no DNS/sink sidecars — the SELFPOST_HOSTNAME gate in
// build/entrypoint.sh runs and fails before anything else needs them) with
// hostnameEnv substituted for SELFPOST_HOSTNAME, and returns its combined
// output and whether it exited zero.
func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) {
t.Helper()
dataDir := t.TempDir()
// Entrypoint also chmod 755 /data; mirror that here so a pre-fix image
// still gets a traversable bind mount under Go's 0700 TempDir.
if err := os.Chmod(dataDir, 0o755); err != nil {
t.Fatalf("chmod data dir: %v", err)
}
args := []string{
"run", "--rm",
"-e", "SELFPOST_HOSTNAME=" + hostnameEnv,
"-v", dataDir + ":/data",
"selfpost:e2e",
}
cmd := exec.Command("docker", args...)
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
return string(out), err == nil
}
// TestHostnameGate is plan C.4 negative check 7: an empty or syntactically
// invalid SELFPOST_HOSTNAME must fail the container fast with an explanatory
// message (plan B.3), never a silent bad fallback. It runs the image directly
// rather than through the shared stand — the whole point is to exercise
// entrypoint.sh's gate before anything else in the container has a chance to
// start, so it does not depend on TestE2E's stack at all.
func TestHostnameGate(t *testing.T) {
cases := []struct {
name string
hostname string
want string
}{
{"empty", "", "SELFPOST_HOSTNAME is not set"},
{"scheme_and_port", "https://mail.example.com:465", "must be a bare hostname"},
{"bare_word_no_dot", "localhost", "fully-qualified domain name"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out, ok := runEntrypoint(t, c.hostname)
if ok {
t.Fatalf("container started with SELFPOST_HOSTNAME=%q, want a fatal exit\noutput:\n%s", c.hostname, out)
}
if !strings.Contains(out, c.want) {
t.Fatalf("SELFPOST_HOSTNAME=%q: output does not mention %q:\n%s", c.hostname, c.want, out)
}
})
}
t.Run("valid_hostname_starts", func(t *testing.T) {
out, ok := runEntrypointBackground(t, "mail.example.test")
if !ok {
t.Fatalf("container with a valid SELFPOST_HOSTNAME failed to start:\n%s", out)
}
})
}
// runEntrypointBackground starts the container detached and confirms
// supervisord came up (rather than crash-looping) within a short timeout, then
// removes it — the positive control for the gate cases above: a valid
// hostname must not be caught by the same checks.
func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) {
t.Helper()
dataDir := t.TempDir()
if err := os.Chmod(dataDir, 0o755); err != nil {
t.Fatalf("chmod data dir: %v", err)
}
certDir := t.TempDir()
if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil {
t.Fatalf("generate throwaway TLS cert: %v", err)
}
name := "selfpost-e2e-hostname-check"
_ = exec.Command("docker", "rm", "-f", name).Run()
defer exec.Command("docker", "rm", "-f", name).Run()
// #region agent log
if fi, err := os.Stat(dataDir); err == nil {
agentDebugLog("H1", "hostname_gate_test.go:pre-run", "host dataDir before docker run", map[string]any{
"mode": fmt.Sprintf("%04o", fi.Mode().Perm()),
"path": dataDir,
})
}
imgInspect, _ := exec.Command("docker", "image", "inspect", "selfpost:e2e",
"--format", "{{.Id}} created={{.Created}}").CombinedOutput()
agentDebugLog("H5", "hostname_gate_test.go:pre-run", "selfpost:e2e image", map[string]any{
"inspect": strings.TrimSpace(string(imgInspect)),
})
// #endregion
up := exec.Command("docker", "run", "-d", "--name", name,
"-e", "SELFPOST_HOSTNAME="+hostnameEnv,
"-v", dataDir+":/data",
"-v", certDir+":/etc/postfix/tls:ro",
"selfpost:e2e")
if out, err := up.CombinedOutput(); err != nil {
agentDebugLog("H2", "hostname_gate_test.go:run", "docker run -d failed", map[string]any{
"out": string(out), "err": err.Error(),
})
return string(out), false
}
err := waitFor("supervisord to report RUNNING processes", 20*time.Second, 300*time.Millisecond, func() (bool, error) {
out, err := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput()
if strings.Contains(string(out), "RUNNING") {
return true, nil
}
return false, err
})
// #region agent log
inspect, _ := exec.Command("docker", "inspect", name, "--format",
"status={{.State.Status}} exit={{.State.ExitCode}} err={{.State.Error}} oom={{.State.OOMKilled}} started={{.State.StartedAt}} finished={{.State.FinishedAt}}").CombinedOutput()
logs, logErr := exec.Command("docker", "logs", name).CombinedOutput()
// Exited containers sometimes yield empty `docker logs` in CI; also read
// the JSON-file log path docker recorded on the container.
logPathOut, _ := exec.Command("docker", "inspect", name, "--format", "{{.LogPath}}").CombinedOutput()
logPath := strings.TrimSpace(string(logPathOut))
logFileHead := ""
if logPath != "" {
if b, e := os.ReadFile(logPath); e == nil {
logFileHead = truncateForDebug(string(b), 4000)
if strings.TrimSpace(string(logs)) == "" && len(b) > 0 {
logs = b
}
} else {
logFileHead = "read LogPath: " + e.Error()
}
}
dataMode, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data").CombinedOutput()
opendkimStat, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data/opendkim").CombinedOutput()
keyTableStat, _ := exec.Command("docker", "exec", name, "stat", "-c", "%a %U:%G", "/data/opendkim/KeyTable").CombinedOutput()
supStatus, _ := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput()
hostDataListing := ""
if entries, e := os.ReadDir(dataDir); e == nil {
var parts []string
for _, ent := range entries {
p := filepath.Join(dataDir, ent.Name())
fi, _ := os.Stat(p)
mode := "?"
if fi != nil {
mode = fmt.Sprintf("%04o", fi.Mode().Perm())
}
parts = append(parts, ent.Name()+":"+mode)
}
hostDataListing = strings.Join(parts, ",")
}
agentDebugLog("H1", "hostname_gate_test.go:post-wait", "data permissions", map[string]any{
"dataMode": strings.TrimSpace(string(dataMode)),
"opendkimStat": strings.TrimSpace(string(opendkimStat)),
"keyTableStat": strings.TrimSpace(string(keyTableStat)),
"hostDataListing": hostDataListing,
})
agentDebugLog("H2", "hostname_gate_test.go:post-wait", "container inspect", map[string]any{
"inspect": strings.TrimSpace(string(inspect)),
"waitErr": fmt.Sprintf("%v", err),
"logErr": fmt.Sprintf("%v", logErr),
"logLen": len(logs),
"logPath": logPath,
"logFileHead": logFileHead,
})
agentDebugLog("H3", "hostname_gate_test.go:post-wait", "supervisor and logs", map[string]any{
"supervisorctl": strings.TrimSpace(string(supStatus)),
"logsHead": truncateForDebug(string(logs), 4000),
})
// #endregion
diag := string(logs)
if strings.TrimSpace(diag) == "" {
diag = "(empty docker logs)\ninspect: " + strings.TrimSpace(string(inspect)) +
"\nlogPath: " + logPath +
"\nlogFileHead: " + logFileHead +
"\nsupervisorctl: " + strings.TrimSpace(string(supStatus)) +
"\n/data mode: " + strings.TrimSpace(string(dataMode)) +
"\nhost /data: " + hostDataListing
}
return diag, err == nil
}
func truncateForDebug(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}