fix(e2e): wait for panel /healthz before setup (v1.0.0 gate)
Supervisor RUNNING is not enough: the setup-token file is written before ListenAndServe, and Docker host-port publish can lag. Poll /healthz first, and stop ordered TestE2E steps after a failure so a nil panel cannot panic and mask the real error. Co-Authored-By: Composer <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,6 +32,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
||||||
|
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
||||||
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
- A send-log row could stay `queued` forever after the container was recreated.
|
- A send-log row could stay `queued` forever after the container was recreated.
|
||||||
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
||||||
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||||
|
|||||||
+25
-12
@@ -87,13 +87,26 @@ type scenario struct {
|
|||||||
func TestE2E(t *testing.T) {
|
func TestE2E(t *testing.T) {
|
||||||
sc := &scenario{}
|
sc := &scenario{}
|
||||||
|
|
||||||
t.Run("startup_processes_running", func(t *testing.T) {
|
// Ordered scenario: each step needs state from earlier ones. A failed
|
||||||
|
// subtest must stop the rest — otherwise sc.panel stays nil and the next
|
||||||
|
// step panics, masking the real failure (as seen on the v1.0.0 release CI).
|
||||||
|
run := func(name string, fn func(*testing.T)) {
|
||||||
|
if t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Run(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
run("startup_processes_running", func(t *testing.T) {
|
||||||
if err := checkSupervisorProcesses(h); err != nil {
|
if err := checkSupervisorProcesses(h); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := waitForPanelReady(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("setup_and_login", func(t *testing.T) {
|
run("setup_and_login", func(t *testing.T) {
|
||||||
token, err := readSetupToken(h.stageDir)
|
token, err := readSetupToken(h.stageDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read setup token: %v", err)
|
t.Fatalf("read setup token: %v", err)
|
||||||
@@ -111,7 +124,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.panel = p
|
sc.panel = p
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("add_domain_and_publish_dkim", func(t *testing.T) {
|
run("add_domain_and_publish_dkim", func(t *testing.T) {
|
||||||
id, err := sc.panel.addDomain(senderDomain)
|
id, err := sc.panel.addDomain(senderDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("add domain: %v", err)
|
t.Fatalf("add domain: %v", err)
|
||||||
@@ -137,7 +150,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.zoneRecords = records
|
sc.zoneRecords = records
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("add_application", func(t *testing.T) {
|
run("add_application", func(t *testing.T) {
|
||||||
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
|
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("add application: %v", err)
|
t.Fatalf("add application: %v", err)
|
||||||
@@ -145,7 +158,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.appLogin, sc.appPassword = login, password
|
sc.appLogin, sc.appPassword = login, password
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("send_verify_dkim_and_status", func(t *testing.T) {
|
run("send_verify_dkim_and_status", func(t *testing.T) {
|
||||||
token := uniqueToken("positive")
|
token := uniqueToken("positive")
|
||||||
res := attemptSend(sendAttempt{
|
res := attemptSend(sendAttempt{
|
||||||
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||||
@@ -179,25 +192,25 @@ func TestE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
||||||
testLevel2RateLimit(t, sc)
|
testLevel2RateLimit(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_sender_login_mismatch", func(t *testing.T) {
|
run("negative_sender_login_mismatch", func(t *testing.T) {
|
||||||
testSenderLoginMismatch(t, sc)
|
testSenderLoginMismatch(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_no_auth_rejected", func(t *testing.T) {
|
run("negative_no_auth_rejected", func(t *testing.T) {
|
||||||
testNoAuthRejected(t, sc)
|
testNoAuthRejected(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_foreign_relay_rejected", func(t *testing.T) {
|
run("negative_foreign_relay_rejected", func(t *testing.T) {
|
||||||
testForeignRelayRejected(t, sc)
|
testForeignRelayRejected(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_journal_milter_fail_open", func(t *testing.T) {
|
run("negative_journal_milter_fail_open", func(t *testing.T) {
|
||||||
testJournalMilterFailOpen(t, sc)
|
testJournalMilterFailOpen(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("session_survives_restart", func(t *testing.T) {
|
run("session_survives_restart", func(t *testing.T) {
|
||||||
testSessionSurvivesRestart(t, sc)
|
testSessionSurvivesRestart(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_level1_ratelimit", func(t *testing.T) {
|
run("negative_level1_ratelimit", func(t *testing.T) {
|
||||||
testLevel1RateLimit(t, sc)
|
testLevel1RateLimit(t, sc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ func newPanelClient() (*panelClient, error) {
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForPanelReady polls the host-published panel port until /healthz
|
||||||
|
// returns 200. Supervisor reporting the panel process RUNNING is not enough:
|
||||||
|
// the setup-token file is written in Start() before ListenAndServe, and
|
||||||
|
// Docker's host-port publish can lag the in-container bind — either race
|
||||||
|
// makes the first setup POST fail and leaves sc.panel nil for later steps.
|
||||||
|
func waitForPanelReady() error {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) {
|
||||||
|
resp, err := client.Get(panelBaseURL + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind
|
// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind
|
||||||
// mounted at stageDir/data/setup-token) and returns just the token.
|
// mounted at stageDir/data/setup-token) and returns just the token.
|
||||||
func readSetupToken(stageDir string) (string, error) {
|
func readSetupToken(stageDir string) (string, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user