1639701de3
test / test (push) Has been cancelled
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>
202 lines
7.5 KiB
Go
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] + "…"
|
|
}
|