mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-27 14:36:07 +00:00
Test: Add readyz tests
This commit is contained in:
2
.github/workflows/tests-rqlite.yml
vendored
2
.github/workflows/tests-rqlite.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache-dependency-path: "**/*.sum"
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/healthcheck -v
|
||||
env:
|
||||
# set Mailpit to use the rqlite service container
|
||||
MP_DATABASE: "http://localhost:4001"
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
# https://olegk.dev/github-actions-and-go
|
||||
run: gofmt -s -w . && git diff --exit-code
|
||||
- name: Run Go tests
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid ./internal/healthcheck -v
|
||||
- name: Run Go benchmarking
|
||||
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/healthcheck"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
useHTTPS bool
|
||||
readyzWait bool
|
||||
readyzTimeout time.Duration
|
||||
readyzPollEvery = time.Second
|
||||
useHTTPS bool
|
||||
readyzWait bool
|
||||
readyzTimeout time.Duration
|
||||
)
|
||||
|
||||
// readyzCmd represents the healthcheck command
|
||||
@@ -25,76 +21,30 @@ var readyzCmd = &cobra.Command{
|
||||
Use: "readyz",
|
||||
Short: "Run a healthcheck to test if Mailpit is running",
|
||||
Long: `This command connects to the /readyz endpoint of a running Mailpit server
|
||||
and exits with a status of 0 if the connection is successful, else with a
|
||||
and exits with a status of 0 if the connection is successful, else with a
|
||||
status 1 if unhealthy.
|
||||
|
||||
If running within Docker, it should automatically detect environment
|
||||
settings to determine the HTTP bind interface & port.
|
||||
`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
|
||||
proto := "http"
|
||||
if useHTTPS {
|
||||
proto = "https"
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
|
||||
|
||||
conf := &http.Transport{
|
||||
IdleConnTimeout: time.Second * 5,
|
||||
ExpectContinueTimeout: time.Second * 5,
|
||||
TLSHandshakeTimeout: time.Second * 5,
|
||||
// do not verify TLS if this instance is using HTTPS as we connect using IP
|
||||
// so won't be the same as the cert
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
|
||||
}
|
||||
client := &http.Client{Transport: conf}
|
||||
uri := healthcheck.URI(config.HTTPListen, config.Webroot, useHTTPS)
|
||||
client := healthcheck.NewClient()
|
||||
|
||||
var err error
|
||||
if readyzWait {
|
||||
if err := waitForReady(client, uri, readyzTimeout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
err = healthcheck.Wait(client, uri, readyzTimeout)
|
||||
} else {
|
||||
err = healthcheck.Check(client, uri)
|
||||
}
|
||||
|
||||
if err := checkReady(client, uri); err != nil {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func checkReady(client *http.Client, uri string) error {
|
||||
res, err := client.Get(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %s", res.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForReady(client *http.Client, uri string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
if err := checkReady(client, uri); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out after %s waiting for Mailpit to become ready", timeout)
|
||||
}
|
||||
|
||||
time.Sleep(readyzPollEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(readyzCmd)
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckReady(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := checkReady(srv.Client(), srv.URL); err != nil {
|
||||
t.Fatalf("checkReady() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForReadyRetriesUntilSuccess(t *testing.T) {
|
||||
oldPoll := readyzPollEvery
|
||||
readyzPollEvery = time.Millisecond
|
||||
t.Cleanup(func() { readyzPollEvery = oldPoll })
|
||||
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := waitForReady(srv.Client(), srv.URL, 100*time.Millisecond); err != nil {
|
||||
t.Fatalf("waitForReady() error = %v", err)
|
||||
}
|
||||
|
||||
if calls < 2 {
|
||||
t.Fatalf("waitForReady() calls = %d, want at least 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForReadyTimesOut(t *testing.T) {
|
||||
oldPoll := readyzPollEvery
|
||||
readyzPollEvery = time.Millisecond
|
||||
t.Cleanup(func() { readyzPollEvery = oldPoll })
|
||||
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := waitForReady(srv.Client(), srv.URL, 5*time.Millisecond); err == nil {
|
||||
t.Fatal("waitForReady() error = nil, want timeout")
|
||||
}
|
||||
|
||||
if calls == 0 {
|
||||
t.Fatal("waitForReady() did not call the endpoint")
|
||||
}
|
||||
}
|
||||
70
internal/healthcheck/healthcheck.go
Normal file
70
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Package healthcheck probes a running Mailpit instance's /readyz endpoint.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PollInterval is the delay between polls in Wait. Exported as a variable so
|
||||
// tests can shorten it.
|
||||
var PollInterval = time.Second
|
||||
|
||||
// URI builds the readyz URL from a listen address, webroot, and TLS flag.
|
||||
func URI(listen, webroot string, https bool) string {
|
||||
proto := "http"
|
||||
if https {
|
||||
proto = "https"
|
||||
}
|
||||
root := strings.TrimRight(path.Join("/", webroot, "/"), "/") + "/"
|
||||
return fmt.Sprintf("%s://%s%sreadyz", proto, listen, root)
|
||||
}
|
||||
|
||||
// NewClient returns an HTTP client suitable for probing a Mailpit readyz
|
||||
// endpoint. TLS verification is disabled because probes typically connect via
|
||||
// IP, which won't match the server certificate.
|
||||
func NewClient() *http.Client {
|
||||
return &http.Client{Transport: &http.Transport{
|
||||
IdleConnTimeout: time.Second * 5,
|
||||
ExpectContinueTimeout: time.Second * 5,
|
||||
TLSHandshakeTimeout: time.Second * 5,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
|
||||
}}
|
||||
}
|
||||
|
||||
// Check makes a single readiness probe. Returns nil if the server responds
|
||||
// with 200 OK.
|
||||
func Check(client *http.Client, uri string) error {
|
||||
res, err := client.Get(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %s", res.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait polls uri until Check succeeds or timeout elapses.
|
||||
func Wait(client *http.Client, uri string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
if err := Check(client, uri); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out after %s waiting for Mailpit to become ready", timeout)
|
||||
}
|
||||
|
||||
time.Sleep(PollInterval)
|
||||
}
|
||||
}
|
||||
88
internal/healthcheck/healthcheck_test.go
Normal file
88
internal/healthcheck/healthcheck_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := Check(srv.Client(), srv.URL); err != nil {
|
||||
t.Fatalf("Check() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitRetriesUntilSuccess(t *testing.T) {
|
||||
oldPoll := PollInterval
|
||||
PollInterval = time.Millisecond
|
||||
t.Cleanup(func() { PollInterval = oldPoll })
|
||||
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := Wait(srv.Client(), srv.URL, 100*time.Millisecond); err != nil {
|
||||
t.Fatalf("Wait() error = %v", err)
|
||||
}
|
||||
|
||||
if calls < 2 {
|
||||
t.Fatalf("Wait() calls = %d, want at least 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTimesOut(t *testing.T) {
|
||||
oldPoll := PollInterval
|
||||
PollInterval = time.Millisecond
|
||||
t.Cleanup(func() { PollInterval = oldPoll })
|
||||
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if err := Wait(srv.Client(), srv.URL, 5*time.Millisecond); err == nil {
|
||||
t.Fatal("Wait() error = nil, want timeout")
|
||||
}
|
||||
|
||||
if calls == 0 {
|
||||
t.Fatal("Wait() did not call the endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listen string
|
||||
webroot string
|
||||
https bool
|
||||
want string
|
||||
}{
|
||||
{"plain", "127.0.0.1:8025", "", false, "http://127.0.0.1:8025/readyz"},
|
||||
{"https", "127.0.0.1:8025", "", true, "https://127.0.0.1:8025/readyz"},
|
||||
{"webroot", "127.0.0.1:8025", "/mailpit", false, "http://127.0.0.1:8025/mailpit/readyz"},
|
||||
{"webroot trailing slash", "127.0.0.1:8025", "/mailpit/", false, "http://127.0.0.1:8025/mailpit/readyz"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := URI(tc.listen, tc.webroot, tc.https); got != tc.want {
|
||||
t.Errorf("URI() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user