diff --git a/.github/workflows/tests-rqlite.yml b/.github/workflows/tests-rqlite.yml index 46f66b5..024c47e 100644 --- a/.github/workflows/tests-rqlite.yml +++ b/.github/workflows/tests-rqlite.yml @@ -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" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ee70e8..1c862ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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=. diff --git a/cmd/readyz.go b/cmd/readyz.go index 07449e4..ce56bb6 100644 --- a/cmd/readyz.go +++ b/cmd/readyz.go @@ -1,20 +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 + useHTTPS bool + readyzWait bool + readyzTimeout time.Duration ) // readyzCmd represents the healthcheck command @@ -22,33 +21,25 @@ 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 := healthcheck.URI(config.HTTPListen, config.Webroot, useHTTPS) + client := healthcheck.NewClient() + + var err error + if readyzWait { + err = healthcheck.Wait(client, uri, readyzTimeout) + } else { + err = healthcheck.Check(client, uri) } - 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} - - res, err := client.Get(uri) - if err != nil || res.StatusCode != 200 { + if err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } }, @@ -74,4 +65,6 @@ func init() { readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port") readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API") readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)") + readyzCmd.Flags().BoolVar(&readyzWait, "wait", readyzWait, "Wait until Mailpit is ready instead of checking once") + readyzCmd.Flags().DurationVar(&readyzTimeout, "timeout", 30*time.Second, "Maximum time to wait when --wait is set") } diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go new file mode 100644 index 0000000..989ff71 --- /dev/null +++ b/internal/healthcheck/healthcheck.go @@ -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) + } +} diff --git a/internal/healthcheck/healthcheck_test.go b/internal/healthcheck/healthcheck_test.go new file mode 100644 index 0000000..d77c63c --- /dev/null +++ b/internal/healthcheck/healthcheck_test.go @@ -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) + } + }) + } +}