diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ab9c8..d9b459a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version master.cf service. After mail.log moved to `/data/log`, `postfix check` fatally rejected the path (default prefixes are only `/var` and `/dev/stdout`) and often left stderr/mail.log empty. +- e2e: read `/data/setup-token` via `docker compose exec` (file is `0600` + panel-owned; host `ReadFile` got permission denied on CI). Reclaim `/data` + ownership before TempDir/stage cleanup so panel/postfix UIDs do not fail + Go's `RemoveAll`. ## [1.0.0] - 2026-08-09 diff --git a/build/postfix-config.sh b/build/postfix-config.sh index 6d8a7be..c0df341 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -245,7 +245,7 @@ EOF # Validate the generated configuration; fail loudly if postconf produced # anything Postfix rejects, before the wrapper tries to start it. # #region agent log -pcstep "pre-check maillog=$(postconf -qh maillog_file 2>&1) prefixes=$(postconf -qh maillog_file_prefixes 2>&1) postlog=$(postconf -qM postlog/unix-dgram 2>&1)" +pcstep "pre-check maillog=$(postconf -h maillog_file 2>&1) prefixes=$(postconf -h maillog_file_prefixes 2>&1) postlog=$(postconf -M postlog/unix-dgram 2>&1)" # #endregion pcstep "postfix check" set +e @@ -260,8 +260,8 @@ if [ "$ec" -ne 0 ]; then cat "$MAIL_LOG_PATH" 2>&1 >&2 || true # #region agent log echo "postfix-config: --- postconf dump (debug) ---" >&2 - postconf -qh maillog_file maillog_file_prefixes 2>&1 >&2 || true - postconf -qM postlog/unix-dgram 2>&1 >&2 || true + postconf -h maillog_file maillog_file_prefixes 2>&1 >&2 || true + postconf -M postlog/unix-dgram 2>&1 >&2 || true # #endregion exit "$ec" fi diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go index ca654b0..cdb3fea 100644 --- a/test/e2e/hostname_gate_test.go +++ b/test/e2e/hostname_gate_test.go @@ -88,7 +88,14 @@ func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, s } name := "selfpost-e2e-hostname-check" _ = exec.Command("docker", "rm", "-f", name).Run() - defer exec.Command("docker", "rm", "-f", name).Run() + defer func() { + // Make bind-mounted /data deletable by Go's TempDir cleanup: the panel + // leaves setup-token/db/opendkim owned by container UIDs (CI failed the + // subtest on testing.go TempDir RemoveAll even when start succeeded). + _ = exec.Command("docker", "exec", name, "sh", "-c", + "chown -R root:root /data && chmod -R a+rwX /data").Run() + _ = exec.Command("docker", "rm", "-f", name).Run() + }() // #region agent log if fi, err := os.Stat(dataDir); err == nil { diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index bd845b7..4135598 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -54,6 +54,10 @@ func TestMain(m *testing.M) { fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc)) } } + // Panel/postfix-owned files under the /data bind mount outlive the + // container; reclaim ownership while selfpost is still up so a later + // prepareStage RemoveAll (or a local re-run) is not stuck on EACCES. + s.reclaimData() s.down() os.Exit(code) } @@ -107,7 +111,7 @@ func TestE2E(t *testing.T) { }) run("setup_and_login", func(t *testing.T) { - token, err := readSetupToken(h.stageDir) + token, err := readSetupToken(h) if err != nil { t.Fatalf("read setup token: %v", err) } diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index b65e70a..5fb6044 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -7,8 +7,6 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "os" - "path/filepath" "regexp" "strings" "time" @@ -60,26 +58,38 @@ func waitForPanelReady() error { }) } -// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind -// mounted at stageDir/data/setup-token) and returns just the token. -func readSetupToken(stageDir string) (string, error) { - var raw []byte +// readSetupToken reads the one-time setup URL from /data/setup-token inside +// the running selfpost container. The file is mode 0600 owned by the panel +// UID (security.md / setup.go); on a typical CI bind mount that is not the +// host runner's UID, so a host-side os.ReadFile returns permission denied +// even though the panel already wrote the token (CI: setup_and_login). +// Reading via compose exec matches docs/guide.md ("docker compose exec +// selfpost cat /data/setup-token"). +func readSetupToken(s *stack) (string, error) { + var raw string err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) { - b, err := os.ReadFile(filepath.Join(stageDir, "data", "setup-token")) + out, err := s.execIn("selfpost", "cat", "/data/setup-token") if err != nil { return false, err } - raw = b - return len(b) > 0, nil + raw = out + return strings.TrimSpace(out) != "", nil }) if err != nil { return "", err } - u, err := url.Parse(strings.TrimSpace(string(raw))) + u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return "", fmt.Errorf("parse setup token file: %w", err) } - return strings.TrimPrefix(u.Path, "/setup/"), nil + token := strings.TrimPrefix(u.Path, "/setup/") + // #region agent log + agentDebugLog("H9", "panel_client.go:readSetupToken", "setup token via docker exec", map[string]any{ + "tokenLen": len(token), + "rawLen": len(strings.TrimSpace(raw)), + }) + // #endregion + return token, nil } func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) { diff --git a/test/e2e/stack.go b/test/e2e/stack.go index f39bb06..879af14 100644 --- a/test/e2e/stack.go +++ b/test/e2e/stack.go @@ -106,6 +106,14 @@ func (s *stack) down() { _, _ = s.compose("down", "-v", "--remove-orphans") } +// reclaimData chowns/chmods the /data bind mount from inside the still-running +// selfpost container so the host test user can delete it afterwards. Files +// written as panel/postfix (setup-token 0600, opendkim tree, sqlite) otherwise +// leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage). +func (s *stack) reclaimData() { + _, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data") +} + // logs returns a service's combined stdout/stderr, for failure diagnostics. func (s *stack) logs(service string) string { out, _ := s.compose("logs", "--no-color", service) diff --git a/test/e2e/stage.go b/test/e2e/stage.go index c036a75..3f51391 100644 --- a/test/e2e/stage.go +++ b/test/e2e/stage.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" "os" + "os/exec" "path/filepath" "time" ) @@ -23,7 +24,7 @@ const selfpostHostname = "mail.e2e.test" // CoreDNS is authoritative for, and the sink-MX's dump directory. Called once // per run before `docker compose up`, so every run starts from a clean slate. func prepareStage(s *stack) error { - if err := os.RemoveAll(s.stageDir); err != nil { + if err := removeAllBestEffort(s.stageDir); err != nil { return fmt.Errorf("clean stage dir: %w", err) } dirs := []string{"data", "certs", "dns-stage", "mail-stage"} @@ -48,6 +49,17 @@ func prepareStage(s *stack) error { return writeZone(s.stageDir, nil) } +// removeAllBestEffort deletes path; if a previous run left container-UID files +// on the bind mount, a root alpine one-shot removes them first. +func removeAllBestEffort(path string) error { + if err := os.RemoveAll(path); err == nil { + return nil + } + _ = exec.Command("docker", "run", "--rm", "-v", path+":/wipe", "alpine:3.20", + "sh", "-c", "rm -rf /wipe/..?* /wipe/.[!.]* /wipe/*").Run() + return os.RemoveAll(path) +} + // writeSelfSignedCert generates a throwaway RSA key + self-signed certificate // for selfpostHostname, valid for a day — this stand never outlives that. // Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced,